毕业后荒废了很多时间. 最近收拾心情, 想找几个存储的开源项目学习下. 先看了fatcache的代码, 它是twitter开源的缓存服务, 可以认为是SSD版的memcache(索引还是在内存). 本文简单分析下, 做个备忘.

generic queue

fatcache使用generic queue维护资源使用情况, generic queue的实现在fc_queue.c. 和常见思路一致:fatcache在启动时, 预先分配一大块内存, 划分为多个元素, 并将多个元素组织成一个队列, 作为空闲队列. 需分配元素时, 将元素从空闲队列中弹出压入已使用队列. 当归还元素时, 将元素从已使用队列弹出压入到空闲队列.

slab allocator

fatcache底层采用slab allocator内存模型, 并且使用hash表快速索引key所在的item.

结构定义

如上所述, fatcache采用slab allocator内存管理模型. slab所占内存大小相同,但slab上item的大小可以不同, fatcache按照item大小将slab分级, 称为slabclass, 每种class有不同的id, 记做cid. slabclass的结构为:

47 struct slabclass {
48     uint32_t         nitem;           /* # item per slab (const) */
49     size_t           size;            /* item size (const) */
50     size_t           slack;           /* unusable slack space (const) */
51     struct slabhinfo partial_msinfoq; /* partial slabinfo q */
52 };

其中,nitem表示该class下每个slab有多少item, size表示每个item的大小, partial_msinfoq作为队列头指针, 指向已使用但未满的slabs所组成的队列.

slab的结构为:

21 struct slab {
22     uint32_t  magic;     /* slab magic (const) */
23     uint32_t  sid;       /* slab id */
24     uint8_t   cid;       /* slab class id */
25     uint8_t   unused[3]; /* unused */
26     uint8_t   data[1];   /* opaque data */
27 };

magic是魔数, 做校验用. sid表示该slab的id, cid表示该slab的级别. unused作为padding未使用. data便是item数组. 同一个slab在不同的周期可以属于不同slabclass(由cid确定).

slab可以在内存中, 也可以在磁盘上. 每个slab一一对应一个摘要结构体, 称为slabinfo. slabinfo是永在内存中的. 其结构为:

35 struct slabinfo {
36     uint32_t              sid;    /* slab id (const) */
37     uint32_t              addr;   /* address as slab_size offset from memory / disk base */
38     TAILQ_ENTRY(slabinfo) tqe;    /* link in free q / partial q / full q */
39     uint32_t              nalloc; /* # item alloced (monotonic) */
40     uint32_t              nfree;  /* # item freed (monotonic) */
41     uint8_t               cid;    /* class id */
42     unsigned              mem:1;  /* memory? */
43 };

其中,sid为slab的id, cid表示slab属于哪个级别. nalloc表示该slab当前已使用的item数, nfree表示该slab当前仍剩余的空闲item数. mem表示slab在内存中还是在磁盘上.

fatcache作为类memcache, 原理是在内存中维护hash表, 表元素记做itemx:

23 struct itemx {
24     STAILQ_ENTRY(itemx) tqe;    /* link in index / free q */
25     uint8_t             md[20]; /* sha1 message digest */               //作为key.
26     uint32_t            sid;    /* owner slab id */
27     uint32_t            offset; /* item offset from owner slab base */
28     uint64_t            cas;    /* cas */
29 } __attribute__ ((__packed__));

sid表示该item所在slab, offset表示item在slab的偏移. cas是memcache协议定义的业务相关字段, 无须理解.
需要说明的是uint8_t md[20]字段, 它表示key的摘要. 因为在fatchache中, key最长可以为256, 明显太大了, 所以fatcache认定md标志一个key, 多个不同的key如果md相同, fatcache将认为这些key相同.

itemx只是item在内存中的摘要信息. 真正的item既可以保存在内存slab中, 也可以在磁盘slab中. item结构为:

 23 struct item {
 24     uint32_t          magic;      /* item magic (const) */
 25     uint32_t          offset;     /* raw offset from owner slab base (const) */
 26     uint32_t          sid;        /* slab id (const) */
 27     uint8_t           cid;        /* slab class id (const) */
 28     uint8_t           unused[2];  /* unused */
 29     uint8_t           nkey;       /* key length */
 30     uint32_t          ndata;      /* date length */
 31     rel_time_t        expiry;     /* expiry in secs */
 32     uint32_t          flags;      /* flags opaque to the server */
 33     uint8_t           md[20];     /* key message digest */
 34     uint32_t          hash;       /* key hash */
 35     uint8_t           end[1];     /* item data */
 36 };

fatcache将key和value按tl格式保存在end尾端.

处理流程

启动阶段:

fatcache根据参数选项得知总共有多少slab级别(uint8_t nctable), 在内存中创建slabclass数组(struct slabclass *ctable), 依次初始化. 然后在内存中创建slabinfo数组(struct slabinfo *stable), 个数(uint32_t nstable)为配置的内存slab数(uint32_t nmslab)和磁盘slab数(uint32_t ndslab)之和. 然后将slabinfo数组逐一初始化, 前nmslab个元素slabinfo.mem为1, 表示slab在内存. 后ndslab个元素slabinfo.mem为0, 表示slab在磁盘.

然后fatcache创建一大块内存, 大小为nmslab * settings.slab_size (记住每个slab大小相同), 再将内存分割为多个slab并逐一入内存slab空闲队列(struct slabhinfo free_msinfoq). 同理, fatcache将磁盘分割为多个slab并逐一入磁盘slab空闲队列(struct slabhinfo free_dsinfoq).

工作阶段:

fatcache定义了几个辅助函数, 其中 uint8_t slab_cid(size_t size) 表示size大小的item属于哪个slabclass. 当需要分配一块大小为size的item时, 首先用过slab_cid()函数获得对应的cid, 然后通过struct item *slab_get_item(uint8_t cid)函数分配item. 其处理流程为:

1. 如果内存中无空闲的itemx, 则调用slab_evict()函数,它找到最老的磁盘slab, 逐一将该slab上的所有item对应的itemx从hash表中删除.

2.1 如果slabclass.partial_msinfoq不为空, 则对头部的slabinfo, 从其对应的slab上分配一个item. 如果slab此时没有空闲item, 则将slabinfo从partial_msinfoq中移除, 插入到full_msinfoq中
2.2 如果slabclass.partial_msinfo为空, 则从free_msinfoq中弹出一个slabinfo, 从对应的slab上分配item, 并将该slab从free_msinfoq移除后插入partial_msinfoq或full_msinfoq

3. 如果2.1或2.2都未成功分配, 则调用slab_drain()函数, 它将删除磁盘上最老的slab(通过slab_evict()), 然后调用_slab_drain()函数, 找到full_msinfoq中最老的一个slabinfo对应的内存mslab, 然后从free_dsinfoq中找到一个的磁盘dslab, 将mslab数据写入dslab, 交换两个slab对应的slabinfo的标志和所管理的数据的起始地址(即slabinfo.addr和slabinfo.mem). 然后尾递归调用slab_get_item()跳回1重新干活.

淘汰策略

既然使用了generic queue, 淘汰策略必须是FIFO, 这是很容易实现的: slab如果有更新, 插入到queue尾部. 如果需淘汰, 从queue头部开始.

高效的读写

对于写操作:上面的流程保证了slab_get_item()返回的item必定在内存中, 所以上层可以直接将数据写入item尾部(item.end). 如果某个slab已写满, 且没有其他的空闲slab, 则上面的操作3所调用的_slab_drain()函数, 将内存slab和磁盘slab做数据交换时, 可以成片将数据写入磁盘. 可以理解为将随机写缓存在slab中转为顺序写.

对于读操作:SSD对随机读本来就有很高的IOPS.考虑到SSD读写块大小为512字节, fatcache通过下面几行代码在应用层做了对齐:

492     off = slab_to_daddr(sinfo) + addr;
493     aligned_off = ROUND_DOWN(off, 512);
494     aligned_size = ROUND_UP((c->size + (off - aligned_off)), 512);
495
496     n = pread(fd, readbuf, aligned_size, aligned_off);
497     if (n < aligned_size) {
498         log_error("pread fd %d %zu bytes at offset %"PRIu64" failed: %s", fd,
499                   aligned_size, (uint64_t)aligned_off, strerror(errno));
500         return NULL;
501     }
502     it = (struct item *)(readbuf + (off - aligned_off));

对于删除操作:SSD在hash表的层面删掉索引. 数据不做修改, 等待自然淘汰. 这种机制保证了如果数据在SSD上, 可以不发生随机写.

总结

代码虽然很短, 还是能学到一点知识, 尤其是随机写转为顺序写的思路. 最牛逼的是对删除请求, 只删索引不改数据的机制真是简单高效.

update

1. 关于删除操作: 上文没有介绍清楚, 这里详细解释下, 从item结构可以看出它没有next指针, 也就是说, 对某个slab而言, 可以认为它只提供append item操作, 新分配的item都压在slab数据尾部. 当需要删除某个key时, fatcache只在在hash表上删除对应的itemx(前文介绍过, itemx只是item的摘要, md唯一标识key), 也就是说不管item在内存slab还是在磁盘slab, 都不会发生删除item的操作. item的真正删除是以slab为单位, 从内存淘汰到磁盘后才发生的. 作为一个缓存服务, 这没有任何问题.

2. 如何将fatcache改造为永久存储服务: fatcache代码模块化不错, 改造起来应该没什么大问题, 比较不好处理的是第1点提到的未真正删除item而带来的存储空间出现空洞的问题. 这也许可以分两方面解决: 一是对item在内存的情况, 可以用对应slab上的最后一个item覆盖待删除item的空间(对同一slab, item大小是相同的), 这种方式可以保证slab作为数组类的操作, 不会出现空洞, 仍然不需要使用额外的next指针来维护链表; 二是对item在磁盘的情况, 可以通过某种机制在特定时刻触发磁盘slab的回收, 回收时可以针对该磁盘slab上的所有item, 一一查找hash表, 如果找不到itemx或者itemx指向存储空间不同(说明item已被删除, 或者删除后插入了key相同的数据), 跳过该item, 否则, 将item从fatcache中删除后重新插入. 最后将磁盘slab标记为free即可.