淘宝那岩曾经在淘宝核心系统团队博客上介绍过,本文将尝试着介绍rdb(redis存储引擎)的实现。
是淘宝开源的分布式KV缓存系统,内部将功能模块化,抽离出底层存储细节,可以接入不同的存储引擎。是一个开源的、高效的key-value存储,提供了strings、hashs、lists、sets、sorted sets等多种高级数据结构。redis作为Tair的存储引擎接入,称为rdb。
本文参考的源码为Tair rdb实现最新的release版(),对照的redis实现为2.4.10版。
Tair首先是一个分布式的框架,有一系列策略满足CAP(数据备份,迁移复制等)。另外,还有针对应用场景的功能特性(namespace,数据过期时间,原子计数等)。接入redis时做了比较多的修改,下边逐一介绍。对redis内部实现感兴趣的读者,可以看(或)。
1.配置修改
为了达到配置统一管理的目的,tair rdb去掉了redis自带的配置文件。它将redis所需的配置统一放在tair配置文件的TAIRRDB_SECTION下边。当前保留的配置文件项有:
RDB_UNIT_NUM 一个命名空间下的单元划分,默认值是16,决定了并发粒度,下边会有介绍;
RDB_AREA_GROUP_NUM 单机存储引擎的命名空间范围,默认值是512,这个值决定了单机上最多可以有多少个命名空间同时存在(命名空间类似于db name,使用中可以映射到项目,比如可以在area 0中保存项目名到命名空间id的映射,后续就可以在特定的项目内查找key-value);
RDB_MAXMEMORY 单机上rdb存储引擎占用内存的上限值,达到上限后将依据maxmemory_policy的不同采取不同的行为;
RDB_MAXMEMORY_POLICY 达到内存上限后进行的操作,可以直接返回错误,也可以进行LRU,可以参考redis的设置;
RDB_MAXMEMORY_SAMPLES 与LRU实现相关的一个配置参数,可以参考redis的配置;
RDB_LIST(/HASH/ZSET/SET)_MAX_SIZE list、hash、sorted set、set数据结构value最大占用空间的大小,默认均是8k(感觉偏小了);
需要根据自身项目需求调整上述配置。
2.加锁的逻辑
redis的主逻辑是一个单进程、单线程的程序,其并发是通过操作系统结合事件驱动框架(ae)实现的。但在rdb中,剥离了redis的网络层,只保留了内部存储实现,而上层是由多个线程调用的,因此就涉及到加锁的逻辑。
我们先看一下tair的mdb存储引擎(类memcache存储引擎)中遇到的问题,下图是一张调用逻辑图:
所有的线程(无论是否同一个area,也不区分读写)都在争用同一把锁,存在严重的瓶颈(我们在生产环境中已经遇到了这个问题)。
rdb的调用逻辑与图中所示基本是一致的,但在bottleneck部分做了优化。rdb利用了redis可以同时存在多个db的特性,一次性提供了area_group_num*unit_num个db,所有的操作都局限在db内,将锁的粒度变成了以unit为单位,相当于将上图中的单一的蓝色框的部分分解成了大量的管道并行处理,同时,不同area之间实现互不干扰。以默认配置举例,其加锁的粒度由所有area争用一把锁,变成了一个area划分成16个unit,只有落在同一个unit中的查询才会竞争一把锁,大大的提高了并发度。
其加锁的index通过下述的宏来查找,其中_hashcode为key值的hash:
76 #define get_redis_db(_area, _hashcode) ((_area) * context->get_unit_num() + (_hashcode) % context->get_unit_num())
3.定时任务的逻辑
redis的定时任务是由事件驱动框架实现的,其逻辑在redis.c的serverCron函数中实现,可以参考。
rdb的定时任务改成使用后台线程的方式执行,同redis默认配置一样,每100ms执行一次。定时任务以unit为单位,执行定时任务前会获取对应unit的锁,其实现逻辑是redis定时任务的一个子集,去掉了和持久化相关的部分。
4.持久化的逻辑
Update: 没有使用2.4.x中的bio主要是原来是基于2.2做的rdb,改成bio的略显麻烦 中间尝试改过一次,需要涉及修改的东西比较多,所以先上2.2的方式。和redis不同的是这边不会使用子进程来说这些事情,而是线程来做的,主要是防止cow可能的内存上升。
rdb提供了dump和load的接口,采用后台线程的方式,实现类似于redis的snapshot操作,但比较奇怪的是,最新的release版本中,这部分代码都是被注释掉的,猜测或许是这部分代码的测试还不完善,也可能是因为rdb的定位就是提供丰富数据结构的cache服务,可靠性保证通过Tair中间件层提供的机制实现。由于没有持久化的逻辑,相应的代码中也去掉了redis精巧设计的bio。
1266 void* dump_db_thread(void* argv) {1267 pthread_detach(pthread_self());1268 dumpThreadInfo* info = (dumpThreadInfo *)argv;1269 //rdbSave(info->server, info->filename, info->area);1270 free_dump_thread_info(info);1271 return NULL;1272 }
1274 void* load_db_thread(void* argv) {1275 pthread_detach(pthread_self());1276 loadThreadInfo* info = (loadThreadInfo *)argv;1277 //rdbLoad(info->server, info->filename, info->area);1278 free_load_thread_info(info);1279 return NULL;1280 }
5.丰富的操作
与mdb相比,rdb从redis继承了丰富的操作,包括list、hash、sorted set、set(与mdb相比,list不再那么ugly)。
但由于分布式的关系,不同的set很有可能落在不同的redis db实例中,因此不再支持集合的交(/) 并()操作。
6.对版本的支持
Update: 关于version rdb不止有key有version的,db,value都是有的,现在主要要是hash结构的中的field的version来实现根据field删数据的功能和清清namespace的作用。
memcache中有版本的概念,tair中同样有版本的概念。我们看一下tair中引入版本的用途,其思路借鉴自Dynamo的vector clocks:
“Version支持
Tair中的每个数据都包含版本号,版本号在每次更新后都会递增。这个特性有助于防止由于数据的并发更新导致的问题。
比如,系统有一个value为“a,b,c”,A和B同时get到这个value。A执行操作,在后面添加一个d,value为“a,b,c,d”。B执行操作添加一个e,value为”a,b,c,e”。如果不加控制,无论A和B谁先更新成功,它的更新都会被后到的更新覆盖。
Tair无法解决这个问题,但是引入了version机制避免这样的问题。还是拿刚才的例子,A和B取到数据,假设版本号为10,A先更新,更新成功后,value为”a,b,c,d”,与此同时,版本号会变为11。当B更新时,由于其基于的版本号是10,服务器会拒绝更新,从而避免A的更新被覆盖。B可以选择get新版本的value,然后在其基础上修改,也可以选择强行更新。”
redis自身没有版本的概念,antirez曾就此进行过,rdb将版本引入,同key存在一块,同时修改了存储部分的代码,加入了对版本的支持。
本文作者水平有限,而且更多的是从源码的角度去反推设计,因此对于不正确和不完善的地方,还望大家指出,多多讨论。