Skip to content

Redis、排行榜与锁 ​

Category ​

database / cache 子树。节点定位:缓存、排行榜、分布式锁的边界治理——「谁是事实、谁是加速」,以及锁的租约成本。本页是 cache 子树的边界总纲。

Definition ​

缓存、排行榜和分布式锁经常被当成“性能配件”来介绍,但它们真正影响的是系统里“谁是事实、谁是加速”这条边界线。Redis 足够快,快到很多团队会顺手把越来越多的状态放进去,直到某一次故障才发现,自己已经把权威数据写进了一个重启会丢、主从会切换、内存会打满的组件里。

这一篇讨论三个高频切面:缓存到底该承担什么,排行榜的分数为什么比结构更难设计,分布式锁的真实成本花在哪里。

Problem ​

三者被当「性能配件」接入,真正影响的是「谁是事实、谁是加速」这条边界。最贵的错账都发生在边界颠倒时:把权威状态写进重启会丢、主从会切、内存会满的组件,故障日才发现恢复缓存不等于恢复数据;锁只把并发窗口收窄、不提供正确性,只设计拿锁成功的路径,租约、竞争、误删会在流量上来那天一起结算。

缓存的定位:加速层而不是事实层 ​

缓存的价值来自一个简单前提:同一份数据会被反复读,而源头算一次或查一次的成本更高。缓存存在的意义是让“读得快”,而不是让“数据住在这里”。一旦这个前提颠倒——读缓存成了习惯,没人再说得清源头在哪——缓存就从加速层变成了事实上的权威存储,而它并没有为这个角色做好准备。

判断一份数据能不能进缓存,通常会看这几个条件:

  • 源头明确,且不依赖缓存也能重建或恢复。
  • 允许短时间陈旧,陈旧窗口对业务无感或可补偿。
  • 访问频率足够高,否则缓存的维护成本收不回。
  • 体积可控,不会把内存压力转嫁给整个集群。

四条里最常被忽略的是第一条。命中率、过期时间、淘汰策略都有大量现成讨论,“这份数据的源头到底是谁”却往往没人写下答案。

把缓存当权威源,代价往往在故障日才结算 ​

平时把缓存当权威源用,监控曲线上几乎看不出异常:请求都快,业务都正常。差异要等到 Redis 重启、主从切换或内存淘汰触发的那一天才显现——那时丢的不是一个缓存项,而是一批“只存在于缓存里”的状态。

这类事故有几个共同特征:

  • 恢复缓存并不等于恢复数据,因为源头本来就不完整。
  • 受影响的状态往往分散在多个业务里,事后难以逐个补齐。
  • 缺少对账手段,团队甚至说不清到底丢了多少。

更稳妥的做法是反向假设:任何写进缓存的状态,都先回答“缓存清空之后,这份数据从哪里重建”。答案如果是“重建不出来”,它通常应该落在持久层,缓存只保留副本;答案如果是“由事件重放生成”,那也要确认重放链路真的存在,并且演练过。

排行榜为什么通常落在有序集合上 ​

排行榜的核心操作很稳定:更新某个成员的分数、取前 N 名、查某个成员的名次。有序集合把这三个操作的代价都压在对数级别,还顺带处理了同分成员的稳定排列,所以它往往成为默认选择,而且这个选择大多数时候是对的。

但它不是唯一选项,也覆盖不了所有榜单形态。常见的变化场景包括:

  • 榜单总量极大但只展示前一百名,定时聚合生成的快照往往更省。
  • 需要多维度排序或按条件过滤,单个有序集合的结构会开始变形。
  • 榜单要跨服合并或按赛季切换,成员身份和生命周期要先想清楚。

选型时更值得关心的是榜单的生命周期:赛季榜什么时候清空,历史榜怎么留存,新榜开启时旧榜的读请求怎么接续。这些问题比“用哪个结构”更容易被拖到上线前一夜才暴露。

分数设计常常比榜单结构更难 ​

结构选型是十分钟的事,分数语义却常常返工。原因在于“分数”这一列要同时承担排序、并列和展示三个职责,而业务对三者的要求经常互相冲突。

实践中反复出现的问题大致有这些:

  • 同分玩家的名次不稳定,刷新一下页面排名就跳动。
  • 想按“先达到者靠前”打破并列,却没有地方记录达成时间。
  • 分数要组合多个维度,直接拼接成小数后精度悄悄丢失。
  • 运营临时要求改分数或清零,才发现分数字段里藏着时间戳和维度位。

常见做法是把并列规则提前定下来:要么接受并列名次并保证排序稳定,要么把达成时间编码进分数的低位。前者简单但展示上要自洽,后者省事,却往往在“分数还有别的用途”时暴露出可读性和精度问题。经验上,分数一旦开始携带元数据,就值得把“展示分数”和“排序键”拆成两个字段。

榜单的读法取决于名次还是区间 ​

同样是排行榜,“我要看前十名”和“我要看我排第几”是两类完全不同的读请求,成本差一个量级。前十名可以整页缓存、定时快照;查名次却是每个玩家各自的请求,难以共享,也就难以缓存。

常见的处理思路:

  • 头部榜单整体定时生成,展示类请求全部落在快照上。
  • 个人名次单独提供接口,并明确它允许的延迟。
  • 百分位、段位这类粗粒度结论优先于精确名次,前者往往可以离线算。

很多榜单卡顿的根因,是所有玩家在整点活动结束后同一秒查自己的名次。这时问题不在数据结构,而在读路径没有被设计过。

分布式锁解决的是什么问题 ​

分布式锁的实际用途比它的出现频率暗示的窄:它解决的是“多个执行者都认为该自己做”的互斥问题,比如定时任务只跑一份、热点缓存项只回源一次、同一订单只被一个消费者处理。它并不提供严格意义上的正确性保证,只是在概率上把并发窗口收窄。

判断要不要引入一把锁,通常会问三个问题:

  • 这件事重复执行一遍,代价是什么?很多时候答案是无害,锁就可以省掉。
  • 锁住的临界区里做了什么?跨网络的长时间操作不该出现在这里。
  • 拿不到锁的一方怎么办?等待、放弃还是降级,这条路径常常没被设计。

三个问题里最常被跳过的是最后一个,而线上围绕锁的纠纷,多数发生在“有人没拿到锁”的时刻。

锁的真实成本:租约、时钟与续期 ​

分布式锁没有真正的阻塞,只有租约。持有方拿到的是一个会过期的凭据,过期之后锁对其他人重新可用。于是所有麻烦都从“过期”这一点长出来:租约太短,业务还没做完锁先失效;租约太长,持有方崩溃后系统要干等很久。

常见的失效形态有这几类:

  • 业务执行超过租约时长,第二个持有者进入,两边同时写。
  • 进程暂停——垃圾回收、虚拟机迁移、磁盘卡顿——让持有方“自以为还在租约内”。
  • 主从切换时锁记录尚未同步,新主上的锁处于释放状态。
  • 释放时没有校验持有身份,把别人刚拿到的锁删掉了。

应对思路也围绕租约展开:续期线程给长任务保活,释放前校验凭据再删,临界区里再叠一层版本校验作为最后防线。没有哪一层是完美的,通常按“锁失效的代价”决定堆几层防护,代价越高越值得堆。

常见误区 ​

  • 把 Redis 当成不会丢的存储,重启或主从切换后才发现一批状态无处重建。
  • 排行榜分数里塞进时间戳和维度位,运营要求改分数时才发现动不了。
  • 只设计拿锁成功的路径,锁竞争、锁过期、锁误删都没有下文。
  • 用锁保护一段跨网络的长事务,租约窗口根本罩不住临界区。

缓存、榜单和锁的问题很少在接入当天出现,它们往往在流量上来、节点重启或运营改规则的当天一起出现。

实战案例:一次「补发任务跑了两遍,全服多发 600 万」的锁租约排查 ​

案例为教学示例(非摘自真实项目),数字经过取整简化。它把本页「缓存的定位:加速层而不是事实层」「把缓存当权威源,代价往往在故障日才结算」「分布式锁解决的是什么问题」「锁的真实成本:租约、时钟与续期」「常见误区」用到深水区:一次凌晨补发,把「锁只收窄概率、不提供正确」和「名单只住在缓存里」两个缺口同时照了出来。

背景与现象:一款长线运营项目,运营要求对活动 bug 受影响玩家补发奖励。补发脚本凌晨 02:00 定时启动:从活动参与名单(活动期间为提速直接写在 Redis 集合里,活动后没人再说得清源头该是谁)逐个调发奖接口,接口无幂等键。任务在 A 机执行、持锁租约 30 分钟;同时段 B 机的补偿调度探测到锁即拉起同名任务。03:30 任务窗口结束后无人察觉异常,04:10 整点对账告警:发奖流水人次是名单的两倍。

第一步:现象与口径——先算「应发多少」,再看「实发多少」 ​

口径统一:应发以补发名单为准(每人 500 金币),实发以钱包服务的发奖流水为准,按人次与金额双口径对账。先记两笔:

  • 应发:名单 1.2 万人 × 500 金币 = 600 万
  • 实发:流水 2.4 万人次、合计 1200 万金币

实发恰好是应发的两倍——先查流水分布,再算锁的账。

第二步:分层归因——从发奖流水到锁租约的双跑账本 ​

text
名单账   补发名单 1.2 万人 × 500 金币 = 应发 600 万;发奖限速 200 人/分钟,全程 60 分钟
锁账     租约 30 分钟 < 任务 60 分钟:A 机 02:00 启动,02:30 租约到期未续期,B 机 02:30 抢到锁从头再跑
双跑账   两机各发一遍:流水 2.4 万人次 = 1.2 万 × 2,实发 1200 万,超发 600 万
对账账   以活动流水库重放出「每人 1 次」基准,核出 1.2 万人全部双发;回收 480 万(未消费 80%),已消费 120 万转活动对冲

账本收束:任务要跑 60 分钟、租约只有 30 分钟——第二个持有者是必然事件,不是意外;B 机从名单头重跑,而发奖接口没有幂等键,两遍全部生效。能核清事实,靠的是活动流水库的重放链路还在——本页「由事件重放生成」的那半句话「也要确认重放链路真的存在,并且演练过」,这次是靠运气成立的。

第三步:根因——锁只收窄概率,名单没有源头 ​

对照本页「锁的真实成本:租约、时钟与续期」:租约太短,业务还没做完锁先失效——本页四种失效形态里的第一种「业务执行超过租约时长,第二个持有者进入,两边同时写」逐字应验;续期线程、释放校验、临界区版本校验一层都没做,本页常见误区「只设计拿锁成功的路径,锁竞争、锁过期、锁误删都没有下文」同样应验。对照「缓存的定位:加速层而不是事实层」:名单四条件里最常被忽略的第一条「源头明确,且不依赖缓存也能重建或恢复」没人答过——名单只存在于 Redis 集合里,缓存就从加速层变成了事实上的权威存储;这次回收能定位到人,靠的是活动流水库还在,而不是名单本身可重建。

第四步:处置与回填——锁堆防护,幂等兜底,源头登记 ​

修法分两段。应急:04:10 告警、04:25 重放对账核出双发名单(对账 15 分钟)、04:50 按「未消费优先」回收 480 万(80%),已消费 120 万不追、转当周回收活动对冲并公告。制度四步:长任务锁配续期线程与释放前身份校验,临界区内叠版本校验;发奖接口以「任务号 + 玩家 ID」为幂等键,重复请求直接去重;Redis 集合登记「源头 = 活动流水库、可重放重建」,运营取数入口改走持久层;任务临界区从逐个 RPC 改为分片批处理 + 断点续跑,把 60 分钟临界区拆成 12 个 5 分钟段、锁按段持有。复核:次周用同名单重放演练,幂等键去重后 1.2 万人次 = 应发 600 万,零超发。

回填清单:

落点动作回填处
租约治理长任务锁配续期线程与释放前校验,临界区叠版本校验锁的真实成本:租约、时钟与续期
幂等兜底发奖接口以任务号+玩家 ID 为幂等键,重复请求去重分布式锁解决的是什么问题
源头登记缓存集合登记源头与重建方式,取数入口只走持久层缓存的定位:加速层而不是事实层
对账演练补发任务以流水重放为基准做整点对账,双发即告警把缓存当权威源,代价往往在故障日才结算

案例的三个教训 ​

  1. 锁的租约不是截止时间,是概率——租约 30 分钟罩 60 分钟的任务,第二持有者是必然事件;围绕锁的纠纷,多数发生在「有人没拿到锁」的时刻。
  2. 锁只把并发窗口收窄,收不住重复——真正的防线是幂等键,它比任何一层锁防护都便宜。
  3. 名单住在缓存里,故障日才结账——重放链路演练过一次,对账只要 15 分钟;没演练过,就只能事后回收 600 万。

Used By ​

缓存接入评审(四条件:源头明确、允许陈旧、访问够频、体积可控);榜单选型与读法分级(快照 vs 名次接口);分布式锁的引入判断(重复代价、临界区内容、拿不到锁的路径)与租约治理。

  • skynet(commit 4f76d75):缓存客户端的脏活集中在 channel 一层——各库驱动(lualib/skynet/db/redis.lua、mysql.lua、mongo.lua)都架在同一个 socket_channel.channel 工厂(lualib/skynet/socketchannel.lua:26)上:连接与重连是 channel:connect(同文件 :474),请求-响应按 session 配对是 channel:request(:510),消费侧给两种派发模型——按 session 派发(:96)与单线程按序派发(:194)。驱动只剩协议编解码,谁是事实、要不要加锁、一致性怎么保证都不在这一层

关系链:缓存边界(加速层不是事实层)→ 权威源登记 → 榜单选型与分数语义 → 读法分级 → 锁的互斥与租约治理。上游:数据建模(01,派生副本的源头语义)。下游:热点与削峰(02);玩法侧按负载选实现见排行榜。

Reference ​

本页为工程经验归纳;页内实战案例为教学示例(数字取整简化)。

游戏知识体系