先说说我为什么想写这篇东西。我见过太多人一开始玩 Redis,把命令背得滚瓜烂熟,SET、GET、LPUSH、SADD 张口就来,可真到业务里要做选型的时候,犹豫了:用户信息到底放 String 还是 Hash?消息队列能不能直接用 List?积分排行榜除了 Zset 还有没有别的办法?这些问题本质上不是命令不会敲,而是对 Redis 核心数据结构的设计逻辑没吃透。
这篇文章我就用“排行榜应用”这条主线,把 String 到 Zset 五种核心数据结构从头到尾撸一遍,不光讲命令和底层原理,更重要的是讲清楚每个结构适合什么场景、不适合什么场景、踩过哪些坑。适合刚上手 Redis 的开发者,也适合写了两三年 Redis 但没系统梳理过数据结构边界的同学。看完你应该能自己做选型,而不是每次都得去搜“Redis 排行榜怎么实现”。
1. 先弄明白:Redis 为什么能这么快
很多人以为 Redis 快是因为“数据在内存里”,这话对了一半。内存存储只是基础,真正让 Redis 在单线程模型下还能扛住高并发的原因,是它的数据结构设计足够“聪明”。这里说的聪明,不是用了什么黑科技,而是针对不同的读写模式,选择了实现复杂度最合理的那套方案。
1.1 内存存储与单线程模型对数据结构设计的影响
Redis 的命令执行核心是单线程的,这一点哪怕到了 Redis 7 支持多线程 IO,真正的命令执行逻辑依然是单线程。这意味着同一时刻只有一个命令在跑,不存在多个线程同时修改数据的竞争问题,也就不需要加锁。好处是简单、原子性强,坏处是:任何一个命令都不能太“重”,否则会把后面所有请求都堵住。
这就直接决定了 Redis 内部数据结构的设计取向。举例来说,List 的结构用的是链表而不是数组,因为链表在头部和尾部插入元素是 O(1),而数组在头部插入需要把整个数组往后挪,O(N) 的成本在单线程模型下不可接受。Zset 用跳表而不是红黑树,也不是随便选的,红黑树虽然查找效率也很高,但实现复杂,而且做范围查询(比如“取分数从 100 到 200 的所有成员”)不如跳表直观、简单、高效。Redis 是追求极致性能和简洁实现的项目,跳表能用几十行代码实现清楚,红黑树做不到。
明白了“单线程导致命令必须轻量”这个前提,你就很容易理解后面所有关于数据结构的选型逻辑:你能不能用这个结构存数据、怎么用,核心就看你这个业务场景的读写模式够不够“轻”。
1.2 五种结构一句话定位
在深挖每个结构之前,我先用一句话给它们做个定位,后面展开的时候你们会有更具体的认知。
- String:最朴素的 key-value,适合存单值、计数器、简单缓存。
- Hash:一个 key 对应多个字段,适合存对象,可以单独更新某个字段。
- List:有序的字符串链表,适合做消息队列、时间线列表。
- Set:无序且自动去重的集合,适合做标签、抽奖池、共同关注。
- Zset:带分数的有序集合,适合做排行榜、延迟队列、滑动窗口。
你看,这五种结构基本覆盖了程序里最常见的五种读写模式:读一个值、读对象的多个字段、按顺序读一批值、判断某个值是否存在、按分数排序并读取。绝大多数业务场景,都能归到这五种模式里,这也是为什么 Redis 只有五种核心结构,却能支撑起几乎整个互联网后端生态的原因。
2. String 与 Hash:把单个值和多个字段分清
String 和 Hash 在缓存场景里经常被放在一起比较,因为它们都适合存“一个东西”。区别在于:你想存的是一个简单的值,还是一个有多个属性的对象。这个选择看着简单,但实际业务里我见过太多用错的案例。
2.1 String 的底层类型与典型操作
String 的底层并不是 C 语言里的标准字符串,而是 Redis 自己封装的 SDS(Simple Dynamic String)。SDS 在 O(1) 时间内获取字符串长度,并且能避免缓冲区溢出问题,这也是 Redis 能安全处理大量字符串操作的基础。
String 最经典的应用有三类。第一类是缓存:把序列化后的 JSON 直接塞进去,读取的时候反序列化回来,典型的如商品详情、配置信息。第二类是计数器:INCR、DECR、INCRBY 都是原子操作,适合做秒杀库存扣减、点赞数、访问统计,注意这里不需要先 GET 再 SET,直接 INCR 即可,天然防并发超卖。第三类是分布式锁:用 SET key value NX PX 毫秒数 实现加锁,用 Lua 脚本或 DEL 配合 value 校验实现解锁。
关于分布式锁我想多说一句。网上很多教程教你 SETNX 加锁、DEL 解锁,但一个常见的坑是:如果锁的过期时间设置太短,业务还没执行完锁就自动释放了,另一个进程拿到锁就会出问题。我在项目里一般用 Redisson 之类的库,它会自动续期,不需要在业务代码里手动管续期逻辑。当然如果你只想用原生 Redis 命令手写,那至少要把过期时间设成业务预估耗时的 5 到 10 倍,并且加锁时带上唯一标识,解锁前校验标识,防止把别人持有的锁误删。String 虽然简单,但用错了照样给你挖坑。
2.2 Hash:对象缓存的最佳选择
Hash 结构一个 key 底下可以挂很多 field,每个 field 又是一个独立的 value。如果你要缓存用户信息,包含昵称、头像、等级、积分、最后登录时间这五个字段,用 Hash 会很舒服:
HSET user:10086 nickname 张三 avatar /img/1.png level 5 score 3200 last_login 1700000000 HGET user:10086 nickname HINCRBY user:10086 score 100注意最后一条 HINCRBY,这个操作在排行榜里非常常用,因为它可以直接给某个用户的 score 字段增加分值,而不需要先读出来、程序里加、再写回去。HINCR 系列是原子操作,在并发场景下不会出错,这个性质后面讲排行榜的时候还会用到。
Hash 和 String 最大的区别是字段级更新能力。String 存 JSON 的话,你想改其中一个字段,得把整个 JSON 读出来、反序列化、改字段、再序列化、写回去,这一整串动作在高频更新下既消耗网络又消耗 CPU。Hash 则可以直接针对某个字段做读写计数,代价又小又原子。判断标准其实很简单:你的数据是不是有多个属性、属性之间是不是独立更新?如果是,用 Hash;如果只是一个孤零零的值,比如验证码、token、计数器,用 String。
我用过的一个购物车场景就是典型的 Hash 应用:以 用户ID 为 key,商品ID 为 field,商品数量为 value。用户加购、减购、改数量,都是 HSET 或 HINCRBY 一下的事;结算时 HGETALL 把整个购物车捞出来就行。比在关系型数据库里建购物车表轻量太多了。
2.3 选型对照表:什么时候用 String,什么时候用 Hash
为了让你一眼看明白,我把它们放到一张表里对比:
| 场景特征 | 推荐结构 | 原因 |
|---|---|---|
| 单值数据:验证码、token、计数器、限流计数 | String | 命令简单,INCR/DECR 原生原子 |
| 简单缓存:整个 JSON 一次性读、一次性写,不拆字段 | String | 序列化一次到位,代码简单 |
| 多字段对象,且经常更新其中一个字段 | Hash | HSET、HINCRBY 可单独更新字段,省流量 |
| 多字段对象,但总是整体读写 | String | 相比 Hash 更节省内存(小对象时 Hash 也有压缩优化,需实测判断) |
一个我个人的经验是:如果你不确定数据未来会不会拆字段更新,优先选 Hash。因为从 String 改成 Hash 涉及缓存 key 的数据迁移,比较麻烦;而从一开始就用 Hash,哪怕后来用不上字段级更新,损失也不大。这个思路在项目里帮我少踩了好几次坑。
3. List 与 Set:有序的队列与无序的集合
List 和 Set 表面上都是“存一组数据”,但一个保持插入顺序且允许重复,一个完全不关心顺序且自动去重。这个差异决定了它们在业务里的定位完全不同。
3.1 List 双向链表:消息队列和最新列表
List 在 Redis 里是双向链表实现,支持从头部或尾部推入、弹出数据,组合起来可以做几种常见的事。
最经典的是做简单任务队列。生产者用 LPUSH 把任务塞进队列,消费者用 BRPOP 阻塞弹出。BRPOP 和 RPOP 的区别在于:RPOP 没数据时直接返回空,你得自己写循环去轮询,既浪费 CPU 又增加延迟;BRPOP 可以指定阻塞超时时间,没数据时就挂在那里等待,一旦有数据立刻返回,极大减少空轮询。我在项目里用这个模式做过异步通知队列,效果很好,几千上万的任务量完全扛得住,没有引入额外中间件。
List 还有一个很适合的场景:最新列表。比如一个用户的最近操作记录、一篇帖子的最新评论。做法是 LPUSH 新数据,然后 LTRIM 限制列表长度只保留最近 100 条。LTRIM key 0 99 这个命令会剪掉超出范围的部分,相当于自动清理老数据,防止列表无限增长。这是我个人很推荐的一个“有界列表”方案,比定期删除干净利落得多。
需要提醒的是,List 的 LINDEX 和 LSET 这类随机访问操作是 O(N) 的,如果数据量大还频繁做随机访问,性能会很难看。另外,如果队列积压数据量巨大(几十万上百万),List 占的内存会很大,这时候就不太适合继续用 Redis 队列了,建议上真正的消息中间件,把数据生命周期的管控能力接过来。
3.2 Set 去重集合:标签、随机、抽奖
Set 最核心的能力就是去重,而且去重是由 Redis 内部完成的,你不需要先查询存在性再插入。这个特性在做标签系统时非常自然:给一篇文章打标签、给用户打兴趣标签,用 SADD 反复添加同一个标签也不会重复。
几个常用命令:
SADD user:10086:tags 游戏 篮球 音乐 SISMEMBER user:10086:tags 篮球 SCARD user:10086:tags SINTER user:10086:tags user:10087:tagsSINTER 可以取两个集合的交集,这在做“共同关注”“相似用户推荐”时非常好用。还有 SRANDMEMBER 和 SPOP,前者是随机返回一个成员但不删除,适合做“随机抽一个用户展示”这类活动;后者是随机返回并删除,适合做成“奖池抽奖”,保证一个用户只能抽到一次且奖品不会重复发放。我在一个活动系统里做过抽奖,用 SPOP 把奖池里的优惠券 ID 弹出,配合一个用户中奖记录 Set,基本实现了零重复的抽奖逻辑。
坑也要提醒:SMEMBERS 会返回集合里全部成员,如果一个 Set 里有几百万元素,这个命令会造成 Redis 阻塞,线上有可能会出事故。正确做法是业务里尽量用 SSCAN 分批迭代获取,或者干脆用 SRANDMEMBER / SPOP 等只取部分成员的命令。这个坑在单个 Set 很大时是真实的阻塞源,搞不好会把整个 Redis 拖垮。
3.3 List 与 Set 的选择思路
简单总结它们的取舍:
| 需求 | 推荐结构 | 关键命令 |
|---|---|---|
| 按时间顺序排列、允许重复、需要读取最近 N 条 | List | LPUSH + LTRIM |
| 先进先出队列、阻塞等待消费 | List | LPUSH + BRPOP |
| 自动去重、判断存在性、交集并集运算 | Set | SADD / SISMEMBER / SINTER / SUNION |
| 随机抽奖、奖池不重复、随机推荐 | Set | SPOP / SRANDMEMBER |
一句话:你需要“顺序”就选 List,你需要“唯一性”就选 Set。如果既需要顺序、又需要去重、还需要带分数排序,那就请看下一章 Zset。
4. Zset 深挖:跳表 + 排行榜实战
Zset 是整篇文章的主角。它和 Set 一样是去重的,但每个成员额外关联了一个 double 类型的分数(score),Redis 会按分数从小到大把成员排好序。这就让它成为做排行榜的天然数据结构。下面我先把它的底层实现讲透,再上实战。
4.1 结构本质与底层实现
理解 Zset 的本质,你只需要记住三个角色:成员(member)、分数(score)、排名(rank)。成员是唯一的,分数可以相同;Redis 内部会维持一个按 score 升序排列的跳表,同时还有一个哈希表记录 member 到 score 的映射。哈希表保证 O(1) 查到某个成员的分数,跳表保证按分数范围查询和按排名查询都是 O(logN)。
跳表是什么?你可以把它想象成一个多层的高速公路。最底层是普通链表,每个节点都指向下一个;第二层开始,每隔几个节点抽一个“快车道”,直接跳过中间节点;层数越高,跳过的节点越多。查找的时候从最上层开始,先走快车道大幅度向前,然后逐层降级细化,最后到底层定位目标。这个“多层索引”的构思很像二分查找,但不需要像数组那样要求连续内存,在链表上就能实现,而且写起来比红黑树简单得多。
Redis 为什么选跳表而不是红黑树?两个主要原因:一是跳表实现起来直观得多,插入、删除、查找的代码量远小于红黑树;二是跳表做区间查询有天然优势,比如你想取 score 值在某个范围内的所有成员,跳表按序走下去就行,红黑树则要反复做中序遍历,代码复杂度高不少。Redis 的设计哲学是“用简单实现解决 99% 的问题”,Zset 用跳表就是这种哲学的最佳体现。
另外补充一点:Zset 在数据量比较小的时候,底层并不是跳表,而是用压缩列表(ziplist,新版本叫 listpack)存储的。只有当元素数量超过 128 或者某个成员的长度超过 64 字节,它才真正转成跳表加哈希表结构。这是 Redis 做小数据优化的一种手段,保证小对象也能非常节省内存。
4.2 Zset 常用命令盘点
先过一遍最常用的命令,防止后面实战代码你看着陌生。
ZADD leaderboard 100 user_1 # 新增或更新:user_1 的分数是 100 ZINCRBY leaderboard 10 user_1 # 给 user_1 加 10 分 ZSCORE leaderboard user_1 # 查询 user_1 当前分数 ZRANK leaderboard user_1 # 查询 user_1 分数从低到高排第几名(从 0 开始) ZREVRANK leaderboard user_1 # 查询 user_1 分数从高到低排第几名(从 0 开始) ZRANGE leaderboard 0 4 # 按分数从低到高取前 5 名 ZREVRANGE leaderboard 0 4 # 按分数从高到低取前 5 名 ZRANGEBYSCORE leaderboard 100 200 # 取分数在 100 到 200 之间的成员(含端点) ZREMRANGEBYRANK leaderboard 0 99 # 删除排名最靠前的 100 个成员有几个细节值得注意。ZRANGE 和 ZREVRANGE 的下标从 0 开始,所以取 Top10 就是 ZREVRANGE key 0 9。ZRANK 返回的排名从 0 开始,如果你要展示给用户看“第 1 名”,程序里要加 1。ZADD 不只是新增,它还可以更新已存在成员的分数,同一个命令重复执行没有问题。这些命令单独看都很简单,组合起来就能做很多事情。
4.3 排行榜业务场景:积分总榜与同分排序的坑
现在进入重头戏:假设我们要做一个游戏积分排行榜,玩家通过完成关卡获得积分,需要展示全服前 100 名,并且实时更新。
最基础的版本很简单:
ZADD game_rank 0 user_1 ZINCRBY game_rank 50 user_1 # 玩家完成一关,加 50 分 ZREVRANGE game_rank 0 99 WITHSCORES # 取前 100 名,带分数展示每次玩家得分,执行一次 ZINCRBY;排行榜页面数据量大、要求实时性不高时,可以每 10 秒把前 100 名同步到本地缓存或 CDN,避免每次都打 Redis。这个基本流程不难,但真正做过排行榜的人都知道,最麻烦的问题是:同分怎么排名?
Redis 默认对分数相同的成员,按 member 的字典序排。也就是说分数一样时,user_a 会排在 user_b 前面,因为 a 比 b 大。这在业务上几乎没有意义。游戏行业的常见需求是“同分时先到先得”,也就是谁先达到这个分数,谁排前面。
一个业界常用的解法是把时间戳编码进 score:score = 实际分数 * 一个足够大的权重 + (一个大数 - 时间戳)。比如权重取 10 的 10 次方,大数取 9999999999,这样分数每增加 1,保底拉开 100 亿的 score 差距;同分的人再按“大数 - 时间戳”这部分的差异决定先后,越早到达的人拿到更大的后缀,排名自然更靠前。
这里必须提醒精度问题。Zset 的 score 是 double 类型,超过 2 的 53 次方(约 9 千万亿)之后就不能精确表示每个整数了。所以如果实际积分的数量级很大,权重就不能选得太大。比如实际积分能达到 1 亿,权重选 10 的 10 次方,score 就达到了 10 的 19 次方,直接超出精度范围,排名会错乱。稳妥的做法是先把业务可能的积分上限估算出来,再定权重,保证 score 最大值远小于 2^53。我之前做过一个积分排行榜,预计人均积分不超过 100 万,权重选了 10 的 6 次方,后缀取了 10 的 7 次方减时间戳,整体 score 范围控制在 10 的 13 次方以内,精度完全安全。
还有几个常见问题要处理。比如只展示前 100 名的话,底层数据不用全量展示,但 Zset 里还是可以存所有玩家,排名会用 ZRANK/ZREVRANK 查,查询成本很低。比如玩家流失严重,长期不活跃的人还占着 Zset 容量,你可以定期用 ZREMRANGEBYRANK 删除分数最低的一部分人,或者根据业务在每天凌晨跑一次冷数据清理。再比如高并发下频繁 ZINCRBY 会不会性能有问题?ZINCRBY 是单个命令,天然原子,单线程执行,每个操作 O(logN),在几百万成员的 Zset 上也就几十微秒级别,完全扛得住一般业务量。
4.4 扩展玩法:同分并列的排行榜怎么实现
上一节讲的是把时间戳编码进 score 实现先到先得,这样每个人排名是唯一的。但有些业务希望同分的人显示相同名次,比如分数一样都是第 2 名,而不是一个第 2 一个第 3。这时候怎么处理?
实现思路很简单:先用 ZREVRANK 拿到某个玩家的“物理排名”,再取这个玩家的分数,最后向前扫描和它分数相同的玩家,把所有相同分数玩家的最小物理排名作为展示名次。用伪代码表示:
function showRank(userId): rank = redis.ZREVRANK(key, userId) # 物理排名,0 开始 score = redis.ZSCORE(key, userId) # 用户分数 while rank > 0: prev = redis.ZREVRANGE(key, rank-1, rank-1) prevScore = redis.ZSCORE(key, prev[0]) if prevScore != score: break rank = rank - 1 return rank + 1 # 展示名次从 1 开始这个实现有一个性能点:如果同分的人非常多,向前扫描的次数会很多。不过实际排行榜里同分人数通常不会特别夸张,而且这个逻辑可以加一层本地缓存,或者干脆在服务端把前 100 名的名次一次性算好定期刷新,用户查询时直接读缓存,就不存在性能问题了。我把这个方法用在过一个社区积分榜上,效果很稳定。
再扩展一点,如果你需要实时查看“我前一名是谁、后一名是谁”,可以用 ZREVRANGE key rank-1 rank-1 和 ZREVRANGE key rank+1 rank+1 分别取前后玩家的 member,再配合 ZSCORE 展示分数。这些操作都是 O(logN),在高并发下也能保持很低延迟。
4.5 排行榜之外:Zset 还能做延迟队列和滑动窗口
排行榜是 Zset 最出名的应用,但它本身的能力远不止于此。延迟队列就是另一个经典玩法:member 是任务 ID,score 是任务要执行的时间戳。消费者定时用 ZRANGEBYSCORE key -inf now 取出所有已经到了执行时间的任务,然后 ZREM 把它们删掉,开始执行。这个方案实现简单、精度足够,比在数据库里扫过期任务要高效很多。
再比如滑动窗口限流:score 存当前时间戳,member 存请求 ID,窗口大小比如 1 分钟。每来一个请求,ZADD 当前时间戳,然后 ZREMRANGEBYSCORE key -inf 60秒前 把窗口外的数据清掉,再用 ZCARD 统计窗口内请求数,超过阈值就限流。这个方案用 Zset 实现了灵活的滑动窗口,比固定窗口限流平滑很多。我实际在接口限流里用过这个套路,逻辑非常清爽,维护成本极低。
所以 Zset 不只是“排行榜神器”,它本质上是“一个带分数的有序集合”,凡是需要按某个指标排序、筛选、计数、范围读取的场景,它都可以介入。你把它理解成“内存里的数据库索引”,很多应用思路就打开了。
5. 数据结构选型速查表和常见坑
前面把每种结构都过了一遍,最后我做一个汇总,再集中讲几个贯穿所有结构的常见问题。这些坑大部分是我自己在项目里踩过,或者在帮别人 review 代码时见过的,希望你能直接绕过去。
5.1 选型速查表
一个比较通用的对照表,适合贴在工位上:
| 业务需求 | 推荐结构 | 核心命令示例 |
|---|---|---|
| 验证码、token、缓存单值、计数器 | String | SET / GET / INCR / EXPIRE |
| 用户信息、商品信息、对象属性缓存 | Hash | HSET / HGET / HINCRBY |
| 任务队列、简单消息队列、最新列表 | List | LPUSH / BRPOP / LTRIM |
| 标签、去重、抽奖、共同关注 | Set | SADD / SISMEMBER / SINTER / SPOP |
| 排行榜、延迟队列、滑动窗口限流 | Zset | ZINCRBY / ZREVRANGE / ZRANGEBYSCORE |
| 分布式锁 | String | SET NX PX / Lua 解锁 |
这个表不复杂,真正复杂的是业务里往往是多个结构组合使用。比如一个社区 Feed 流,用户发帖时用 List 存时间线,用 Zset 存热门度排序,用 Set 存用户标签,再用 String 做计数缓存。Redis 的五种结构不是互斥选项,而是可以组合的积木,取决于你愿意怎么去搭。
5.2 大 key 与热 key 问题
大 key 是我最想强调的坑。一个 Hash 有几十万字段、一个 Set 有几十万成员、一个 Zset 有几十万成员,这些都会成为大 key。大 key 的危害不只是占内存多,而是很多操作是 O(N) 的,比如 HGETALL、SMEMBERS、ZRANGE 全量输出,在高并发下直接拖垮 Redis 单线程。
预防手段有两个方向。第一,业务上尽量不要让单个 key 无限增长,比如 List 用 LTRIM 限制长度,Set 定期裁剪,Zset 定期清理冷数据成员。第二,在 Redis 5.0 以上可以用 OBJECT ENCODING 命令查看某个 key 的编码,用 SCAN 扫描大 key 做监控;或者干脆用 --bigkeys 参数定期找出大 key,然后针对性地拆分或转移。我在一个项目里就是靠这个扫出一个 Hash 达到了几十万字段,当时线上偶尔出现几秒的卡顿,找了一圈最后定位到这个,拆分之后立竿见影。
热 key 是另一个问题。比如一个排行榜 Top1 的玩家,所有人都要看他的分数,这个 key 会被高频读取。解决办法一般是加一层本地缓存(如本地内存缓存 TopN 数据,比如 5 秒刷新一次),或者引入 Redis 集群的从节点分担读流量,再或者把热 key 加随机后缀分片到多个位置,读的时候随机取一个。这部分看你的业务体量,如果只是个人项目或者中小团队,本地缓存基本就足够了。
5.3 过期策略与内存淘汰
Redis 的过期机制是惰性删除加定期删除配合:惰性删除是访问 key 时发现过期才删;定期删除是后台任务每 100ms 抽一部分 key 检查是否过期。大批 key 同一时间过期会造成短暂卡顿,所以设置过期时间时最好加一个随机偏移量。
但要注意一点:排行榜这种场景不适合给 Zset 的 key 设置过期。如果你给整个排行榜设置了过期时间,时间一到全部数据就没了,玩家积分全部推翻,这在业务上是不可接受的。排行榜要么不设过期,要么做冷热分离,把一个月前的数据定期清理掉。这就涉及到 Redis 的内存淘汰策略。如果你只是拿 Redis 当缓存,可以配置 allkeys-lru;但如果 Redis 里还存着排行榜这类“数据不容丢失”的业务,就要小心了,内存打满后 LRU 淘汰可能把你不想淘汰的数据也干掉。我一般建议把 Redis 分两类:一类纯缓存,可以允许淘汰;一类存业务数据,禁止淘汰(noeviction),靠容量监控和扩容解决内存问题。这个决策要提前做,别等线上数据丢了再后悔。
5.4 持久化对数据结构的影响
Redis 的持久化分为 RDB 和 AOF,对数据结构的影响容易被忽略。RDB 是定期把内存快照落盘,AOF 是追加写命令日志。如果你的 Zset 很大(比如千万级成员),RDB 每次落盘时要遍历整个跳表完成序列化,耗时可能会很长;AOF 重写时也类似。所以排行榜这类大型有序集合,在持久化配置不当时会出现写命令延迟。
解决办法分两种。如果排行榜数据可以容忍丢失一段时间,比如游戏周榜,那可以只开 RDB,且把 save 间隔调长一点,减少快照频率。如果数据不能丢,比如用户资产相关的排名,那必须开 AOF,同时设置合理的 rewritemin-size 和 auto-aof-rewrite-percentage,控制重写的频率,避免重写时 CPU 和磁盘 IO 冲高影响正常命令执行。这个方面没有放之四海皆准的配置,但理解原理之后,你至少能根据业务特点做出合理选择。
写在最后的经验总结
我个人在实际操作中的体会是,Redis 数据结构学得好不好,不取决于你背了多少命令,而取决于你脑子里有没有“读写模式”这个概念。String 对应的是一次读写一个值,Hash 对应的是一次读写一个对象的某些字段,List 对应的是一次读写一批有序值,Set 对应的是判断某个值在不在集合里,Zset 对应的是按分数范围读取值。业务需求一旦被抽象成这些模式,选型就水到渠成了。
我曾经在维护一个老项目时吃过一次亏:原开发把所有排行榜数据都存成了 String,每次更新分数都先 GET 一个序列化的 JSON,反序列化,修改分数,再序列化,SET 回去。并发一高就出现覆盖写,排名数据经常错乱。后来我把它改成 Zset,用 ZINCRBY 一行命令解决了所有问题,那段时间我真正理解了一个道理:结构选对了,代码量能少一个数量级,线上故障也少一个数量级。
最后再分享一个小技巧。如果你的排行榜需要批量更新分数,别一个成员一个成员地发 ZINCRBY,每个命令都有一次网络往返(RTT),批量场景下累计耗时很可观。可以把多个更新合并成一个 Lua 脚本,在 Redis 服务端一次执行,也可以在客户端用 pipeline 打包发送。我实测过,100 个用户的分数更新用 pipeline 比逐个发送快了一个量级,在高并发活动期间这个优化非常值得做。数据结构的掌握是基础,但对命令的高效使用方式和常见坑的规避,才是真正拉开差距的地方。