引言
本文从实际工作中方案选型出发,以Redis的特性为着眼点,逐层剥开Redis技术内幕,并对工作中容易出现的使用误区进行了总结。
面临的问题
对于有状态的服务而言,数据库往往会成为系统的瓶颈所在。在用户活跃的高峰期,或者由于PUSH、活动等引发的请求突增,都会给后端的数据库造成巨大的压力。
由存储系统的特性我们知道,从内存读一个数据,比从一般的磁盘读要快10000倍左右,基于这样的原因,数据库本身也会有一定的内存cache。但是当热数据集比较大的时候,本地cache会频繁淘汰,此时会触发大量磁盘IO,性能急剧下降,往往也会伴随有大量的慢日志。另外,有些数据是需要通过复杂的查询或计算后得到且又不会频繁变化的。
虽说数据库可以通过读写分离来扩展读的能力,但存在增加slave实例的成本、主从延迟导致数据不一致等问题。子曾经曰过,“计算机科学领域的任何问题可以通过增加一个中间层来解决”,于是我们考虑在系统中再增加一个cache层,这里暂不讨论cache的设计实现。
让数据说话
需要cache的热点数据
- 用户个人资料
- 关注、粉丝、好友列表
- 关注数、粉丝数、好友数
- 用户之间的关系
- 用户浏览过的TopN...
基于以上数据的特点,我们最终选择了redis。
Redis是什么
Redis is an open source (BSD licensed), in-memory data structure store, used as a database, cache and message broker. It supports data structures such as strings, hashes, lists, sets, sorted sets with range queries, bitmaps, hyperloglogs, geospatial indexes with radius queries and streams. Redis has built-in replication, Lua scripting, LRU eviction, transactions and different levels of on-disk persistence, and provides high availability via Redis Sentinel and automatic partitioning with Redis Cluster.
以上引自官网的原话,概括起来有这样一些特性:
- 纯内存数据存储,并且支持多种持久化
- 支持丰富的数据结构,比如string,hash,list,set,sorted set,bitmap,hyperloglog, geospatial index等
- 支持复制、Lua脚本、LRU淘汰、事务
- 基于Sentinel实现高可用
- Cluster模式支持自动分区
看得见的优势
0. 性能强悍
虽然是单进程单线程模型,但是读写性能非常优异,单机可支持10wQPS,原因主要有以下几点:
- 纯内存操作,避免了与磁盘的交互
- 用hash table作为键空间,查找任意的key只需O(1)
- 单线程,天生的队列模式,避免了因多线程竞争而导致的上下文切换和抢锁的开销
- 事件机制,Redis服务器将所有处理的任务分为两类事件,一类是采用I/O多路复用处理客户端请求的网络事件;一类是处理定时任务的时间事件,包括更新统计信息、清理过期键、持久化、主从同步等;
当然这种单线程事件机制也是有缺陷的,由于所有的事件都是串行执行,一旦某个事件比较重就会阻塞其它事件,从而导致整个系统的吞吐率下降。比如某个客户端执行了一个比较重的lua函数、或者使用了诸如keys*、zrange(0,-1)、hgetall等全集合扫描的操作,又或者删除的过期键是个big key,又或者使用了较多内存的redis实例进行bgsave时,都会导致服务器一定程度的阻塞,一般伴随会有相应的慢日志。所以我们在实际使用redis的过程中,必须要给每一次的操作分配合理的时间片。
1. 支持持久化
对于内存型数据库,比如redis和memcache,如果数据状态不落盘,一旦服务器进程退出,那么这些数据状态也就会全部消失不见。数据状态的重建需要从后端数据库回源,这会给后端数据库造成非常大的压力,最坏的情况可能会把数据库压垮,导致服务不可用。
为了解决这个问题,Redis提供了RDB和AOF两种持久化方式。前者会生成一份内存快照--RDB文件,该文件是经过压缩的二进制格式,记录的是键值对数据;后者则是以Redis的命令请求协议格式来保存,记录的是命令操作;
- RDB的特点,文件体积小,加载速度快;但因为是对整个实例的内存生成快照,所以操作比较重,一般持久化的间隔不宜太快,所以保存的数据相对比较旧一些;
- AOF的特点,文件体积较大(可以用AOF重写进行覆盖);所有的写操作会追加到AOF缓冲区,持久化的行为可配置,分为三种,always(每次刷盘)、everysec(异步线程每隔1秒刷一次)和no(只写到page cache,交给操作系统来刷盘);相对来说,AOF文件数据保存的比较新一些,所以如果开启了AOF,那么Redis服务器恢复的时候会优先加载AOF文件。
由于RDB SAVE和AOF重写会阻塞主线程,所以都支持BG模式执行,至于持久化的具体实现这里就不展开讨论了。
2. 丰富的数据类型
比较巧妙的是,Redis并没有使用固定的数据结构来存储各种类型的数据,而是创建了一套对象系统,对于同一个对象,可以对应一个或多个不同的底层数据结构(或者叫做编码方式),某些特定的编码方式在时空间的效率上有所优化,通过执行"Object Encoding"可以查询当前编码方式。
- String 字符串对象,最大可支持512MB,memcache最大只支持1MB.编码可以是int,raw或者embstr,int对应整型数据,可方便计数,embstr用来保存长度小于等于39字节的字符串值,采用连续的空间进行存储,更好利用缓存优势;字符串对象常用来进行计数,或者缓存序列化的对象;
- List 列表对象,编码可以是ziplist或linkedlist,ziplist是为了节约内存而开发的,是一个经过特殊编码的连续内存块组成的顺序结构,当列表对象元素的个数较少以及元素的长度较短时会采用这种方式;列表对象一般可用来实现消息队列;
- Hash 哈希对象,编码可以是ziplist或hashtable,在Redis的实现里,采用的链式冲突来解决冲突问题,并且为了维护hash表的负载因子在一个合理的范围,会执行渐进式rehash;哈希对象一般用于存储某个对象的属性数据,便于选择性查询,这个效率要比粗暴的序列化和反序列化要高很多,比如用户的个人资料;另一个用法,则是利用ziplist编码方式实现压缩存储,节省内存;
- Set 集合对象,编码可以是intset或hashtable,当集合的元素不多且都是整数时,Redis就会使用整数集intset,底层是一个以有序、无重复的方式进行排列的数组,能有效的节约内存;这个对象一般用于去重,比如派奖;
- Sorted Set 有序集合对象,编码可以是ziplist或者skiplist,跳跃表skiplist是一种查找效率可媲美平衡树的数据结构,平均O(logN),最坏O(N),而且实现更加简单;其实,Redis用了skiplist和hashtable两种数据结构来实现zset,一方面hashtable能实现O(1)的查找,另一方面skiplist实现了有序,可支持范围查找;有集合序对象用的就比较广泛了,比如排行榜(只要是排序相关的列表都可以)、延迟任务队列等。
3. 高可用
Redis的高可用,主要通过主从复制机制以及Sentinel集群来实现。
- 主从复制 分为两个阶段,首先,当从服务器发起SYNC命令后,主服务器会生成最新的RDB文件发送给从服务器,并使用一个缓冲区来记录从此刻开始主服务器执行的所有写命令;待RDB文件传输完之后,再将该缓冲区的数据再发送给从服务器,这样就完成了复制。旧的Redis版本有个缺陷是,如果在第二个阶段发生失败,需要从第一个阶段重新开始同步,而这个阶段的操作会消耗大量的CPU、内存和磁盘I/O以及网络带宽资源,太过耗费资源。所以从2.8版本开始,实现了部分重同步,通过主从服务器各维护一个复制偏移量来实现。
- Sentinel 由一个或多个Sentinel实例组成的哨兵系统,可以监视任意多个主从服务器,并完成Failover的操作。Sentinal其实是一个运行在特殊模式下的Redis服务器,运行期间,会与各服务器建立网络连接,以检测服务器的状态;同时会与其它Sentinel服务器创建连接,完成信息交换,比如发现某个主服务器心跳异常时,会互相询问心跳结果,当超过一定数量时即可判定为客观下线;一旦主服务器被判定为客观下线状态,那么Sentinel集群会通过raft协议选举,选出一个Leader来执行Failover。
- Failover 一般来说,会先选出优先级最高的从服务器,然后再从中选出复制偏移量最大的实例,作为新的主服务器;最后将其它从和旧的主都切换为新主的从。
当从服务器有2个或者多个时,Redis的主从架构可以有两种形式。一种是,所有的从服务器直接挂在主服务器上,这种模式的优点是,所有从服务器复制的延迟相对较低,而缺点在于加大了主服务器的复制压力;另一种形式,是采用级联的方式,S1从M复制,S2从S1复制,以此类推,这种模式的优点是,将主服务器的复制压力分摊到多个服务器上,而缺点在于越处于级联下游的从实例,复制延迟就越大。
从主从复制模式可以看出,Redis的数据只能保证最终一致,不能保证强一致性。
4. 可扩展
读扩展,基于主从架构,可以很好的平行扩展读的能力。写扩展,主要受限于主服务器的硬件资源的限制,一是单个实例内存容量受限,二是一个实例只使用到CPU一个核。下面讨论基于多套主从架构Redis实例的集群实现,目前主要有以下几种方案:
- 客户端分片 实现方案,业务进程通过对key进行hash来分片,用Sentinel做failover。优点:运维简单,每个实例独立部署;可使用lua脚本,业务进程执行的key均hash到同一个分片即可;缺点:一旦重新分片,由于数据无法自动迁移,部分数据需要回源;
- Redis集群 是官方提供的分布式数据库方案,通过分片实现数据共享,并提供复制和failover。按照16384个槽位进行分片,且实例之间共享分片视图。优点:当发生重新分片时,数据可以自动迁移;缺点:客户端需要升级到支持集群协议的版本;客户端需要感知分片实例,最坏的情况,每个key需要一次重定向;不支持lua脚本;不支持pipeline;
- Codis 是由豌豆荚团队开源的一款分布式组件,它将分布式的逻辑从Redis集群剥离出来,交由几个组件来完成,与数据的读写解耦。Codis proxy负责分片和聚合,dashboard作为管理后台,zookeeper做配置管理,Sentinel做failover。优点:底层透明,客户端兼容性好;重新分片时,数据可自动迁移;支持pipeline;支持lua脚本,业务进程保证执行的key均hash到同一个分片即可;缺点:运维较为复杂;引入了中间层;
看不见的误区
- 键过大
Redis的key是string类型,最大可以是512MB,那么实际中是不是也可以这样用呢?答案是否定的,redis将key保存在一个全局的hashtable,如果key过大,一是占用过多的内存,二是计算hash和字符串比较都会更耗时;一般建议key的大小不超过2kB。
- Big key
或者说是big value,这会导致删除key的操作比较耗时,会阻塞主线程。比如有些同学喜欢用集合类的对象,动辄上百万的元素。对于这类超大集合,一般有两种优化方案,一是采取分片的方式,将每个集合分片控制在较小的范围内,比如小于1000个元素;二是起一个异步任务,对集合中的元素分批进行老化。
- 全集合扫描
比如在业务代码使用了keys*,hgetall,zrange(0, -1)等返回集合中所有元素,这些都属于阻塞操作,一般考虑用scan,hscan等迭代操作代替。
- 单个实例内存过大
内存过大有什么问题呢?上文中在讲到持久化的时候其实有说到,无论是生成RDB文件,还是AOF重写,都是要对整个实例的内存数据进行扫描,非常消耗CPU和磁盘资源;当使用Backgroud方式创建子进程时也会涉及到内存空间的拷贝,即便使用了COW机制,也会占用相当的内存开销。另外,在主从复制的第一阶段,save、传输和加载RDB文件的开销,也会随着RDB文件的变大而变大。当单个实例达到瓶颈时,更好的解决方案应该是采用集群方案。
- 大量key同时过期
redis删除过期键采用了惰性删除和定期删除相结合的策略,惰性删除则是在每次GET/SET操作时去删,定期删除,则是在时间事件中,从整个key空间随机取样,直到过期键比率小于25%,如果同时有大量key过期的话,极可能导致主线程阻塞。一般可以通过做散列来优化处理。
参考资料
- 《Redis设计与实现》(黄建宏)
- 大规模 codis 集群的治理与实践