组队刷深渊这件事,只要队伍超过两个人,分配环节就一定会出问题。有人喊"戒指归我,其他你们拿",有人喊"我只要武器,剩下分",喊完之后掉落一出来,谁先点到算谁的,线下约定在服务端没有任何约束力。等到真出了争议物品,聊天记录翻半天,谁也说不清当时到底约定了什么。
这个场景看起来只是游戏里的社交摩擦,但对做服务端的人而言,它其实是一个典型的"多方并发争抢同一份稀缺资源"问题:资源数量有限、参与方并行操作、规则由人临时约定、结果必须可追溯。只要把这四条抽象出来,你会发现它和秒杀库存、优惠券发放、工单抢单、会议室预订在结构上是同一个东西。
本文不讨论某个具体游戏的机制细节,而是以"多人组队掉落分配"作为业务背景,完整讲一遍怎样把一句口头约定变成服务端可执行的规则:状态机怎么设计、并发怎么挡住、幂等键怎么定、审计怎么做、出了问题从哪查。文中给出一套可以本地跑起来的 Java + Redis + MySQL 最小实现,代码完整可复制。
如果你正在做游戏服务端、活动发奖、资源抢占类功能,这篇可以直接当设计参考;如果你只是被分配纠纷困扰的开发同行,至少能看清"约定"和"规则"之间差了什么。
1. 这篇文章真正要解决的问题
先明确一个判断:掉落分配难的不是"选谁",而是"选完之后不重复、不丢失、不抵赖"。绝大多数自制分配功能翻车,都不是因为算法不够聪明,而是因为在这三件事上没做扎实。第一件是并发,两个人同时点"需求",服务端如果没有互斥,两条发放记录都会落库;第二件是幂等,客户端超时重试或者网关重发,同一次分配被执行两遍;第三件是审计,出问题之后只有一张结果表,没有过程记录,无法复盘到底哪一步判断错了。
很多团队的第一版实现是"队长手动分配"。看起来最省事:掉落进背包,队长点谁给谁。但这个模式的隐性成本很高。队长成了单点,掉线、挂机、误操作都会把整个分配卡住;同时它把纠纷从"系统问题"变成了"人际问题",运营侧完全无法介入。第二版常见实现是"自由拾取",谁手快归谁,这个方案的并发压力最小,但公平性最差,也是最容易引发流失的设计。
真正稳定的做法,是把分配拆成"表态阶段"和"结算阶段"两段,中间用一个带截止时间的会话隔开。表态阶段允许所有人在时间窗内并行提交需求,服务端只做记录,不做裁决;结算阶段由单一执行者(分布式锁持有者)按确定规则一次性算完,然后进入发放。这个结构的好处是:并发被收敛到一个点,规则是纯函数,结果可重算、可回放、可审计。
这篇文章适合三类读者。第一类是做游戏服务端、需要落地组队分配、ROLL 点、DKP 或竞拍系统的开发者。第二类是做电商或运营中台、需要处理库存抢占和奖品发放的工程师,两者的幂等和一致性问题完全同构。第三类是正在设计审批流、工单分配、任务派单的技术负责人,多方争抢单一资源的建模思路是通用的。
需要提前说清楚边界:本文的实现是通用教学版本,不涉及任何具体游戏的真实数值、真实爆率或真实运营规则。实际项目里的权重、优先级、冷静期、跨服同步等策略,需要按你自己的业务补充。代码层面只保证逻辑正确性和并发安全性,性能数字请以你自己的压测结果为准。
2. 掉落分配的核心概念与一致性边界
2.1 五种常见分配模式的本质差异
把市面上的分配方式归一下类,其实只有五种,差异集中在"决策权归谁"和"决策时机"两个维度上。
| 分配模式 | 决策权 | 决策时机 | 并发压力 | 主要风险 |
|---|---|---|---|---|
| 自由拾取 | 玩家自己 | 即时 | 极高 | 无公平性,纠纷最多 |
| 队长分配 | 队长 | 即时 | 低 | 单点阻塞,误操作无法撤销 |
| 需求/贪婪 ROLL | 服务端随机 | 表态截止后 | 中 | 随机结果需要可验证 |
| 积分竞拍(DKP) | 服务端按出价 | 截止后 | 中 | 积分扣减必须与发放同事务 |
| 个人掉落 | 系统预分配 | 掉落生成时 | 无 | 缺少社交激励,团队粘性低 |
从工程角度看,"个人掉落"是最容易做的,因为它在掉落生成的那一刻就把归属定死了,后续没有任何竞争。而"需求 ROLL"和"积分竞拍"是复杂度最高的,因为它们引入了时间窗口和多方状态,必须处理超时、掉线、重复提交、中途退出。
本文的实现选择"需求表态 + 服务端排序结算"这条路,原因是它同时具备两个优点:一是规则完全确定,可以用单元测试覆盖;二是过程可回放,同一个会话重算一遍必然得到同一个结果。随机 ROLL 当然也能做,但随机数种子必须落库,否则事后无法自证公平。
2.2 三个必须理解的一致性概念
第一个概念是表态幂等。玩家点"需求"这个动作,在网络层面可能发生多次:客户端点击后没收到响应会重试,网关超时会重发,玩家自己也可能连点。服务端必须保证"同一个人对同一个分配会话,只产生一条有效表态"。实现上最简单的方式是给表态表加(session_no, player_id)唯一索引,插入冲突就当作已存在处理,而不是抛错给用户。
第二个概念是结算互斥。结算必须只有一个执行者。如果两个线程同时读到"已有 5 条表态",各自算出一份结果,就会发出两份奖励。这里的互斥不能用数据库行锁硬扛,因为结算过程可能包含远程调用(发邮件、发背包、写流水),事务会拉得很长。更稳的做法是用 Redis 的SET NX PX抢一个带过期时间的锁,抢到的人结算,抢不到的人直接返回。
第三个概念是发放幂等。结算算出"谁获得什么"之后,真正落地是调用发奖接口。这个接口必须自带幂等键,键的构成建议是sessionNo:playerId:lootCode。这样即使整个结算流程被重跑,也不会重复发。发奖表上给这个键加唯一索引,插入成功才继续,插入失败说明已经发过,直接跳过。
把这三个幂等点守住,这套系统就具备了一个很重要的性质:任何一步失败都可以安全重试。这比追求"一次成功"现实得多,因为分布式环境下失败是常态。
2.3 状态机是整个设计的骨架
分配会话必须是一个明确的状态机,不能是几个布尔字段拼出来的。推荐五个状态:OPEN(会话已创建,尚未开放表态)、CLAIMING(开放表态)、SETTLING(已锁定,正在结算)、CLOSED(结算完成并发放)、CANCELED(作废)。
状态迁移只允许单向:OPEN → CLAIMING → SETTLING → CLOSED,任何状态下都可以→ CANCELED,但SETTLING之后不允许回退到CLAIMING。这个约束非常重要,它保证了"锁定的那一刻,表态集合就冻结了",否则结算过程中还有人能插入新表态,结果就不可复现。
在实际项目中,我建议把状态迁移做成一张显式的表或者一组显式的方法,而不是散落在业务代码里到处if (status == ...)。因为状态机的分支数量会随着需求增长迅速膨胀,一旦分散就再也收不回来。
3. 环境准备与技术选型说明
3.1 运行环境与依赖
本文示例使用 Java 服务端实现,具体版本以你本地实际项目为准,核心逻辑不依赖特定小版本。整体技术栈和用途如下:
| 组件 | 用途 | 说明 |
|---|---|---|
| JDK | 运行环境 | 建议使用长期支持版本,示例使用 record、switch 表达式等较新语法 |
| Spring Boot | Web 与依赖注入 | 提供 REST 接口和 Bean 管理 |
| MySQL | 持久化 | 存储会话、表态、发放记录,要求支持事务和唯一索引 |
| Redis | 分布式锁 | 仅用于结算互斥,不存储业务状态 |
| Maven | 依赖管理 | 常规构建即可 |
为什么要把锁放在 Redis 而不是数据库?因为结算过程包含远程调用,如果用SELECT ... FOR UPDATE占住行锁,整个事务期间连接不释放,在高并发场景下会迅速耗尽连接池。而 Redis 锁的生命周期只有结算那几百毫秒,且可以设置过期时间兜底,异常退出时不会永久死锁。
需要提醒的是,Redis 锁不是绝对安全的。如果结算耗时超过锁的过期时间,第二个执行者可能拿到锁。所以锁的过期时间要显著大于预估结算耗时,同时在结算入口再做一次"状态是否已经是 SETTLING 之后"的校验。这是双保险,缺一不可。
3.2 项目结构与配置文件
示例工程的目录结构如下,后面所有代码都按这个路径放置:
loot-demo ├── pom.xml └── src └── main ├── java/com/example/loot │ ├── LootApplication.java │ ├── controller/LootController.java │ ├── service/LootAllocationService.java │ ├── service/LootSettleService.java │ ├── domain/LootSessionStatus.java │ ├── domain/LootIntent.java │ └── mapper/LootMapper.java └── resources ├── application.yml └── schema.sql配置文件里需要把数据库和 Redis 的连接信息配好,同时给结算锁设置一个合理的过期时间:
# 文件路径:src/main/resources/application.yml server: port: 8080 spring: datasource: url: jdbc:mysql://127.0.0.1:3306/loot_demo?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai username: root password: change_me hikari: maximum-pool-size: 20 data: redis: host: 127.0.0.1 port: 6379 timeout: 3000ms loot: settle: lock-key-prefix: "loot:settle:lock:" lock-expire-millis: 10000 claim-window-seconds: 60claim-window-seconds是表态窗口时长,它决定了玩家有多长时间可以提交需求。这个值不能太短,否则掉线玩家来不及表态;也不能太长,否则一次掉落要等好几分钟才能分完,体验很差。实际项目里通常给 15 到 60 秒,并且支持队长手动提前截止。
4. 核心流程拆解:从掉落到发放的六个阶段
4.1 阶段一:掉落生成与归属判定
掉落生成阶段要回答一个问题:这批掉落是"队伍共有"还是"个人归属"。如果是个人掉落,直接跳过整个分配流程,把物品写进对应玩家的背包即可。如果是队伍共有,就创建一条loot_session记录,带上副本编码、掉落物编码、总数量、分配模式和截止时间。
这里容易踩的坑是数量问题。如果同一件掉落出了 3 个,而队伍有 5 个人,那这不是一次分配,而是三次分配。建议的做法是把"总数量"作为会话字段,结算时按排序结果依次取前 N 名发放。不要为每个物品单独建会话,那样会产生大量小会话,锁竞争和查询都会变复杂。
另一个坑是截止时间的时区。数据库里存DATETIME时,一定要统一用同一个时区写入,否则跨服或者跨机房部署时,"已经超时"和"还没超时"的判断会互相矛盾。示例中统一使用东八区。
4.2 阶段二:会话开放与表态收集
会话进入CLAIMING之后,玩家可以提交需求。表态接口需要三个参数:会话号、玩家 ID、需求类型(NEED强需求 /GREED一般需求 /PASS放弃)。服务端在这里要做四件事:校验会话状态是否允许表态、校验截止时间是否已过、校验玩家是否属于该队伍、写入表态记录。
"校验玩家是否属于该队伍"这一步经常被忽略。如果不校验,理论上任何知道会话号的客户端都能提交需求,这是一个典型的越权漏洞。正确做法是在会话创建时把队伍成员快照下来,表态时比对快照,而不是实时去查队伍表,因为玩家可能在分配过程中退出队伍。
表态写入使用"唯一索引 + 忽略冲突"的策略。SQL 上可以用INSERT ... ON DUPLICATE KEY UPDATE或者先查询再插入配合唯一索引兜底。前者更简洁,但要注意它会更新updated_at,如果你有基于更新时间做逻辑判断的地方要留意。
4.3 阶段三:截止与状态锁定
到达截止时间(或被手动触发)后,需要把会话从CLAIMING切到SETTLING。这个切换必须使用带条件的更新语句:UPDATE loot_session SET status='SETTLING' WHERE session_no=? AND status='CLAIMING'。通过影响行数判断是否切换成功,影响 1 行说明抢到了结算权,影响 0 行说明别人已经先切了。
这一步是整个系统里最关键的一行 SQL。它把"谁负责结算"这件事交给了数据库的行级原子性,而不是应用层的判断。很多实现写成"selsect 出来判断 status 再 update",在并发下就会有两个线程同时通过判断,然后都进入结算逻辑。
4.4 阶段四:规则排序与名额确定
结算的排序规则必须是一条可以完整用于ORDER BY的语句,不能包含应用层再加工的随机逻辑。示例使用的规则是三级排序:先按需求优先级降序,再按历史获取次数升序(公平轮转),最后按表态时间升序(先到先得)。三级排序之后结果唯一,不存在并列。
这个规则背后有一个业务判断:优先级保证了"真需要的人优先拿到",公平轮转保证了"不会同一个人连续拿同一件东西",时间戳保证了"规则有终点"。三者缺一,就会出现无法解释的结果。比如只有优先级没有轮转,某个高需求玩家会长期垄断;只有轮转没有优先级,抢手物品会被完全随机化,玩家反而更不满。
4.5 阶段五:幂等发放
名额确定后,进入发放。发放接口拿到的是"会话号 + 玩家 + 物品编码 + 数量",先按sessionNo:playerId:lootCode生成幂等键,插入发放记录表。插入成功才调用真正的背包服务;插入失败(唯一键冲突)说明已经发过,直接返回成功。
这里有一个细节需要处理:如果插入成功但调用背包服务失败怎么办?发放记录应该保持在PENDING状态,由定时任务扫描补偿。补偿任务同样要用幂等键去重,不能因为补偿就重发一次。这个设计把"发放"从一个同步动作变成了"至少一次投递 + 幂等消费",可靠性会高很多。
4.6 阶段六:审计落库与结果通知
结算完成后,除了更新会话状态为CLOSED,还要写一条结算快照记录,内容包括:参与人数、有效表态数、实际发放明细、使用的规则版本号。规则版本号很重要,因为它意味着"这条结果是用哪一版规则算出来的",将来规则调整时,历史结果仍然可以解释。
结果通知建议走异步推送,不要阻塞结算主流程。通知失败不影响分配结果,因为结果已经在数据库里了,玩家下次登录可以查到。
5. 完整示例代码实现
5.1 数据库表结构
先建表。三张核心表分别对应会话、表态、发放,另外一张审计表记录结算快照。
-- 文件路径:src/main/resources/schema.sql CREATE TABLE IF NOT EXISTS loot_session ( id BIGINT NOT NULL AUTO_INCREMENT, session_no VARCHAR(64) NOT NULL COMMENT '分配会话号,业务唯一键', dungeon_code VARCHAR(64) NOT NULL COMMENT '副本编码', loot_code VARCHAR(64) NOT NULL COMMENT '掉落物编码', total_quantity INT NOT NULL COMMENT '掉落总数量', allocated_quantity INT NOT NULL DEFAULT 0 COMMENT '已分配数量', mode VARCHAR(16) NOT NULL COMMENT '分配模式:NEED_GREED/BID/LEADER', status VARCHAR(16) NOT NULL COMMENT 'OPEN/CLAIMING/SETTLING/CLOSED/CANCELED', rule_version VARCHAR(16) NOT NULL DEFAULT 'v1' COMMENT '结算规则版本号', deadline_at DATETIME NOT NULL COMMENT '表态截止时间', version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本', created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_session_no (session_no), KEY idx_status_deadline (status, deadline_at) ) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT '掉落分配会话'; CREATE TABLE IF NOT EXISTS loot_intent ( id BIGINT NOT NULL AUTO_INCREMENT, session_no VARCHAR(64) NOT NULL COMMENT '分配会话号', player_id BIGINT NOT NULL COMMENT '玩家 ID', intent_type VARCHAR(16) NOT NULL COMMENT 'NEED/GREED/PASS', priority INT NOT NULL DEFAULT 0 COMMENT '需求优先级,越大越优先', fair_score INT NOT NULL DEFAULT 0 COMMENT '历史获取次数,越小越优先', created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_session_player (session_no, player_id) ) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT '玩家表态记录'; CREATE TABLE IF NOT EXISTS loot_grant ( id BIGINT NOT NULL AUTO_INCREMENT, grant_no VARCHAR(96) NOT NULL COMMENT '幂等键:sessionNo:playerId:lootCode', session_no VARCHAR(64) NOT NULL COMMENT '分配会话号', player_id BIGINT NOT NULL COMMENT '玩家 ID', loot_code VARCHAR(64) NOT NULL COMMENT '掉落物编码', quantity INT NOT NULL DEFAULT 1 COMMENT '发放数量', status VARCHAR(16) NOT NULL COMMENT 'PENDING/SUCCESS/FAILED', retry_count INT NOT NULL DEFAULT 0 COMMENT '补偿重试次数', created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_grant_no (grant_no), KEY idx_status (status) ) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT '发放记录,幂等键唯一';三张表的关系是:一个会话对应多条表态,一个会话对应多条发放记录,发放记录数等于total_quantity。uk_grant_no这个唯一索引是整个幂等体系的地基,绝对不能省。
5.2 状态机与服务接口
状态用枚举表达,并显式定义合法迁移,避免业务代码里到处判断字符串。
// 文件路径:src/main/java/com/example/loot/domain/LootSessionStatus.java package com.example.loot.domain; import java.util.Set; public enum LootSessionStatus { OPEN, CLAIMING, SETTLING, CLOSED, CANCELED; private static final Set<String> ALLOWED = Set.of( "OPEN->CLAIMING", "CLAIMING->SETTLING", "SETTLING->CLOSED", "OPEN->CANCELED", "CLAIMING->CANCELED", "SETTLING->CANCELED" ); public boolean canTransitTo(LootSessionStatus target) { return ALLOWED.contains(this.name() + "->" + target.name()); } }这个枚举的价值不在代码量,而在约束。它让"从 CLOSED 回到 CLAIMING"这类非法迁移在代码评审阶段就能被发现,而不是等线上出现重复发放才回头查。
表态接口的入参用一个record承载,简洁且天然不可变:
// 文件路径:src/main/java/com/example/loot/domain/LootIntent.java package com.example.loot.domain; import java.time.LocalDateTime; public record LootIntent( String sessionNo, long playerId, String intentType, int priority, int fairScore, LocalDateTime createdAt ) { public static LootIntent need(String sessionNo, long playerId, int fairScore) { return new LootIntent(sessionNo, playerId, "NEED", 10, fairScore, LocalDateTime.now()); } public static LootIntent greed(String sessionNo, long playerId, int fairScore) { return new LootIntent(sessionNo, playerId, "GREED", 1, fairScore, LocalDateTime.now()); } }priority由需求类型推导:强需求给高分,一般需求给低分。这样排序 SQL 就只需要一个字段比较,不需要在 SQL 里写CASE WHEN。业务规则放在代码里,SQL 保持简单,可读性和可测试性都更好。
5.3 表态服务:唯一索引兜底
表态的核心是"允许重复提交,但只保留一条"。这里用INSERT ... ON DUPLICATE KEY UPDATE实现,把并发写入交给数据库的唯一索引处理。
// 文件路径:src/main/java/com/example/loot/mapper/LootMapper.java package com.example.loot.mapper; import com.example.loot.domain.LootIntent; import org.apache.ibatis.annotations.Insert; import org.apache.ibatis.annotations.Mapper; import org.apache.ibatis.annotations.Param; import org.apache.ibatis.annotations.Select; import org.apache.ibatis.annotations.Update; import java.util.List; import java.util.Map; @Mapper public interface LootMapper { @Insert(""" INSERT INTO loot_intent (session_no, player_id, intent_type, priority, fair_score, created_at) VALUES (#{sessionNo}, #{playerId}, #{intentType}, #{priority}, #{fairScore}, #{createdAt}) ON DUPLICATE KEY UPDATE intent_type = VALUES(intent_type), priority = VALUES(priority), fair_score = VALUES(fair_score) """) int upsertIntent(LootIntent intent); @Update(""" UPDATE loot_session SET status = 'SETTLING', version = version + 1 WHERE session_no = #{sessionNo} AND status = 'CLAIMING' AND deadline_at <= NOW() """) int lockForSettle(@Param("sessionNo") String sessionNo); @Select(""" SELECT player_id AS playerId, fair_score AS fairScore FROM loot_intent WHERE session_no = #{sessionNo} AND intent_type <> 'PASS' ORDER BY priority DESC, fair_score ASC, created_at ASC """) List<Map<String, Object>> selectRanked(@Param("sessionNo") String sessionNo); }lockForSettle这条语句是整个流程的闸门。注意它同时带了status = 'CLAIMING'和deadline_at <= NOW()两个条件,意思是"只有确实还在开放且确实已经超时的会话才能被锁定"。如果某个线程想提前锁定一个还在开放中的会话,影响行数会是 0,直接被拒绝。这防止了恶意调用结算接口绕过表态窗口。
5.4 结算服务:Redis 锁 + 三级排序
结算服务负责抢锁、算名额、发奖励。锁只是加速手段,真正的正确性由上面的条件更新保证,所以这里即使锁失效也不会产生脏数据。
// 文件路径:src/main/java/com/example/loot/service/LootSettleService.java package com.example.loot.service; import com.example.loot.mapper.LootMapper; import org.springframework.beans.factory.annotation.Value; import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.time.Duration; import java.util.List; import java.util.Map; import java.util.UUID; @Service public class LootSettleService { private final LootMapper lootMapper; private final StringRedisTemplate redisTemplate; private final String lockPrefix; private final long lockExpireMillis; public LootSettleService(LootMapper lootMapper, StringRedisTemplate redisTemplate, @Value("${loot.settle.lock-key-prefix}") String lockPrefix, @Value("${loot.settle.lock-expire-millis}") long lockExpireMillis) { this.lootMapper = lootMapper; this.redisTemplate = redisTemplate; this.lockPrefix = lockPrefix; this.lockExpireMillis = lockExpireMillis; } public void settle(String sessionNo, String lootCode, int totalQuantity) { String lockKey = lockPrefix + sessionNo; String token = UUID.randomUUID().toString(); Boolean locked = redisTemplate.opsForValue() .setIfAbsent(lockKey, token, Duration.ofMillis(lockExpireMillis)); if (!Boolean.TRUE.equals(locked)) { // 没抢到锁说明别人正在结算,直接返回,由对方推进状态 return; } try { int affected = lootMapper.lockForSettle(sessionNo); if (affected == 0) { // 状态已被推进或还未到截止时间,本次不结算 return; } List<Map<String, Object>> ranked = lootMapper.selectRanked(sessionNo); int grantCount = Math.min(totalQuantity, ranked.size()); for (int i = 0; i < grantCount; i++) { long playerId = ((Number) ranked.get(i).get("playerId")).longValue(); String grantNo = sessionNo + ":" + playerId + ":" + lootCode; lootMapper.insertGrantIgnore(grantNo, sessionNo, playerId, lootCode); } lootMapper.markClosed(sessionNo, grantCount); } finally { // 只有持有者才能释放锁,避免误删别人的锁 String current = redisTemplate.opsForValue().get(lockKey); if (token.equals(current)) { redisTemplate.delete(lockKey); } } } }这里有两个细节值得单独说。第一,释放锁时比对 token,防止"A 的锁过期后被 B 拿到,A 执行完把 B 的锁删了"这个经典问题。第二,insertGrantIgnore用INSERT IGNORE或ON DUPLICATE KEY UPDATE,让重复插入静默跳过,而不是抛异常中断循环。
对应的两条 SQL 补在 Mapper 里:
// 文件路径:src/main/java/com/example/loot/mapper/LootMapper.java(续) @Insert(""" INSERT INTO loot_grant (grant_no, session_no, player_id, loot_code, quantity, status) VALUES (#{grantNo}, #{sessionNo}, #{playerId}, #{lootCode}, 1, 'PENDING') ON DUPLICATE KEY UPDATE updated_at = updated_at """) int insertGrantIgnore(@Param("grantNo") String grantNo, @Param("sessionNo") String sessionNo, @Param("playerId") long playerId, @Param("lootCode") String lootCode); @Update(""" UPDATE loot_session SET status = 'CLOSED', allocated_quantity = #{allocated}, version = version + 1 WHERE session_no = #{sessionNo} AND status = 'SETTLING' """) int markClosed(@Param("sessionNo") String sessionNo, @Param("allocated") int allocated);markClosed同样带条件,只有处于SETTLING的会话才能被关闭。如果补偿任务和正常结算同时跑,第二个执行者的影响行数是 0,不会覆盖结果。
5.5 对外接口
最后把接口暴露出来,方便本地验证:
// 文件路径:src/main/java/com/example/loot/controller/LootController.java package com.example.loot.controller; import com.example.loot.service.LootAllocationService; import com.example.loot.service.LootSettleService; import org.springframework.web.bind.annotation.*; import java.util.Map; @RestController @RequestMapping("/api/loot") public class LootController { private final LootAllocationService allocationService; private final LootSettleService settleService; public LootController(LootAllocationService allocationService, LootSettleService settleService) { this.allocationService = allocationService; this.settleService = settleService; } @PostMapping("/intent") public Map<String, Object> intent(@RequestBody Map<String, Object> body) { String sessionNo = String.valueOf(body.get("sessionNo")); long playerId = Long.parseLong(String.valueOf(body.get("playerId"))); String type = String.valueOf(body.getOrDefault("intentType", "NEED")); int fairScore = Integer.parseInt(String.valueOf(body.getOrDefault("fairScore", 0))); boolean accepted = allocationService.submitIntent(sessionNo, playerId, type, fairScore); return Map.of("code", accepted ? 0 : 1001, "accepted", accepted); } @PostMapping("/settle") public Map<String, Object> settle(@RequestBody Map<String, Object> body) { String sessionNo = String.valueOf(body.get("sessionNo")); String lootCode = String.valueOf(body.get("lootCode")); int quantity = Integer.parseInt(String.valueOf(body.getOrDefault("quantity", 1))); settleService.settle(sessionNo, lootCode, quantity); return Map.of("code", 0, "message", "settle triggered"); } }submitIntent内部要先校验会话状态和截止时间,再调用upsertIntent,这两步的顺序不能反。如果先写后校验,一旦校验失败还要回滚,事务边界会变复杂;如果做成"先校验后写",校验失败直接返回,写入根本不会发生。
6. 运行验证与并发压测
6.1 启动与基础验证
先执行建表脚本,然后启动应用:
# 初始化数据库(把 change_me 换成本地实际密码) mysql -h 127.0.0.1 -u root -pchange_me loot_demo < src/main/resources/schema.sql # 启动应用 mvn spring-boot:run启动成功后,先用两条curl验证单链路是否通:
# 1. 玩家 1001 提交强需求 curl -s -X POST http://127.0.0.1:8080/api/loot/intent \ -H "Content-Type: application/json" \ -d '{"sessionNo":"S202401","playerId":1001,"intentType":"NEED","fairScore":3}' # 2. 触发结算 curl -s -X POST http://127.0.0.1:8080/api/loot/settle \ -H "Content-Type: application/json" \ -d '{"sessionNo":"S202401","lootCode":"SWORD_001","quantity":1}'预期第一次curl返回{"code":0,"accepted":true};如果会话还没到截止时间,第二次会返回settle triggered但数据库状态不会变化,这是正常的,因为lockForSettle的影响行数是 0。验证结果请直接查库:
SELECT session_no, status, allocated_quantity, version FROM loot_session WHERE session_no = 'S202401'; SELECT grant_no, player_id, loot_code, status FROM loot_grant WHERE session_no = 'S202401';成功的标志是:会话状态为CLOSED,allocated_quantity等于实际发的数量,loot_grant里的记录数与之相等,且grant_no无重复。
6.2 并发表态压测
真正要验证的是并发下的幂等性。用一个简单的 bash 脚本,让 20 个请求同时提交同一个玩家的表态:
# 文件路径:scripts/concurrent_intent.sh #!/usr/bin/env bash set -euo pipefail SESSION_NO="S202401" for i in $(seq 1 20); do echo "1001 ${SESSION_NO}" done | xargs -n 2 -P 20 -I {} bash -c ' curl -s -o /dev/null -w "%{http_code}\n" -X POST http://127.0.0.1:8080/api/loot/intent \ -H "Content-Type: application/json" \ -d "{\"sessionNo\":\"$1\",\"playerId\":$0,\"intentType\":\"NEED\",\"fairScore\":2}" '执行后统计表态表:
SELECT COUNT(*) AS intent_count FROM loot_intent WHERE session_no = 'S202401';只要结果恒为 1,说明唯一索引和ON DUPLICATE KEY UPDATE生效了。如果出现 2 条以上,第一件事是检查表上是否真的建了uk_session_player,很多时候是迁移脚本漏执行。
6.3 并发结算压测
结算的并发验证更关键,因为它直接对应"会不会重复发奖"。用同样的方式同时触发多次结算:
#!/usr/bin/env bash set -euo pipefail for i in $(seq 1 10); do echo "$i" done | xargs -I {} -P 10 curl -s -o /dev/null -X POST http://127.0.0.1:8080/api/loot/settle \ -H "Content-Type: application/json" \ -d '{"sessionNo":"S202401","lootCode":"SWORD_001","quantity":1}'然后核对发放表:
SELECT COUNT(*) AS grant_count FROM loot_grant WHERE session_no = 'S202401';对于quantity = 1的会话,这个数字必须恒为 1。如果出现 2,说明lockForSettle的条件更新被绕过了,重点检查是不是有人在代码里用了"先 select 再 update"的写法;如果出现 0,说明deadline_at条件没满足,会话其实还没到截止时间。
7. 常见问题与排查方法
| 问题现象 | 可能原因 | 排查方式 | 解决方案 |
|---|---|---|---|
| 表态接口返回成功,但库里没有记录 | 事务未提交或表名映射错误 | 打开 SQL 日志看实际执行语句 | 检查@Transactional是否加在 public 方法上,检查 Mapper 映射 |
| 表态重复插入抛唯一键异常 | 代码用了普通INSERT而非 upsert | 查看执行 SQL 是否含ON DUPLICATE KEY | 改用ON DUPLICATE KEY UPDATE或捕获异常当作已存在 |
| 结算后发放数量翻倍 | 结算入口缺少状态条件更新 | 查loot_session.version是否被加过两次 | 强制走lockForSettle条件更新,禁止先查后改 |
| 会话一直卡在 SETTLING | 结算中途异常且未回滚状态 | 查应用日志中的异常堆栈 | 增加补偿任务,对超时的 SETTLING 会话重新触发或置为 CANCELED |
| Redis 锁提前失效 | 结算耗时超过lock-expire-millis | 查结算耗时分布 | 调大过期时间,同时保证条件更新兜底 |
| 玩家反馈"我能表态但别人看不到" | 表态快照与实际队伍不一致 | 对比会话创建时的成员快照和当前队伍 | 成员校验以会话快照为准,不要实时查队伍表 |
| 结算结果每次跑都不一样 | 排序规则包含非确定性字段 | 检查ORDER BY是否覆盖到唯一字段 | 排序末尾追加created_at或主键,保证结果唯一 |
| 补偿任务重复发放 | 补偿未使用幂等键 | 查loot_grant中是否有多条同grant_no | 补偿逻辑必须复用同一个幂等键 |
这张表里的每一条都对应真实项目里出现过的排查方向。其中"会话卡在 SETTLING"是最常见的,因为结算过程可能包含远程调用,一旦远程超时而本地事务已经提交,状态就推不动了。解决办法不是让事务变长,而是把远程调用移出主事务,用状态表驱动补偿。
8. 工程最佳实践与设计取舍
8.1 幂等键的设计原则
幂等键要满足三个条件:全局唯一、可重建、包含业务语义。sessionNo:playerId:lootCode满足这三点:同一次分配会话里,同一个玩家对同一种掉落只会有一条发放记录;这个键完全由业务参数推导,不需要额外存储;从键本身就能看出是哪个会话发给了谁什么东西。
千万不要用 UUID 做幂等键。UUID 虽然唯一,但每次调用都生成新的,重试时无法复用,等于没有幂等。也不要用自增 ID,因为在插入之前拿不到。
8.2 事务边界怎么划
一个经验法则是:事务里只放本地数据库操作,不放任何远程调用。结算流程里,lockForSettle、selectRanked、insertGrantIgnore、markClosed可以在一个事务里,因为它们都是本地 SQL 且执行很快。而"把物品塞进玩家背包"这种操作,一定是在事务提交之后异步做的。
这样划分的代价是会出现短暂的"已分配但未到账"状态,需要靠补偿任务收敛。但这个代价远小于长事务带来的锁等待和连接耗尽。在分配这种高频场景下,长事务几乎是必炸的。
8.3 公平轮转的冷启动
fair_score字段表示"该玩家历史上获取该物品的次数"。新玩家如果fair_score一直是 0,会持续压过老玩家,导致老玩家永远排不上。常见的处理是给fair_score加一个初始值,比如全服平均值,或者按角色创建时间做微调。
另一个思路是把fair_score换成"距上次获取的天数",越大越优先。这样新玩家也能自然参与轮转,不会出现"新人永远赢"的极端情况。两种方案各有取舍,前者实现简单,后者更像人情。
8.4 监控与灰度
这套系统上线后,至少需要三个监控指标:结算成功率、单次结算耗时、PENDING状态发放记录的积压量。第三个尤其重要,它直接反映补偿任务是否跟不上。
灰度方面,建议先只在低价值掉落上启用自动分配,高价值掉落仍然走人工确认。等结算成功率和耗时稳定之后,再逐步放开。不要一次性全量切换,因为分配规则的争议往往在"结果不符合玩家预期"时才爆发,而不是技术故障时。
8.5 权限与安全边界
结算接口绝对不能暴露给客户端直接调用,必须由服务端内部触发或者加白名单校验。因为结算一旦被恶意提前触发,可能绕过表态窗口,让某些玩家的需求来不及提交。同理,表态接口必须校验调用方身份,playerId不能从请求体里直接信任,要从会话上下文中取。
数据库层面,应用账号只授予SELECT、INSERT、UPDATE权限,不授予DELETE和DROP。分配记录属于审计数据,只允许作废标记,不允许物理删除。
9. 最后聊几句落地时的取舍
回到最开始那个场景:队伍里有人喊"绝不争抢",这句话在技术上的价值,其实是它揭示了需求——玩家想要一个提前说清楚的规则。服务端能做的不是替他们做决定,而是把这个决定变成一个有时间点、有优先级、有记录的东西。规则一旦落库,争议就从"你当时是不是这么说的"变成了"我们查一下第 3 条记录"。
这套实现里有几处是可以按项目实际情况简化的。如果你们的分配场景并发很低,比如同时只有一两个队伍在跑,Redis 锁可以省掉,直接靠数据库条件更新就够了;如果掉落不涉及数量大于 1 的情况,allocated_quantity字段也可以不要。但有两样东西不能省:条件更新和幂等键。前者保证不会重复结算,后者保证不会重复发放,缺任何一个,系统在异常路径上都会出问题。
值得继续深挖的方向有几个。一是把结算规则做成可配置的策略,用策略模式替换硬编码的排序,这样不同副本可以有不同的权重;二是把表态过程做成可回放的事件流,出了问题能按时间顺序重放一遍;三是把补偿任务做成通用的发放框架,复用到活动奖励、邮件附件等其他场景去。
最后一个提醒:分配系统上线前,请务必在测试环境把并发场景跑一遍,尤其是"结算过程中应用被 kill"和"发奖接口超时但实际已成功"这两种情况。这两种场景在本地单线程测试里永远发现不了,但它们是线上重复发奖最常见的来源。