标签归档:C

基于Mysql的流水日志记录系统

什么是流水日志?说白了,就是用户的操作日志,包括 who, when, what, how, why 等等。以一个论坛的发表流水日志为例,可能需要记录发表者的帐号ID,发表时间,发表IP,发表内容,发表类型,所在的主贴ID等等。有了这些数据,事后很容易进行分析和统计。

流水日志的重要性是不言而喻的。再我负责的工作中,有很多种类的流水日志需要记录,以供日后的查询和建模分析,并且这些流水日志要记录的内容也经常需要扩展。我们一般使用 Mysql 来记录这些数据。

记录流水日志看似是一件很简单地事情,直接写入数据库就可以了。但当数据量大起来之后,在几千条每秒的量级下,很多原来不是问题的问题,就变成了问题。

首先业务程序直接写数据库是行不通的,就需要搭建专门的流水日志记录服务,通过网络进行通信。这个服务也不能直接写数据库,要有专门的进程(或线程)负责处理请求,另外有进程(或线程)负责写入数据库。或者简单一点的话,先写入文件,然后再定时的写入数据库,但这样会有延迟。每次一条的进行数据库插入速度会很慢,可以通过批量插入或Load in file的方式,以提高写入的效率。

其次,一旦涉及到每天几亿条数据的量级,对于 Mysql 来说,全部放在一个表里也是行不通的,这就涉及到分表的操作。最简单的,按自然时间分表,比如每天的数据写一张表里。分得太细的不方便查询,分的太粗数据量太大,查询也慢。另外,在按时间切分数据表的基础上,有时还需要按照某个具体字段再次分表,以减少单个表的数据量,提高查询效率。比如按用户ID取模,每天再分100张表。

另外,如果需要对字段进行扩展,也是一个麻烦事,如果系统设计的不好,很可能会在升级的过程中丢失部分数据。

一开始,我们是每个系统自己实现流水日志的记录,当然也就没做的很好,出过各种问题。并且,每次做一个新系统,都要重复解决上面的问题,很繁琐。

为了简化这个过程,我设计并实现了Logdb, 它提供一个基于Mysql的通用可配置化的流水日志记录系统。对于业务来说,只需要配置一下,然后调用自动生成的API就好了。会根据配置自动进行分表,如果是 MyISAM 引擎,会同时创建Merge表。并且支持自动删除过期不再需要的数据。扩展也很简单,能够自动识别新增的字段并且修改数据库,并且API是向后兼容的。代码已经开源,再这里,有兴趣的同学可以看一下。

Logdb使用UDP协议通讯,每次调用API就相当于向指定地址发送一个UDP请求。默认情况下Logdb是没有回包的。API也不会等待回包,这样调用API的开销是很小的,几乎不会对业务进程造成任何的延迟。UDP协议是不可靠的,不过在局域网内,UDP发生丢包的概率还是极低的。并且UDP在实现上相对比较简单,后续可能会添加对TCP协议的支持。

Logdb在实现上分为了3层,分别称为 Interface, Reciver 和 Worker,整体架构可以用下面这个简图表示(Windows 自带画图画的,比较挫)。

Logdb架构

其中最核心的部分是 Reciver 进程。Reciver 在收到请求后,根据配置和协议格式对消息进行解析,并生成对应的SQL语句。在默认情况下,为了提高入库的效率,不是每条日志都进行入库,而是缓存一定的时间,进行批量入库。因为对于 Mysql 来说,使用含多个VALUE的INSERT语句同时插入几行,比单行的 Insert 要快很多倍。在默认情况下,LogDB对每个表维护一个缓存,如果缓存时间超过 500ms,或者缓存长度达到 1MB,则进行入库操作。这对于人来说,可以说是实时入库的,查询上不会造成延迟。

因为入库是一个阻塞的操作,所以将入库的工作交给另一个独立的进程Worker 来做。Reciver 通过一个基于共享内存和文件的队列将 SQL 语句推送给一个 Worker。这个队列也是很有趣的:当Worker 没有即时处理队列中的数据导致队列内存满时,会自动把新的数据写入文件。这就保证即使在短时间内请求量超过Worker 进程能处理的量时,数据仍然不会丢失,而在正常情况下,又能避免大量的文件IO操作。

Worker 在入库时,如果检测到因为数据库连接断开而失败的情况,则把失败的数据放入另一个队列。当数据库连接恢复时,自动的重新入库这些数据。这就保证,即使在数据库发生异常或重启时,也不需要人为的干涉,更不会丢失数据。

那 Interface 进程做什么呢?其实 Interface 进程所做的工作非常简单:它将接受到的请求转发给 Reciver并等待回包。如果超过1s 没有收到 Reciver 的回包,那么同样把这个请求放入一个队列,等到 Reciver 有回包时重试。这就保证,即使在重启 Reciver时,新的数据也不会丢失。

Logdb 的配置使用 ini 格式,还是很简单易懂的,自带的配置文件是自说明的,在这里

扩展新的字段时,需要再原有字段的最后面添加。这是因为,Logdb 所用的协议是非常简单的:首先是简单地包头,然后是数据部分,数据是按照配置的简单地顺序排列再一起的。如果新增的字段放在了中间,那么在解析请求时就可能得到脏数据或者解析失败。为了在升级后兼容老的API,再解析请求时添加了简单地规则:如果请求包剩余长度为0,那么使用空值。

一开始是准备设计的更通用一点,希望能够支持多种数据库,但后来发现不同的数据库支持的字段差别还是挺大的,比较难实现的通用。我们用 Mysql 比较多,就只支持这一种好了。

读了点 redis 的源码

很早之前边对 redis 有所耳闻,但了解不多,直到前不久看到《V2EX 从过去一年半中学到的几件事》这篇文章。文中提到 redis 可以替换掉 Mysql 而单独使用,吃惊了不少,准备下决心研究一下 redis. 加上前不久看过部分 twemcache (twemcache 是 twitter 的 memcache 改写版,以下简称 memcache)的源码,所以这里结合这两者分享一下心得。

redis 是一种纯内存的、key-value 的数据库,NO-SQL 的一种。它的 value 支持 string(字符串)、list(链表)、set(集合)、zset(sorted set –有序集合)和hashs(哈希类型)。redis  还支持持久化和主从复制。

相比之下,memcache 就纯粹多了。memcache 是一种纯内存的 key-value 缓存系统,value 类型只是一段 buffer,不支持持久化,不支持主从复制。

redis 和 memcache 都是使用 ANSI C 实现的,代码质量和可读性很高,非常有学习价值。

redis 中的数据结构

我读 redis 源码时主要读的是 redis 内部使用的一些基本数据结构。这些数据结构都是非常通用的。

sds:Simple Dynamic String,简单动态字符串。是 Redis 底层所使用的字符串表示, 它被用在几乎所有的 Redis 模块中。它很简单,就是一个带长度的、能自动扩展的、基于堆内存的字符串,并且兼容 C 中的字符串。

adlist: 双向链表,链表这东西已经被无数程序员实现过无数次了,无需多讲。

dict: 字典,有趣的是它扩容的过程。每个字典内部有两个 hash 表,一般用第一个,当发现 hash 表容量太大需要扩容时,创建第二个 hash 表。这时候不能一下子把第一个 hash 表里面的数据全部导入到第二个 hash 表,因为这样可能会导致阻塞。而是在每次执行查找或添加等操作时,从第一个 hash 表迁移一个节点到第二个 hash 表,这样像愚公移山一样直到扩容完毕。

skiplist: 跳跃表,这个数据结构也很有意思它,它是一种有序链表,加上随机化数据结构,基于并联的链表,其效率可比拟于二叉查找树(对于大多数操作需要O(log n)平均时间)。它的原理可以查看这篇文章 http://dsqiu.iteye.com/blog/1705530

ziplist: 一种压缩链表 ,使用压缩型的数据结构,减去指针的消耗,能够显著的减小对内存的使用。list, map, zset 在数据量小于特定值时,都是使用的 ziplist 以降低内存的消耗。

另外还有 intset, zipmap 等数据结构,不做多述。

 redis 的主从复制

redis 的 master, slaver 实现的可以说是相当优雅,浑然天成。关于主从复制与同步的问题,之前在工作中也遇到过,但苦于没找到一个更好的解决方案。主从结构的同步需要一次全量同步和后续的增量同步,同步的关键在于顺序,比如先 del 一个 key, 然后 add 这个 key, 如果同步成先 add 然后 del 的话,数据就不一致了。redis 的做法是,在收到同步请求之后,利用其持久化的能力,生成一个 RDB 内存 dump 文件,在生成 RDB 文件的同时,维护一个从这时候开始所有所有写操作的链表。RDB 文件生成好后,发送给 slaver, 然后再把生成 RDB 文件时所有的写操作命令发送给 slaver 执行 ,这样 slaver 就完成了一次全量更新。接下来 master 每次执行写操作时都会同步给 slaver。

redis 与 memcache

关于内存分配:memcache 的主要卖点便是其内存的分配。memcache 实现自己的内存分配是因为 glibc 自带的 malloc/free 性能太差,并且容易出现内存碎片。memcache 通过被称为 slab 的内存分配机制对内存进行管理。而 redis 没有自己分配内存,而是使用了第三方的内存分配器。redis 源码自带了 jemalloc, 也可以使用 google 的 tcmalloc, 这两者性能都很高,并且几乎 不会出现内存碎片问题。

关于事件处理:memcache 使用了开源的 libevent  进行事件的处理,包括网络IO和定时器。而 redis 则根据其不依赖的原则,实现了自己的事件处理库,对 epoll 等函数进行了封装。

关于配置:memcache 使用 getopt 进行命令行解析,只支持命令行参数形式配置,不支持配置文件。而 redis 同时支持配置文件和命令行。redis 解析命令行时,没有使用 getopt 等标准化命令行解析工具,而是自己通过 strcmp 进行解析,一开始觉得这个做法相当丑陋,后来发现自己误解了 redis 作者的苦衷了。redis 把除了 –help –version 等基本参数之外的其他 — 开头的参数,去除前面的 –,加入到一个字符串中。如果有配置文件,也把这个文件的内容一并读进这个字符串,然后由统一的函数对这个字符串进行配置解析。这样,就统一了配置文件和命令行参数,只要配置文件中有的,都可以通过命令行进行配置,个人觉得非常巧妙。

关于线程:memcache 是多线程的,由一个主线程处理连接,调度其他线程处理请求,充分利用多核 CPU 的能力。而 redis 是单进程的。严格说来,redis 是线程和进程混合的,它使用线程来进行 fsync ,也使用多进程利用 fork 的 copy-on-write 特性进实现 RDB 的持久化。但redis 在处理请求时,是单线程的。为什么不使用多线程,一个是因为对于处理内存数据,单线程已经够快了,另外,单线程能够提供一定的事务特征。为了充分利用资源可以在单台机器上起多个 redis 实例。

redis 的性能

redis 自带了基准测试工具 redis-benchmark 测试常用命令的性能,根据测试,一般的 GET SET 命令单 CLIENT 阻塞调用时,都可以达到上万次每秒,可以说大部分场合已经足够使用了。

参考:

redis 官方文档

Redis 设计与实现

C 语言中的变长数组

或许这是很多 C 程序员都不知到的特性:C 语言中可以定义变长数组。也就是说,定义数组时,数组的长度可以是变量,而不一定非要是常量。比如:

    int len = 100;
    char str[len];

这个特性无疑是非常有用的。比如在不知到数组的长度时,要么定义一个足够大的数组,要么使用内存分配函数如 malloc 函数获取适当大小的内存,但需要记得 free. 使用变长数组,使得这个过程变得优雅和简化。

这个特性是在 C99 中加入 C 的标准中的,事实上,GNU C 在之前已经支持这种写法了。vc 对 C99 的支持一直不怎么热心,所以 VC 中并不支持这种写法。所以如果代码又跨平台的需求,最好不要使用变长数组,并且最好只使用 C89 的特征。

需要注意的是,变长数组只能是局部变量,不能是静态变量和全局变量,因为这两者的长度是编译时决定的,而变长数组的长度要到运行时才能确定。变长数组是局部变量,所以是有生命周期的,其生命周期仅在当前域内,即 {} 内。

变长数组使用的内存是栈内存,所以需要注意数组长度不能太大超过栈内存大小限制。linux 上可以用 ulimit -s 查看栈大小,一般为 8M.

停止使用 strncpy 函数!

也许你曾经被多次告知,要使用 strncpy 替代 strcpy 函数,因为 strncpy 函数更安全。而今天我要告诉你,strncpy 是不安全的,并且是低效的,strncpy 的存在是由于一个历史原因造成的,你不应当再使用 strncpy 函数。

下面我来解释为什么 strncpy 函数是不安全并且是低效的,以及我们应该使用那些替代函数。

我以前对 strncpy 一直存在误解,直到有一次出现了 BUG。好不容易定位到 strncpy 身上,然后仔细查看文档,才明白问题所在。

误解一:如果 src 长度小于 n, 那么strncpy 和 strcpy 效果一样?

错,事实上,strncpy 还会把 dest 剩下的部分全部置为 0!

一直认为 strncpy 只是比 strcpy 多了长度校验,确不知道 strncpy 会把剩下的部分全置为 0(粗体部分)。

char *strncpy(char *dest, const char *src, size_t n);

DESCRIPTION
The strcpy() function copies the string pointed to by src (including the terminating `\0′ character) to the array pointed to by dest. The strings may
not overlap, and the destination string dest must be large enough to receive the copy.
The strncpy() function is similar, except that not more than n bytes of src are copied. Thus, if there is no null byte among the first n bytes of src,
the result will not be null-terminated.
In the case where the length of src is less than that of n, the remainder of dest will be padded with null bytes.

这会导致什么后果呢?

首先,如果 strncpy 的长度填错了,比如比实际的长,那么就可能会把其他数据清 0 了。我就遇到过这个问题,在后来检查代码看到这个问题时,也并不以为然,因为拷贝的字符串不可能超过缓冲区的长度。

另外,假设 dest 的长度为 1024, 而待拷贝的字符串长度只有 24,strncpy 会把余下的 1000 各字节全部置为 0. 这就可能会导致性能问题,这也是我为什么说 strncpy 是低效的。

误解二:如果src 长度大于等于 n, 那么 strncpy 会拷贝 n – 1 各字符到 dest, 然后补 0?

错,大错特错,罚抄上面的 DESCRIPTION ,直到看到:

if there is no null byte among the first n bytes of src, the result will not be null-terminated.

这就可能导致了不安全的因素。

如果待拷贝字符串长度大于了 n, 那么 dest 是不会有结尾字符 0 的。假设这样一种情况:

    char s[] = "hello world";
    strncpy(s, "shit!", 5);
    puts(s);

输出的结果是 “shit” 还是 “shit! world” ?

这种情况只是导致了输出结果错误,严重的,如果 dest n 字节后面一直没有 0,那么就会导致程序段错误。

strncpy 最开始引入标准库是用来处理结构体中固定长度的字符串,比如路径名,而这些字符串的用法不同于 C 中带结尾字 0 的字符串。所以 strncpy 的初衷并不是一个安全的 strcpy.

那么用那些函数来替代 strncpy?

1、使用 snprintf

snprintf(dest, n, src);

的效果和我们对一个安全的字符串拷贝函数的期望完全一致。

但是这个函数效率有点问题,并且特殊字符比如 %d 会转义。

2、自己实现一个高效并且安全的字符串拷贝函数 sstrncpy,开头的 s 代表 safe

/* safe strncpy */

char *sstrncpy(char *dest, const char *src, size_t n)
{
    if (n == 0)
        return dest;

    dest[0] = 0;

    return strncat(dest, src, n - 1);
}

使用 strncat 是因为很难实现一个性能能够达到库函数的字符串拷贝函数。

3、但是,上面两个函数都有一个问题:如果不能预知 src 的最大长度,那么 src 会被静默的截断。

如果是为了复制一个字符串,那么更好的做法是使用 strdup 函数

char * strdup (const char *s);

strdup 函数会调用 malloc 分配足够长度的内存并返回。

当然,你需要在你不使用的时候 free 它。

如果只是函数内部调用,也可以使用 strdupa 函数。

char * strdupa (const char *s);

strdupa 函数调用 alloca函数而非 malloc 函数分配内存,alloca 分配的内存是桟内存而非堆内存。所以当函数返回后,内存就自动释放了,不需要 free。

4、如果是从文本文件中读数据,相对与 fgets 函数,更好的做法是使用 getline

ssize_t getline (char **lineptr, size_t *n, FILE *stream);

一个简单的例子:

char *line = NULL;
size_t len = 0;

while (getline(&line, &len, stdin) != -1)
{
    fputs(line, stdout);
}

free(line);

当 line 为 NULL 或者 len 为 0 时,getline 调用 malloc 分配足够大的内存。所以你需要在用完后 free 它们。

和 fgets 相同,getline 得到的行是带换行字符的。

所以,忘了 strncpy 吧,血的教训,说出来都是泪…

使用 stdarg 的一些注意事项

stdarg 给C语言提供一种可变参数的功能,最常见的例子为 stdio 中的 printf 函数。

1、stdarg 仅支持整数、浮点数和指针(数组),其他如结构体是未定义行为。

2、stdarg 在入参时会对参数会进行默认的提升,比如所有低于整数会提升到 int, 低于 double 的会提升到 double.

3、在使用 type va_arg ( va_list ap, type ) 取参数时,为了保留整数的符号位,可统一按照有符号整数进行数据类型转换。

4、c99 中 stdarg 加入了对 64 位整数的支持

5、综合2、3、4,va_arg 中的 type 只应该出现 int, int64_t, double, void *

6、在 gcc 中,stdarg 是由编译器在编译时实现的,而非写好的宏或函数等。这样编译器可以做一些优化。

7、永远不要将混用数据类型,如入参时放入的是浮点数,取参却用 int 或 int64_t 这是一个未定义的操作。详细分析请看这边文章:x86-64体系下一个奇怪问题的定位

8、c99 中新增了va_copy,以进行安全的 va_list 对象的复制操作。

malloc 从哪里得到的内存空间

引子

在计算机高级编程语言中,C 语言相对来说是一种低级语言,从某种意义上讲,C 语言就是现代的“汇编语言”。说 C 语言低级很大程度上是因为 C 程序员需要手动管理存储,具体反应在公认最难最容易出错的指针上。比如编写 C 程序时,经常会出现莫名奇妙的段错误,并且内存泄漏会在不知不觉的情况下发生,直到耗尽你的计算机内存资源为止。更危险的则是缓冲区溢出,使程序非常容易受到攻击。

发明 C++ 的一个目的是为了提升 C 语言的抽象能力,还有一个目的就是为了消除指针,但 C++ 显然没有做到这一点。Java 则继承了 C++ 的遗愿,彻底的消灭了指针。Java 等高级语言采用严格的内存管理,动态的垃圾回收等机制使得程序员不用去手动管理内存,不用和底层打交道。但 C 语言的地位仍是无法取代的。在必须和底层打交道的时候,就得使用 C 语言(有时候甚至要用汇编语言),比如现代操作系统都是用 C 语言编写的。另外,高级语言在引入高级特征的同时,效率上就会有所损失,在非常强调执行效率的地方,C 语言通常是首选。

继续阅读