Archive for 八月, 2014

RDB源码解读:

RDB功能在/path/to/redis/src/rdb.c里,其入口函数是rdbSave方法:

 int rdbSave(char *filename) ;

该方法最主要的调用者是redis.c的serverCron方法,通过rdbSaveBackground方法间接调用。顾名思义,rdbSaveBackground方法是通过fork出一个后台执行的子进程来进行持久化操作的,执行完毕子进程就退出。这里,主要利用的就是copy-on-write机制,即如果没有数据修改,则主子进程是复用同一块内存的,而一旦发生数据写入,则子进程的内存区域会copy出来,保持原状。这样,能够最大程度保持RDB持久化时,对Redis的正常访问是没有影响的(注意,当数据集非常大时,fork可能会阻塞请求)。

回到rdbSave方法,它的工作就是:

  1. 以覆盖写的方式,打开临时文件(命名中包含子进程的pid,从而保持唯一性)
  2. 写入magic头:REDISxxxx,后四位是以0补齐的Redis_Rdb_version,从而支持版本识别
  3. 遍历所有redis db,并忽略空数据的db:
    1. 以safe方式获取dict iterator
    2. 遍历dict->ht,默认情况先遍历ht[0],在遇到rehash的情况下会继续遍历ht[1]
    3. 获取当前item的key、val、expire time,“写入文件”(其实是缓存)
  4. 扫尾工作包括写EOF结束符,以及按需写入checksum
  5. 为了保证数据真的持久化到磁盘,调用fflush强制刷新用户缓存(可能还在磁盘缓冲区),调用fsync强制写入磁盘
  6. 将临时文件改名,真的成为RDB持久化文件,并更改RDB统计数据

RDB持久方式的问题是,磁盘上的数据可能总是内存数据的子集,故障恢复无法保证不丢数据。但其优点是,对线上服务影响较小(只是fork子进程的消耗 和 主子进程对系统资源的竞争关系)。

数据的持久化,是为了在故障或重启时,能够顺利恢复数据。在Redis的main方法里,会调用loadDataFromDisk方法,根据配置方式分别从AOF或RDB文件中Load数据。其中,rdbLoad是入口方法,这个方法同时也应用于主从同步。该方法执行:

  1. 读入RDB文件,检查magic,如果版本不匹配,则退出加载
  2. 依次读入文件内容,根据不同type进行处理
    1. 在遇到真正的key、val数据时,若是master节点,则需检查其expire时间,若已超时则free内存后丢弃(注意,slave节点不直接丢弃,而是走expire机制。因为是否超时,应该由master来控制,从而保证master-slave的数据尽量一致)
    2. 否则,将其插入dict entry里,并按需设置expire时间
  3. 最后,会按需检查checksum,若失败则直接exit退出
 RDB fork的资源消耗

fork一般被认为是没有什么系统资源消耗的,但redis文档里却提到当数据量非常大时,RDB fork可能会阻塞请求,这是为什么呢?

fork creates a child process that differs from the parent process only in its PID and PPID, and in the fact that resource
utilizations are set to 0. File locks and pending signals are not inherited.

Under Linux, fork is implemented using copy-on-write pages, so the only penalty incurred by fork is the time and memory required to duplicate the parent’s page tables, and to create a unique task structure for the child.

由于在linux下,fork采用了COW(copy on write)机制,且尽量优先保证子进程先执行,所以一般情况下其系统消耗很小。但,COW不是万能的。一种猜测是,如果数据量很大,那么page tables可能也会较大,所以fork时复制的成本增大。另一个猜测是,对于高并发写入的redis,即使子进程先执行了,但如果数据量大,可能一个时间片内是无法执行完的。这样的话,一旦切换到主进程,其随后的写入很快到来,立刻就会导致被修改到的页(page)复制,在一个高写入且数据较分散的redis里,其涉及到page量可能会比较大,这样就可能会对主进程造成影响,使其无法响应正常的读写请求了。

这里还有另外一个坑,就是一旦发生copy,则代表主子无法共享内存了,也就是这时的内存需求是 正常内存 × 2!如果这时开启了swap,则很可能会交换到磁盘,从而导致性能的降低。具体可以参考http://www.iteye.com/topic/808293,在公司的Redis集群里,貌似是在不提供服务的slave节点上开启RDB持久化的,这样相当于用资源来换可靠性和性能。

AOF源码解读

相对于RDB,AOF方式可能略微复杂,在阅读其持久化描述后,首先有以下3个问题:

  1. 什么时候调用AOF写日志?
  2. 什么时候进行fsync操作?
  3. 什么时候进行AOF日志的重写?

在redis的设计中,将其分为:命令传播、缓存追加、文件追加及fsync 三步骤。以下将对照源码分别解读。

1. 命令传播

redis.c的call方法是响应读写请求的核心方法,其执行完成之后会调用propagate方法,将命令变更传递给AOF和replication程序(按需)。其中AOF是触发了feedAppendOnlyFile方法。所以,AOF写日志,是在每次执行完写操作成功之后,被调用的。

2. 缓存追加

feedAppendOnlyFile方法里,其功能之一是将接收到的cmd和arg参数,转换为待写入AOF文件的网络通信协议(这样在写入时,就可以起一个mock的客户端,逐条读入并mock发起对server的调用)。首先按需select对应的db,然后根据cmd类型(expire、setex and psetex、其他)进行格式转换,存入buf字段中。调用sdscatlen方法,将buf里的数据写入aof_buf里。

以上是正常情况下的处理逻辑,但若这时有正在运行的aof rewriting 子进程,则还需将新数据追加给它,故还需调用aofRewriteBufferAppend方法告知子进程。由于仅主进程会写,故没有加锁操作(主子进程间如何保证互斥?

3. 文件追加及fsync

当完成步骤2后,aof数据存在于程序中开辟的aof_buf内存区中,还存在极大丢失的风险。首当其冲是需要将其“写入”文件,但linux的write类方法的调用,并不是真的持久化到磁盘了,而是先放到系统缓冲区,只有在调用fsync等命令或缓冲区满了以后,才会被真正写入磁盘。所以,这就存在何时调用write、何时fsync的两个时间点了。

一个重要的触发函数beforeSleep,这个方法在Redis每次进入sleep/wait去等待监听的端口发生I/O事件之前被调用。在该方法最后,会触发flushAppendOnlyFile,这时aof_buf中可能会有之前几次client请求的写入数据,会被批量处理。

另外一个重要触发是serverCron,这个方法每秒执行hz次,相当于是一个批量处理的定时器方法。在该方法里,有两处调用flushAppendOnlyFile的地方,主要是当flush出现异常时,尽早处理:

  • 每次都条件执行的 if(server.aof_flush_postponed_start),在AOF_FSYNC_EVERYSEC被hang住时,下一个loop里触发
  • run_with_period(1000)即1s左右执行一次的,在flushAppendOnlyFile中write出错时,下一个loop被触发

先来看flushAppendOnlyFile方法,其执行流程如下图:

 

总结起来,针对3种不同的fsync配置,其执行如下:

  • AOF_FSYNC_NO
    • 每次都执行write,不执行fsync
  • AOF_FSYNC_EVERYSEC
    • 若当前有fsync子进程,且推迟执行未超过2s,则继续推迟执行
    • 否则,调用write。是否调用fsync:
      • 若当前没有其他fsync子进程,且1s未fsync了,则fork子进程fsync
      • 若当前有其他fsync子进程,但aof_no_fsync_on_rewrite设置为no,且1s未fsync了,则fork子进程fsync(即未配置指定不允许多个fsync子进程同时存在)
      • 若当前有其他fsync子进程,但aof_no_fsync_on_rewrite设置为yes,则不fsync
  • AOF_FSYNC_ALWAYS
    • 每次都执行write,每次都主进程执行fsync
AOF rewrite

todo

总结

总体来看,不管是RDB还是AOF方案,都对所在服务器的性能有所影响。所以,在redis的实际运维中,会关闭主服务器的持久化选项,而采用从服务器进行持久化。这就又涉及到“replication”,即主从复制了。下篇继续。

参考文档:
  • http://redis.readthedocs.org/en/latest/topic/persistence.html
  • http://blog.nosqlfan.com/html/3813.html
  • http://www.makelinux.net/books/lkd2/ch03lev1sec2
  • http://redisbook.readthedocs.org/en/latest/internal/aof.html   AOF-Redis设计与实现

本文通过总结当前项目的架构过程,希望抽象出通用思路、并指导我后续发展的方向。

  • 业务理解
  • 逻辑架构设计(模块划分依据)
  • 业务检查
  • 关键技术选型
  • 模块交互接口设计
  • 数据结构设计(粗粒度)
  • 模块架构设计(可能由具体工程师完成)
业务理解
  • input:需求文档(PM提供)
  • output:技术需求文档(架构师产出)

理解PM提出的需求,并对相似产品进行调研,在脑海里构建一个可以run起来的demo。如果发现不完整或不完善的地方,记录并及时与PM交流。然后尝试将其归类(业务复杂型、高并发、大数据处理型等),并与某些(多个)见到过的架构类比,找到参照物(可能没有办法找到100%一样的,但总能找到相似的)。

由于PM提出的需求,可能更多是功能性的,还需自行确认非功能性需求。这时可以从PV、UV、项目重要程度(影响稳定性等指标)等方面,并预估其项目发展速度(大中小型),从而给出吞吐量、性能、数据量、可用性、数据一致性、扩展性、区域部署或国际化等方面的需求,形成中间文档《非功能性需求文档》。

这时,就可以对照着需求文档,将其细化为《技术需求文档》。其中,可以把相似的功能聚类到一起,形成层级和依赖关系,这些就是潜在的模块划分和交互。

逻辑架构设计
  • input:技术需求文档
  • output:多种架构图、模块功能点说明

这阶段更多的是做逻辑架构(分模块架构),并结合数据交互图、关键case时序图,进行整体架构的描绘。

在设计逻辑架构时,就需要考虑“为什么这样划分模块?”出发点可能有:

  • 功能相关性和解耦,所以这些功能需要放在一个模块里,而其他功能不能放在这里
  • 复用性,可能多个模块都需要这些功能,所以其需要被抽取出来,作为横向通用模块
  • 依赖关系,从而形成分层架构
  • 发展前景,对于可能膨胀和需要优化的模块,需要小心制定其功能边界,确保其变动不会影响到其他模块
  • 其他的模块类型还有:底层存储层,垂直调度模块(各个层级都会使用的功能,例如配置中心),第三方插件,外部依赖服务,流量入口类型(严格说不算模块)
  • 监控和报表模块,必须牢记上线之后才是危险真正来临的时刻

在画完逻辑架构之后,数据交互图和关键case时序图,一方面是便于向他人传递架构师的思路,另一方面(很重要)是自己检查是否能满足功能性需求。

 业务检查

完成了整体架构后,还需要再比对着需求文档,进行检查。我的做法就是在脑子里过流程,确保从用户、客户、系统、数据等多个维度,可以跑通。这时,可能还会发现功能上的缺失,可以再回过头对需求、架构进行调整。

关键技术选型
  • input:多种架构图
  • output:技术选型与折衷文档

模块架构的完成,说实话离实现还远着呢。怎样实现这些架构,是这个阶段需要解决的问题:

  • 各个模块的语言选择:根据人力储备、功能类型(CPU密集型、IO阻塞型等)、工期要求、性能要求等进行选择
  • 网络交互协议、序列化协议的选择:一般建议采用公司通用协议,以降低学习和开发成本。例如服务端交互Protobuf,前后端交互Jsonp等
  • 存储层选型:业务计算复杂、数据量小的mysql,吞吐量大、性能要求高的Redis/Memcached等,海量数据的HDFS/Hbase/Hive
  • 依赖第三方技术选型:是否需要分布式计算(非实时的Hadoop之类,实时的Storm之类),是否需要CDN,是否需要图片处理,是否需要分布式文件系统(图片、视频、音频之类)等
  • 部署考虑:各模块是混布还是隔离?部署在哪些区域,区域间如何保持数据同步?如果进行国际化,是否存在网络时延、带宽、时差、本地化等问题(其中网络相关问题比较多)

以上技术选型时,可能会受到排期、人力制约,作出各种折衷,也需在此一一说明。当然,在作出折衷的时候,需要考虑清楚,后续升级或迁移的方案和成本。

另外,针对第三方依赖,架构师得能够给出合理的选择理由,对风险有足够的认识,必要的时候,得跟其维护者进行沟通,确认风险和最佳实践。

模块交互接口设计
  • input:多种架构图、模块功能说明文档、技术选型文档
  • output:模块交互接口文档(粗略稿)

结合上面的网络协议、序列化协议 和 模块功能,这里需要清晰给出各模块的功能边界(与各模块负责人需要达成共识),以及相互依赖关系(谁在什么情况下,调用谁),并在此基础上,制定各模块的交互接口。例如,是网络调用还是方法调用,如果是网络调用使用什么交互协议,是同步调用还是异步调用,对性能和稳定性有没有特殊要求。这时,可以不涉及接口的具体参数、返回值,而由模块详设时再行确认。

数据结构设计
  • input:多种架构图、模块功能说明文档、技术选型文档
  • output:数据结构设计文档(粗略稿)

在该阶段,可能无法给出具体的scheme设计,但需要确认哪些数据使用哪种存储介质,是否需要分库分表,是否能够满足功能和性能需求,是否有什么在详设时需要特别注意的技术点。若存在无法解决的需求问题,可能还需要修改技术选型。

完成以上工作之后,可以形成《整体架构设计文档》,并需要对其进行评审,一般需要其他团队高工,PM、RD、FE、QA、OP、DBA、UE等角色的参与。其他团队的高工主要负责看技术设计是否合适,有没有明显漏洞或优化点;PM和QA需要确保架构符合功能需求,QA还需要思考设计是否具备可测试性。OP、DBA需要从部署架构、存储选型方面予以审核。

模块架构设计
  • input:整体架构设计文档
  • output:各模块详设文档

此部分可以由模块负责人进行,但架构师需要全程参与,确保满足整体需求、没有功能遗漏或重复,模块中采用较优、具备可扩展性的架构设计。如果模块间出现技术配合问题,架构师得予以调解。

 

另外,为了按时按质完成以上工作,架构师需要在一开始就制定合理的日程表,细化到每一天需要完成什么事情(产生什么、与谁沟通)。在项目设计完和发布时等阶段性时间点,最好还能够进行回顾,总结出好的和不好的地方,以便下次改进。

监控以发现问题

http://redis.readthedocs.org/en/latest/server/info.html

通过redis的info命令,可以查看各种信息,其中包括内存相关。

  • memory : 内存信息,包含以下域:
    • used_memory : 由 Redis 分配器分配的内存总量,以字节(byte)为单位
    • used_memory_human : 以人类可读的格式返回 Redis 分配的内存总量
    • used_memory_rss : 从操作系统的角度,返回 Redis 已分配的内存总量(俗称常驻集大小)。这个值和 top 、 ps等命令的输出一致。
    • used_memory_peak : Redis 的内存消耗峰值(以字节为单位)
    • used_memory_peak_human : 以人类可读的格式返回 Redis 的内存消耗峰值
    • used_memory_lua : Lua 引擎所使用的内存大小(以字节为单位)
    • mem_fragmentation_ratio : used_memory_rss 和 used_memory 之间的比率
    • mem_allocator : 在编译时指定的, Redis 所使用的内存分配器。可以是 libc 、 jemalloc 或者 tcmalloc 。
    在理想情况下, used_memory_rss 的值应该只比 used_memory 稍微高一点儿。
    当 rss > used ,且两者的值相差较大时,表示存在(内部或外部的)内存碎片。
    内存碎片的比率可以通过 mem_fragmentation_ratio 的值看出。
    当 used > rss 时,表示 Redis 的部分内存被操作系统换出到交换空间了,在这种情况下,操作可能会产生明显的延迟。

    Because Redis does not have control over how its allocations are mapped to memory pages, highused_memory_rss is often the result of a spike in memory usage.

    当 Redis 释放内存时,分配器可能会,也可能不会,将内存返还给操作系统。
    如果 Redis 释放了内存,却没有将内存返还给操作系统,那么 used_memory 的值可能和操作系统显示的 Redis 内存占用并不一致。
    查看 used_memory_peak 的值可以验证这种情况是否发生。

调优手段

http://redis.io/topics/memory-optimization

Redis(>=2.2)针对小数据量的hash、list、纯数字的set、sorted set会采用特殊的存储方式(encoding)。当然,在数据量增大时,从优化encoding到正常encoding间的数据迁移,对用户来说是透明的。

  • hash
    • 优化存储:zipmap
    • 正常存储:dict(真正的hash table)
  • list
    • 优化存储:ziplist
    • 正常存储:list
  • set
    • 优化存储:intset
    • 正常存储:dict
  • sorted set
    • 优化存储:
    • 正常存储:dict+skip list(跳跃表)

判断小数据量的标准是entries个数和value的字节数:

  • hash-max-zipmap-entries 64  (hash-max-ziplist-entries for Redis >= 2.6) // hash,一个key下fields的个数
  • hash-max-zipmap-value 512   (hash-max-ziplist-value for Redis >= 2.6) // hash,一个key下各个field对应的value大小
  • list-max-ziplist-entries 512  // list
  • list-max-ziplist-value 64   // list
  • zset-max-ziplist-entries 128 // zset
  • zset-max-ziplist-value 64   // zset
  • set-max-intset-entries 512  // set

数据结构的选择

尽可能使用hash而非大量的小内存的KV结构,因为hash在小数据量的情况下,会使用zipmap这种类似一维数组的存储方式,从而大幅节省空间。执行测试数据结果如下:

saveAsString 13824
saveAsHash 3648
small hash len 23907 mem 28768
bigger hash len 24001 mem 37072

可以看到,存储相同的数据(entry个数和value字节数都小于ziplist阈值),kv string需要13k,而hash只需要3k。当增大entry个数使超过阈值时,虽然原始数据值只增长了几十个字节,而redis的存储多使用了8k+,这时应该是发生了encoding的转变。(run_hash脚本如附件)

 

老土了,第一次在线上使用redis,考虑到数据量相对较大,且响应速度要求高,所以对其内存原理进行了一定学习。

使用场景:存储约百万站点的检索信息,例如每个站点的名称、url、logo等。

数据结构设计:

  1. hash结构,key = site:<id>,field = name|url|logo|…,value = <name>|<url>|<logo>|…
  2. hash结构,key = site,field = <id>,value = 序列化后的 array(‘name’=><name>, ‘url’ =><url>, ‘logo’=><logo>,…)

方案1 是从文档上看到的使用方法,但有同事提出其内存消耗可能较大,且没有table的概念了,可能不利于后续的管理。

原理层面的内存占用分析:

临时抱佛脚,看了看redis的内存数据结构,猜测两种方案下,假定数据量是50w条,每条有10个field。其结构如下:

  • 方案1:公用内存:redisDb -> struct dict -> struct dictht -> dictEntry** table ,这时dictEntry里的key对应的是“site:<id>”;私有内存:其val对应的又是一个struct dict结构体 -> struct dictht -> dictEntry** table,这个dictEntry里的key对应的是 ‘name’ / ‘url’ / ‘logo’这样的field名称,其val对应的是name、url、logo等的数值。粗略估算其消耗为:50w * dictEntry指针 + 50w * dictEntry结构体 + 50w*(10 * dictEntry指针 + 10 * dictEntry结构体 + 10 * 字符串/数字的存储空间),第一和第二个dictEntry都消耗内存。
  • 方案2:公用内存:redisDb -> struct dict -> struct dictht -> dictEntry** table ,这时dictEntry里的key对应的是“site”;私有内存:其val对应的又是一个struct dict结构体(我们的数据较大,超出了hash_max_*的配置,故无法使用小数据时的优化方式)-> struct dictht -> dictEntry** table,这个dictEntry里的key对应的是 一个个的siteid,其val对应序列化后的字符串数据(也是struct结构体)。粗略估算其消耗为:50w * dictEntry指针 + 50w * dictEntry结构体 + 50w * Json串所在的struct空间,主要是第二个dictEntry消耗内存。
乍一看,方案2的内存消耗应该更多, 50w*(10 * dictEntry指针 + 10 * dictEntry结构体)这样都是方案1无需的冗余字段。但考虑到方案1是序列化后的数据,所以其还依赖于序列化的内存和cpu成本。

实例层面的内存占用分析:

利用redis的info方法返回的used_memory信息,对1条、1w条、10w条数据,测试的结果如下:

方案2(json) 方案1(array)
1次 使用内存(Byte) 3856 3456
1w次 使用内存 36,516,800 34,677,568
10w次 使用内存 366,213,056 346,355,232

针对一次写入,细化如下:

方案2(json) 方案1(array)
原始数据大小(Byte) 2432 2432
序列化后数据大小 3093 2432
redis使用内存大小 3824 3488
redis冗余内存大小=redis使用内存-序列化 731 1056
redis+序列化冗余内存大小=redis使用内存-原始数据 1392 1056

从以上可以看出:

  • 内存增长基本线性
  • 仅考虑redis自身使用冗余空间,方案2确实优于方案1
  • 在使用json作为序列化手段时,方案2甚至弱于方案1,因为json本身就引入大量的冗余
执行代码:http://flykobe.com/wp-content/uploads/2014/08/run.rar

其他优劣分析:

方案1,在仅更新部分field时,原子性更好,无须自己再关注锁。且没有序列化、反序列化的cpu开销。在仅需读取部分field时,网络传输量较小(按需get filed)。

方案2,若选择压缩比大的序列化方法,且存储空间确实较小。而且在获取多个field信息时(如name+url+logo),仅读取一次最终存储单元,而方案1需要读取3次。

参考文档:

  • http://www.infoq.com/cn/articles/tq-redis-memory-usage-optimization-storage 《Redis内存使用优化与存储》
  • http://www.searchtb.com/2011/05/redis-storage.html 《Redis内存存储结构分析(淘宝)》
  • https://github.com/nicolasff/phpredis/#readme 《phpredis readme》