资讯动态

多人掉落分配服务端设计:状态机、并发幂等与审计

发布时间:2026/10/27 19:07:00 来源:尧图企业网站定制
组队刷深渊这件事只要队伍超过两个人分配环节就一定会出问题。有人喊戒指归我其他你们拿有人喊我只要武器剩下分喊完之后掉落一出来谁先点到算谁的线下约定在服务端没有任何约束力。等到真出了争议物品聊天记录翻半天谁也说不清当时到底约定了什么。这个场景看起来只是游戏里的社交摩擦但对做服务端的人而言它其实是一个典型的多方并发争抢同一份稀缺资源问题资源数量有限、参与方并行操作、规则由人临时约定、结果必须可追溯。只要把这四条抽象出来你会发现它和秒杀库存、优惠券发放、工单抢单、会议室预订在结构上是同一个东西。本文不讨论某个具体游戏的机制细节而是以多人组队掉落分配作为业务背景完整讲一遍怎样把一句口头约定变成服务端可执行的规则状态机怎么设计、并发怎么挡住、幂等键怎么定、审计怎么做、出了问题从哪查。文中给出一套可以本地跑起来的 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 BootWeb 与依赖注入提供 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?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/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 statusSETTLING WHERE session_no? AND statusCLAIMING。通过影响行数判断是否切换成功影响 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 SetString 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 ) ListMapString, 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; } ListMapString, 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 MapString, Object intent(RequestBody MapString, 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 MapString, Object settle(RequestBody MapString, 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;成功的标志是会话状态为CLOSEDallocated_quantity等于实际发的数量loot_grant里的记录数与之相等且grant_no无重复。6.2 并发表态压测真正要验证的是并发下的幂等性。用一个简单的 bash 脚本让 20 个请求同时提交同一个玩家的表态# 文件路径scripts/concurrent_intent.sh #!/usr/bin/env bash set -euo pipefail SESSION_NOS202401 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 会话重新触发或置为 CANCELEDRedis 锁提前失效结算耗时超过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和发奖接口超时但实际已成功这两种情况。这两种场景在本地单线程测试里永远发现不了但它们是线上重复发奖最常见的来源。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑