游戏后端最容易被低估的需求就是匹配服排行榜。它不仅仅是给玩家看的一张表而是匹配算法实时依赖的数据源。今天我想复盘一个具体的设计排行榜位置原子交换以及为了支撑高并发交换锁粒度到底怎么定。这个题目听起来有点绕但背后是无数线上事故换来的经验尤其是当同一秒内几百场对局同时结束、几十个玩家分数同时跳动的时候一个不小心就会踩出线上事故。先说明一点这篇文章默认你已经知道 Redis 的 ZSET 大概是什么至少知道它是按 score 排序的有序集合。如果你还没用过没关系我会把排行相关的结构讲清楚。本文的核心场景放在匹配服但思路同样适用于任何需要高频更新名次、且要有严格一致性的业务排行榜比如活动实时榜、赛季积分榜、榜单运营位等。1. 从业务场景说起匹配服排行榜到底特殊在哪1.1 普通榜和匹配服榜是两种生物先聊聊普通排行榜。你看很多技术社区、数据平台发布的“编程语言排行榜”或者“游戏销量榜”这类榜单有一个共同点更新频率极低可能一天、一周甚至一个月才变动一次。这类排行榜用缓存加定时任务刷一刷就完了性能完全不敏感即便漏掉一次更新也没人会在意。匹配服排行榜完全不是一回事。匹配服每天承载多少场对局一个中型在线竞技游戏日活几十万一天下来轻轻松松上万场甚至几十万场。每一场 5v5 对局结束后双方十个人的匹配分一般叫 MMR 或者 ELO都要重新计算这十个分数落入排行榜后排名会瞬间发生多点变化。也就是说匹配服的排行榜是一个“写多读也多”的高频动态榜不是那种可以慢慢算的离线榜。这带来两个直接后果。第一排行榜更新必须在毫秒级完成否则匹配流程会被拖慢。第二所有玩家分数变化必须是可预期的A 从第 5 名掉到第 8 名B 从第 9 名升到第 5 名这个结果必须精准反映到后续的读取和匹配计算中。如果中间出现“A 在第 5 名和第 8 名之间反复横跳”的中间态匹配算法读到脏数据就可能把两个实力差距很大的玩家放进同一个比赛池那玩家体验就直接崩了。1.2 位置交换从哪里来几个典型触发场景你可能会问排行榜名次变化不都是分数变化带来的吗分数变了排名自然变为什么还要单独设计“位置交换”我刚开始做这个功能时也这么想后来发现匹配服里存在不少“分数不变但名次需要互换”的真实场景下面这些是我实际遇到过的玩家申诉与积分回滚。系统误判玩家违规扣了分后来申诉成功分数要恢复。这时候玩家往往已经掉到了某个名次而恢复分数后他要“回到”原来的位置这本质上是和当前名次靠前的玩家交换位置。处罚后的名次调整。一个前十名玩家被查出作弊分数清空或者冻结后一名顺位补上这也是一种隐式交换。如果清分和补位不是同时完成就会出现某个名次短暂空缺或者两个人同时显示为同一名的怪象。运营活动的换座机制。有些赛季活动允许榜单前列玩家使用“换座卡”和指定名次的玩家互换展示位。这种玩法听起来很简单但并发请求下极容易出问题因为没有分数变化纯粹是位置交换要额外处理。跨赛季段位继承。新赛季开启时玩家分数按比例压缩分数压缩后临界点附近的名次也会发生大范围重排其中很大一部分就是两个或多个玩家之间的位置对调。可以看到位置交换是一个被业务逼出来的独立需求不是分数变更的附属品。它和分数变更的最大区别在于交换发生时两个玩家的 score 都可能不变或变但最终效果是名次互换而所有观察者都必须在任意时刻看到一致的名次序列。1.3 设计目标先想清楚“不能错到哪里”做技术设计第一步不是画架构图而是定义“错了会发生什么”。位置交换如果坏了最轻微的表现是排行榜显示抖了一下重一点的表现是匹配算法读到错误名次把实力不对等的玩家分到一起最严重的表现是数据永久不一致玩家分数和名次对不上客服被投诉淹没。所以我把这个功能的设计目标定成了三条原子性一次交换要么完全生效要么完全不生效不存在中间状态。实时性交换完成后后续任意一次读操作都能立刻看到新排名。高并发安全多个交换请求并发执行时不能互相踩踏不能丢更新。目标定清楚之后再来定技术方案就顺多了。2. 原子交换的核心问题为什么两条 update 会打架2.1 并发场景下的经典事故重演先还原一个很多团队都踩过的坑。假设排行榜数据放在 MySQL 里表结构非常简单player_id、score、rank。交换 A 和 B 的位置很自然的写法是这样的UPDATE leaderboard SET rank 2 WHERE player_id A; UPDATE leaderboard SET rank 1 WHERE player_id B;第一眼看上去没问题对吧但你想一下并发场景。假设这时候 C 也同时发起交换要把 A 从第 2 名换到第 5 名。三条 SQL 在数据库里的执行顺序完全不可控一旦交错结果可能变成 A 的 rank 被改成 2又被改成 5而 B 还停留在 2数据库里出现两个 rank2 的玩家。这就是典型的非原子操作导致的并发事故。有人会说加事务不就行了确实用事务包住这两条 SQL能保证它们要么都成功要么都失败。但事务只能解决“同一时间点的一致性”解决不了“并发事务之间的隔离”。两个事务同时读到了相同的旧值各自执行更新后提交的就会覆盖先提交的这需要引入行锁或者乐观锁才能解决。2.2 数据库悲观锁和乐观锁的初步思路先看悲观锁。选 A 和 B 两行在事务里SELECT ... FOR UPDATE把这两行锁住再执行更新。这个方案在数据量小、并发量低的场景下完全可行问题在于匹配服的高并发场景下每一场对局结束都有十个玩家分数在变加上位置交换锁竞争会非常集中。尤其当热门玩家频繁处于排行榜前列时所有人都在抢那几行记录数据库会变成瓶颈事务等待超时很快就会出现。再看乐观锁。给排行榜表加一个version字段更新时带上版本号判断UPDATE ... WHERE player_idA AND version10更新成功则说明没有冲突失败则重试。这种思路适合读多写少的场景但匹配服排行榜的写频率非常高大量更新的重试率会让人崩溃。而且位置交换涉及两行记录版本号必须成对判断如果 A 的版本没变但 B 的版本变了重试逻辑要更复杂地协调。我不否认数据库方案在某些场景下的价值但针对匹配服排行榜这种吞吐量要求大多数团队最后都会把目光放到 Redis 的 ZSET 上。2.3 Redis ZSET 上的原子交换基础结构Redis 的 ZSET有序集合是排行榜场景的经典数据结构。每个成员member关联一个分数scoreRedis 内部按 score 排序score 相同时再按 member 的字典序排序。排行榜排名查询用ZREVRANGE从高到低或者ZRANGE从低到高取指定区间成员性能非常稳定。如果我们把玩家的匹配分直接作为 score 存进 ZSET那“交换位置”在数据层面到底意味着什么举个例子当前 A 的 score 是 1500排第 1B 的 score 是 1480排第 2。要让 A 和 B 的位置互换最直接的做法是把 A 的 score 变成 1480把 B 的 score 变成 1500这样 A 掉到第 2B 升到第 1。也就是说交换位置本质上就是交换两个人的 score 值。可用 ZSET 做交换必然面临两个问题第一两步操作都得原子完成第二交换后的 score 会不会和别人产生新的并列影响后续排名。Redis 官方提供了一个原子执行脚本的机制通过执行 Lua 脚本来保证一段逻辑在 Redis 内部串行完成。换句话说Lua 脚本天然规避了“两条 update 打架”的问题。我们后面专门讲脚本本身是怎么写的这里先记住一个结论在 Redis 里做位置交换优先选 Lua 脚本不要自己在应用层写两步更新。3. 锁粒度设计权衡全局锁、分段锁与无锁3.1 全局锁最省事也最容易瓶颈先聊最容易想到的方案给整个排行榜的交换操作加一把全局锁。不管是 JVM 内部的synchronized还是分布式锁只要保证同一时刻只有一个交换请求在执行就不会有并发踩踏的问题了。我见过一些团队最开始确实这么做实现起来非常快几行代码就完事。但压测跑起来问题就出来了所有交换请求都被串行化吞吐量非常难看。我实测过一个 MySQL 表结构下的全局锁方案TPS 大概只能到两三百超过这个量锁等待时间就会把请求超时率拉到不可接受的水平。更麻烦的是全局锁保护的粒度太大连不相关的玩家交换也被迫排队。比如一个在 1000 名的交换请求和一个在第 2 名的交换请求它们明明没有任何交集却要互相等待。这种无意义的等待在匹配服场景下尤其浪费因为排行榜越高频更新请求互相“堵门”的概率就越大。3.2 分段锁的拆分思路与死锁风险既然全局锁粒度太粗很自然的想法就是拆细。比如按排名区间拆成多个锁第 1 到 1000 名一把锁第 1001 到 5000 名一把锁第 5001 到 20000 名一把锁以此类推。交换操作只锁自己涉及的区间不同区间的交换请求互不干扰。这个方案看起来很美但有一个绕不开的问题跨区间交换怎么办A 在第 900 名B 在第 1200 名交换位置时A 要去第 1200 名B 要去第 900 名那这两个玩家实际上跨越了两个锁区间。此时你必须同时持有两把锁而且要注意加锁顺序否则就可能死锁。经典的死锁场景是请求 1 想交换 A第 900 名和 B第 1200 名先锁住了小区间再等大区间请求 2 同时想交换 C第 1200 名和 D第 900 名先锁住了大区间再等小区间。两个请求互相等待对方释放锁谁也执行不下去。要避免死锁最简单的办法是规定一致的加锁顺序比如永远先锁排名靠前的区间再锁排名靠后的区间。但这么一来跨区间的交换操作实际上又把多个分段耦合在了一起实现复杂度明显上升。如果你只有单机应用分段锁还有意义。但在匹配服这种通常多节点部署的微服务架构里应用内部的分段锁只能保证本节点内的并发安全跨节点还是要依赖分布式锁复杂度又上一层。3.3 基于 Redis Lua 的脚本级原子性聊到这里你可能已经预感到了如果 Redis 自己就能保证一串命令的原子性为什么还要在应用层费劲加锁对这就是 Lua 脚本方案的核心优势。Redis 从 2.6 版本开始支持执行 Lua 脚本脚本运行期间其他客户端的任何命令都不会插入执行。也就是说脚本里的所有读写操作天然形成了一个隔离级别最高的“临界区”不需要你在外部再加任何锁。这个特性太适合排行榜交换了。用 Lua 脚本完成一次交换整个过程在 Redis 内部是串行执行的外部观察者不可能看到“A 已经改了但 B 还没改”的中间态。相比全局锁Lua 方案没有额外的锁等待Redis 单线程模型下的命令处理本身就是顺序的相比分段锁Lua 方案不需要考虑跨区间加锁顺序也不用担心死锁。只要你的 Redis 实例能扛住整体读写压力交换操作本身的并发上限会比所有“应用层加锁”方案高一个数量级。当然Lua 脚本不是银弹。它最大的限制是集群模式下要求脚本涉及的 key 必须在同一个 hash slot 里否则会报 CROSSSLOT 错误。这个问题我会在后面的常见问题部分展开说。另一个限制是应用层如果还需要保证“同一个玩家同时只能发起一次交换”这种业务级别的约束单靠 Lua 脚本是不够的因为脚本只管数据层操作管不了业务请求的重复提交。3.4 四种方案对照怎么选看匹配服的规模这里我把全局锁、分段锁、数据库乐观锁、Redis Lua 脚本四种方案放在一起做一个直观对照。这些数据不是实验室指标是我在某个具体项目压测时得到的大致量级不同硬件和网络环境下会有浮动但相对关系是稳定的。方案一致性并发上限参考实现复杂度死锁风险适用场景全局分布式锁强一致低约 300~500 TPS低低活动期间低频交换分段锁强一致中约 1500~3000 TPS高中单机或小规模集群数据库乐观锁最终一致中重试率高中低低频写读多写少Redis Lua 脚本强一致高约 8000~10000 TPS低无匹配服高频实时榜我自己在实际项目中最终选用的是 Redis Lua 脚本方案原因很直接匹配服的并发峰值远高于分段锁能支撑的量级而 Lua 脚本把“原子性”从应用层下沉到了 Redis 内部实现代码最少性能和一致性反而最好。4. 完整落地实现数据结构、Lua 脚本与调用链路4.1 数据模型与 key 设计在设计实现之前先想清楚排行榜数据放在 Redis 里怎么组织。我的做法是直接用 ZSETkey 形如match:rank:global每个玩家是一个 memberscore 就是匹配分。这里有一点需要注意ZSET 的 score 是双精度浮点数如果用整数匹配分几万玩家、几百个并列分数是完全正常的但 Redis 在 score 相同的情况下会按照 member 的字典序排序这可能导致同分玩家的排名和你预期不一致。针对这个痛点业界常用的一个做法是“分数扩展法”。把实际匹配分乘以一个大数再加上一个微小的排序因子例如设定 score 匹配分 * 10000 (一个固定值 - 玩家等级/段位修正)让同分玩家也能有一个确定的先后顺序。不过交换位置时如果分数里带了这种修正位需要谨慎处理否则交换后可能出现 score 精度误差。我的选择是玩家匹配分本身保留一位小数交换时完全不碰排序因子让 ZSET 的自然排序来展示名次。除了 ZSET我还会为每个玩家保存一个简单的玩家信息 Hashkey 为match:player:{playerId}里面存当前分数、段位、最后更新时间等。这样交换脚本执行时除了改 ZSET还能顺带把玩家 Hash 里的分数同步更新避免后续读玩家信息时出现“分数和排名不一致”的情况。4.2 交换脚本逐行解析直接上一个核心的 Lua 交换脚本。这个脚本我拆成基础版和带版本号的升级版先说基础版-- KEYS[1]: 排行榜 ZSET 的 key -- ARGV[1]: 玩家A -- ARGV[2]: 玩家B local scoreA redis.call(zscore, KEYS[1], ARGV[1]) local scoreB redis.call(zscore, KEYS[1], ARGV[2]) if not scoreA or not scoreB then return { err player not found } end -- 先把B设置为A的旧分数再把A设置为B的旧分数 redis.call(zadd, KEYS[1], scoreA, ARGV[2]) redis.call(zadd, KEYS[1], scoreB, ARGV[1]) return { scoreA, scoreB }这段脚本逻辑非常简单但有几个容易被忽略的点。第一为什么先更新 B 再更新 A在 Lua 脚本内部这其实是无所谓的因为脚本运行期间其他命令进不来外部看不到中间状态。但我建议先更新排名靠后的玩家再更新排名靠前的玩家这样即使在日志审计里每一步都更符合直觉。第二zscore查询时如果玩家并不在排行榜里比如新玩家或者已经被清理出榜脚本会返回false此时必须终止交换否则会误把新玩家插入排行榜。上面的脚本用not scoreA做了判断。第三zadd默认是如果 member 不存在就插入存在就更新。交换场景下两个玩家必须都在榜内所以前面的存在性判断极其关键。如果想把防御做深一点zadd命令还可以带上XX选项表示只在 member 存在时才更新进一步防止意外插入新成员。我再给一个带版本号的升级版脚本。假设我们在 ZSET 之外还用match:player:{playerId}这个 Hash 保存了玩家的版本号每次交换前都要求双方版本号匹配交换成功后版本号自增。这样可以防止两个交换请求在极端时序下互相覆盖-- KEYS[1]: 排行榜 ZSET 的 key -- KEYS[2]: 玩家A的Hash key -- KEYS[3]: 玩家B的Hash key -- ARGV[1]: 玩家A -- ARGV[2]: 玩家B -- ARGV[3]: 期望的玩家A版本号 -- ARGV[4]: 期望的玩家B版本号 local currentVA redis.call(hget, KEYS[2], version) local currentVB redis.call(hget, KEYS[3], version) if not currentVA or not currentVB then return { err player not found } end if tonumber(currentVA) ~ tonumber(ARGV[3]) or tonumber(currentVB) ~ tonumber(ARGV[4]) then return { err version conflict } end local scoreA redis.call(zscore, KEYS[1], ARGV[1]) local scoreB redis.call(zscore, KEYS[1], ARGV[2]) redis.call(zadd, KEYS[1], scoreA, ARGV[2]) redis.call(zadd, KEYS[1], scoreB, ARGV[1]) redis.call(hincrby, KEYS[2], version, 1) redis.call(hincrby, KEYS[3], version, 1) return { ok done }这个升级版的思路类似乐观锁但把“检查版本”和“执行交换”合并到了一个 Lua 脚本里中间不会插入其他命令所以并发冲突的概率进一步降低。实践中我会把“一次交换请求”作为整体处理同一玩家同一时间只允许一个请求进入防止重复操作。4.3 服务端调用与并发控制Redis 这边准备好脚本后Java 服务端的调用就很简单了。我用 Jedis 客户端举例String luaScript local scoreA redis.call(zscore, KEYS[1], ARGV[1]) ...; ListString keys Collections.singletonList(match:rank:global); ListString args Arrays.asList(playerAId, playerBId); try (Jedis jedis jedisPool.getResource()) { Object result jedis.eval(luaScript, keys, args); // 解析 result如果是错误信息则走异常分支 }这里有几个细节值得注意。第一Lua 脚本字符串最好在服务启动时预加载到 Redis通过SCRIPT LOAD拿到 sha后续用EVALSHA执行这样可以避免每次请求都传一遍脚本内容节省网络带宽。第二eval调用本身是同步阻塞的如果 Redis 压力很大调用超时会影响服务线程建议设置合理的 socket 超时时间同时给 Redis 配置监控告警。服务端层面我会用一个简单的“进行中交换集合”来防止同一个玩家同时发起多个交换。这个集合可以放在本地内存也可以用 Redis 的 Set 结构。比如一个交换请求进来时先把两个玩家 ID 插入match:exchanging如果插入时发现已经有玩家在集合里直接拒绝当前请求。交换完成后再从集合里移除。这个操作配合 Lua 脚本能做到应用层“去重”和数据层“原子”双保险。4.4 压测数据与容量预估方案落地后一定要压测不然不知道极限在哪。我的压测环境是三台应用节点、一台 Redis 主节点模拟请求是随机抽取两个上榜玩家进行位置交换。跑出来的数据大概是这样方案TPS平均耗时超时率应用层全局锁 Redis ZSET400~60018ms0.5%分段锁 Redis ZSET1500~300012ms0.1%Redis Lua 脚本8000~100000.4ms0%为什么 Lua 脚本方案平均耗时这么低因为脚本在 Redis 内部执行没有网络往返的多次通信也没有应用层锁的等待。一次交换只需要一次EVALSHA调用Redis 内部跑完两个 ZSET 的读写然后返回结果。网络 IO 只有一轮耗时自然低。容量预估上如果你们匹配服日活 50 万高峰期每秒约 500 场对局结束每场 10 人更新那就是每秒 5000 次分数变更再加上位置交换类操作Redis 单实例 ZSET 完全可以扛住。但如果把这个量级再翻十倍就需要考虑 Redis 集群和分片了此时要注意 hash tag 的使用保证同一个排行榜的 key 落在同一个 slot。5. 常见问题与排查技巧实录5.1 排行榜“抖动”版本号与快照缓存上线第一天运营同事跑过来说排行榜在“抖”玩家刷新页面时看到的榜单顺序偶尔会闪一下过一两秒又恢复正常。排查发现页面读取走的是一个多级缓存缓存里存的是排行榜快照而交换请求直接更新了 Redis ZSET导致缓存还没刷新时用户读到的是过期数据和最新 ZSET 做对比后看到名次跳变。这个问题的根源不是交换逻辑出了问题而是“读链路”和“写链路”没有统一。我后来给排行榜加了一个全局版本号每次发生交换或者批量更新时都把版本号自增一次。读链路先检查版本号如果版本号变了就重新拉取 ZSET 快照并更新缓存。这样用户看到的就是一次干净的从旧快照切换到新快照而不是反复闪烁。5.2 CROSSSLOT 报错集群模式下的 key 规划项目中后期流量增大Redis 从单实例迁移到集群模式第一个踩到的坑就是 Lua 脚本运行时报错CROSSSLOT Keys in request dont hash to the same slot。原因很简单Redis Cluster 把 key 按照 hash slot 分布到不同节点而 Lua 脚本执行时会校验所有涉及到的 key 是否在同一个 slot否则拒绝执行。我涉及了match:rank:global和多个match:player:{playerId}这些 key 默认情况下 hash slot 完全不同脚本一执行就报错。解决办法是使用 Redis 的 hash tag。把 key 改成match:rank:{global}和match:player:{global}:{playerId}这样花括号里的global作为 hash tag参与 slot 计算的只有global这一个字段所有 key 都会落到同一个 slot。改造之后脚本在集群模式下也能正常运行。这个坑非常典型建议一开始就规划好 key 的命名规则。5.3 死锁与超时持锁时长与重试策略虽然 Lua 脚本方案不需要应用层锁但如果你还在用分段锁或者全局锁的老方案就一定要关注死锁和超时。我们早期用过一段时间的分段锁就发生过线上死锁导致交换接口大面积超时排查日志才发现是两个跨区间的交换请求互相持有对方需要的锁。后来我彻底切到 Lua 脚本后这类问题没有再出现过。但我还是建议保留一层兜底应用层给每个交换请求设定一个最长执行时间比如 3 秒超时后直接返回失败并记录告警。就算 Redis 卡顿也不至于拖垮整个服务。5.4 数据回源不一致幂等与补偿另一个容易被忽视的问题是排行榜数据回源。如果 Redis 数据因为故障被清理需要从数据库恢复排行榜。恢复过程中如果有交换请求已经落库但 Redis 里没有就会导致恢复后数据不一致。我的处理方式是在数据库记录每次交换操作的日志包含一个唯一的交换请求 ID。回源时把 Redis 重新构建成数据库里最新一次落库的状态同时保证所有交换操作都是幂等的。这样即使某个请求被重复执行也不会因为两次操作叠加而产生错误名次。6. 写在最后几点个人体会这个功能做完之后我最大的感受是看似简单的“交换位置”只要你把它放在一个高并发真实场景里所有隐藏的复杂性都会浮出水面。最初用全局锁、后来改分段锁、最后落到 Redis Lua 脚本这个演进路径本质上是在回答一个问题到底在哪一层保证原子性最合适我的答案是把原子性尽量下沉到数据存储层内部而不是在应用层靠锁去协调。Redis 单线程模型加上 Lua 脚本天然就是为这类短小精悍的原子操作设计的你非要绕开它去搞一堆分布式锁反而容易制造更多复杂度。另外还有一个体会是设计排行榜交换时一定不要把眼光只盯在“交换”本身。读链路的一致性、缓存刷新策略、集群模式下的 key 设计、应用的幂等控制这些都是同一个问题的不同侧面。只解决数据层的原子交换就像只修了一个车的发动机其他零件晃动一样会翻车。如果你后续也遇到类似场景我建议从小处起步先用 Lua 脚本把交换逻辑跑通然后加上版本号和缓存刷新最后根据压测数据决定要不要上集群。这个顺序能帮你用最小的复杂度拿到最稳的结果。