ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

赛事结算防偷鸡:幂等、Redis原子计数与状态机实践

赛事结算防偷鸡:幂等、Redis原子计数与状态机实践 相信不少玩竞技游戏的朋友都遇到过这种场面你这边一路顺风打出7杀对面眼看就要崩盘这局稳了1000奖励几乎已经是囊中之物。结果对面一波gank突然提速直接把节奏打乱最后结算时反倒被“偷鸡”翻盘。游戏里的胜负可以下一局再打但把同样的问题搬到后端工程里尤其是赛事平台、积分活动、奖金结算这类系统就没有那么轻松了。“杀掉7个人就能拿到1000奖励”这种玩法本质上是实时对局事件统计加奖励结算系统。所谓“被偷鸡”在技术层面往往对应三种问题击杀计数被重复请求刷错、结算状态被并发覆盖、以及缺少幂等保护导致同一场次被重复结算。本文就用一个可运行的锦标赛结算系统 Demo把事件幂等、Redis 原子计数、结算状态机这几个核心设计拆开讲清楚并附上完整代码和验证命令。1. 背景与核心概念1.1 从“7杀被偷鸡”说起在竞技赛事中“打出7杀”是一个典型的对局事件。玩家每击杀一次系统需要记录一条击杀事件并把这个事件累加到玩家本场比赛的击杀数上。当比赛结束时系统再根据击杀排行决定胜者并发放对应的奖励。可以看到整个过程分为两个阶段比赛进行中高频写入击杀事件实时累计计数。比赛结束后一次性结算结果发放积分或奖金。如果这两个阶段没有做好防护就会出现标题描述的情况明明已经拿到7杀眼看着1000奖励到手结果在结算阶段被别人“偷鸡”。这里的“偷鸡”不只是游戏里的策略突袭也包括后端系统里的并发问题例如同一个击杀事件被重复提交计数被多算。多个人同时触发结算后提交的请求把先提交的结算结果覆盖。请求绕过状态校验对已结算的比赛再次结算。这些问题一旦发生在真实奖金场景中就不是“下局再打”能解决的而是直接关系到资金安全和平台公信力。1.2 赛事结算系统的典型业务场景这类系统并不少见常见的落地场景包括电竞赛事平台的实时击杀榜、伤害榜。游戏内限时锦标赛根据对局表现发放积分。直播平台的互动挑战主播和观众共同完成目标后瓜分奖励。杯赛活动结束后按排名发放奖金或实物奖励。这些场景的共同特点是事件量高、结果敏感、需要事后审计。尤其是涉及到奖金、优惠券、积分这类资产时哪怕只多算一条也可能被刷子利用造成资损。因此赛事结算系统的设计目标可以总结为四个词准确、幂等、可追溯、防并发。1.3 三个核心概念幂等、原子性、状态机在开始写代码之前先明确三个基础概念后面会反复用到。幂等性同一个操作无论执行多少次结果都和只执行一次相同。在击杀事件上报中如果同一事件被发送两次系统必须只计数一次。常见做法是给每个事件生成唯一 ID并在数据库中建立唯一索引。原子性一组操作要么全部成功要么全部失败不能被拆开执行。在计数场景中读取当前值、加一、写回这个流程不是原子的并发时会出现覆盖。Redis 的incrementScore由服务端单线程执行天然具备原子性。状态机一个对象只能从合法状态转移到合法状态。比赛结算的典型状态是进行中 - 已结算 进行中 - 已关停不允许“已结算 - 已结算”这种非法转移。实现方式是在更新 SQL 中带上当前状态条件影响行数为 0 就说明状态已经发生改变。2. 环境准备与版本说明2.1 环境清单本文示例以 Java Spring Boot Redis MySQL 为例适合绝大多数后端项目参考。环境版本如下软件版本建议说明JDK1.8示例代码使用 Java 8 语法Maven3.6项目构建与管理依赖Spring Boot2.7.x示例使用 2.7.18MySQL5.7 / 8.0事件表和结算表存储Redis6.x实时计数与分布式锁IDE / 命令行IDEA curl编写代码和验证接口如果你的项目已经使用了 Spring Boot 3.x注意javax包名调整为jakartaRedis 配置方式也有细微差异。本文重点演示设计思路版本需要根据你的项目实际情况调整。2.2 创建项目结构与依赖先创建一个 Maven 项目目录规划如下tournament-champion-demo ├── pom.xml └── src └── main ├── java │ └── com/example/tournament │ ├── TournamentApplication.java │ ├── common │ │ └── ResponseResult.java │ ├── controller │ │ └── TournamentController.java │ ├── dto │ │ └── KillEventDTO.java │ └── service │ ├── KillEventService.java │ └── SettlementService.java └── resources ├── application.yml └── schema.sqlpom.xml核心依赖如下?xml version1.0 encodingUTF-8? project xmlnshttp://maven.apache.org/POM/4.0.0 xmlns:xsihttp://www.w3.org/2001/XMLSchema-instance xsi:schemaLocationhttp://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd modelVersion4.0.0/modelVersion parent groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-parent/artifactId version2.7.18/version relativePath/ /parent groupIdcom.example/groupId artifactIdtournament-champion-demo/artifactId version1.0.0/version properties java.version1.8/java.version /properties dependencies dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-data-redis/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-jdbc/artifactId /dependency dependency groupIdmysql/groupId artifactIdmysql-connector-java/artifactId scoperuntime/scope /dependency dependency groupIdorg.projectlombok/groupId artifactIdlombok/artifactId optionaltrue/optional /dependency /dependencies /project这里用了 Spring Data Redis 和 JDBC不引入重型的 ORM 框架方便聚焦核心逻辑。启动类直接创建即可package com.example.tournament; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; SpringBootApplication public class TournamentApplication { public static void main(String[] args) { SpringApplication.run(TournamentApplication.class, args); } }3. 关键设计先搞懂三个技术点在写完整 Demo 之前先把三个关键设计讲清楚。理解了它们再看代码就不会乱。3.1 击杀事件的幂等设计客户端上报击杀事件时并不可靠。可能因为网络超时重试也可能因为消息队列重复投递。如果服务端不做幂等就会出现“一个击杀被算了两次”的情况。解决办法是约定一个全局唯一的事件 ID。比如{ matchId: M001, playerId: P001, eventId: E1001, killSeq: 7 }其中eventId必须保证在整场比赛内唯一可以由客户端生成也可以由服务端统一颁发。服务端写入事件表时依赖数据库唯一索引来拦截重复插入。当出现DuplicateKeyException时直接忽略即可。这个设计的核心原则是数据库唯一约束是最后一道防线不能只靠代码里的if判断。分布式环境下多个实例同时处理同一个事件时纯代码判断无法避免竞态。3.2 Redis 原子计数为什么不能先读后写有很多开发者第一次实现计数器时会写出这样的逻辑int count getFromRedis(playerId); count; saveToRedis(playerId, count);这段逻辑在单线程下没有问题但在并发环境下会产生丢失更新。假设两个请求同时读到count 6然后各自加一写回最终结果可能是7而不是8。这就会导致标题里的场景明明打了7杀系统却统计成6杀。正确做法是使用 Redis 自带的原子自增命令。Spring Data Redis 提供了incrementScore方法可以直接对 ZSet 中某个成员的分数做自增。Redis 是单线程执行命令这个操作天然具备原子性不需要额外加锁。为了后续排名方便这里用 ZSet 结构存储keytournament:kill:count:{matchId}memberplayerIdscore击杀数incrementScore实际上对应 Redis 命令ZINCRBY它会对指定成员的分数进行原子增加同时更新排序位置非常适合击杀榜这类场景。3.3 结算状态机与“偷鸡”拦截“偷鸡”最常见的技术形态是并发结算。比如比赛一结束两个管理端请求同时触发结算后一个请求读到旧的结算状态然后覆盖前一个请求写入的结果。要拦截这个问题可以使用状态机 条件更新UPDATE match_settlement SET status 1, settle_version settle_version 1, settle_time NOW() WHERE match_id ? AND status 0 AND settle_version ?这条 SQL 的含义是只有当比赛仍然处于“进行中”并且版本号与本次读取的一致时才允许把状态改为“已结算”。如果两个线程同时执行只有一个线程的更新影响行数为 1另一个线程影响行数为 0后者就知道自己“偷鸡”失败了。在代码层面还可以再加一层 Redis 分布式锁。锁是第一层拦截数据库状态机是第二层保证。即使锁因为意外情况失效数据库的 CAS 更新仍然能把并发请求挡在门外。4. 完整实战锦标赛结算系统 Demo下面进入正题搭建一个可运行的 Demo。核心流程是上报击杀事件 - Redis 累计击杀数 - 结算时读取排行 - 写入结算结果。4.1 数据库表设计在src/main/resources/schema.sql中写入以下三张表-- 击杀事件表每一条击杀记录一行 CREATE TABLE IF NOT EXISTS kill_event ( id BIGINT AUTO_INCREMENT PRIMARY KEY, match_id VARCHAR(32) NOT NULL COMMENT 比赛编号, player_id VARCHAR(32) NOT NULL COMMENT 玩家编号, event_id VARCHAR(64) NOT NULL COMMENT 全局唯一事件号, kill_seq INT NOT NULL DEFAULT 0 COMMENT 击杀序号, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT 事件时间, UNIQUE KEY uk_event_id (event_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT击杀事件表; -- 结算表记录比赛结算结果 CREATE TABLE IF NOT EXISTS match_settlement ( id BIGINT AUTO_INCREMENT PRIMARY KEY, match_id VARCHAR(32) NOT NULL COMMENT 比赛编号, winner_id VARCHAR(32) NULL COMMENT 胜者编号, settle_version INT NOT NULL DEFAULT 0 COMMENT 乐观锁版本, status TINYINT NOT NULL DEFAULT 0 COMMENT 0-进行中 1-已结算 2-已关停, settle_time DATETIME NULL COMMENT 结算时间, UNIQUE KEY uk_match_id (match_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT比赛结算表; -- 审计日志表记录每次结算请求便于事后追溯 CREATE TABLE IF NOT EXISTS settle_audit_log ( id BIGINT AUTO_INCREMENT PRIMARY KEY, match_id VARCHAR(32) NOT NULL COMMENT 比赛编号, settle_version INT NOT NULL DEFAULT 0 COMMENT 结算版本, req_no VARCHAR(64) NOT NULL COMMENT 请求流水号, description VARCHAR(255) NULL COMMENT 描述, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT 记录时间, UNIQUE KEY uk_req_no (req_no) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT结算审计日志表;三张表的职责很清晰kill_event记录事件数据唯一索引负责幂等。match_settlement记录比赛状态和结算结果settle_version用于乐观锁。settle_audit_log记录结算请求流水属于审计机制的一部分。4.2 编写基础配置在src/main/resources/application.yml中配置数据源和 Redisserver: port: 8080 spring: datasource: url: jdbc:mysql://localhost:3306/tournament_demo?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai username: root password: root driver-class-name: com.mysql.cj.jdbc.Driver redis: host: localhost port: 6379 database: 0 logging: level: com.example.tournament: debug这里的数据库密码需要根据本地环境修改。为了方便演示启动前先执行schema.sql并往match_settlement中插入一条比赛数据INSERT INTO match_settlement (match_id, winner_id, settle_version, status, settle_time) VALUES (M001, NULL, 0, 0, NULL);4.3 实现击杀事件上报先定义一个接收请求的数据对象package com.example.tournament.dto; import lombok.Data; Data public class KillEventDTO { /** * 比赛编号 */ private String matchId; /** * 玩家编号 */ private String playerId; /** * 事件唯一编号同一事件重复上报应被忽略 */ private String eventId; /** * 击杀序号客户端上报生产环境建议由服务端生成 */ private Integer killSeq; }核心服务类KillEventServicepackage com.example.tournament.service; import com.example.tournament.dto.KillEventDTO; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.dao.DuplicateKeyException; import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.data.redis.core.ZSetOperations; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.stereotype.Service; import java.time.LocalDateTime; Slf4j Service RequiredArgsConstructor public class KillEventService { private final JdbcTemplate jdbcTemplate; private final StringRedisTemplate stringRedisTemplate; /** * 上报击杀事件 * * return true 表示本次事件成功计数false 表示重复事件已忽略 */ public boolean report(KillEventDTO event) { if (event.getMatchId() null || event.getPlayerId() null || event.getEventId() null) { throw new IllegalArgumentException(matchId、playerId、eventId 不能为空); } // 1. 先写入事件表利用 eventId 唯一索引做幂等 try { jdbcTemplate.update( INSERT INTO kill_event(match_id, player_id, event_id, kill_seq, create_time) VALUES(?,?,?,?,?), event.getMatchId(), event.getPlayerId(), event.getEventId(), event.getKillSeq() null ? 0 : event.getKillSeq(), LocalDateTime.now() ); } catch (DuplicateKeyException e) { log.info(重复事件 eventId{}直接忽略, event.getEventId()); return false; } // 2. 更新 Redis 计数ZSet 的 incrementScore 对应 ZINCRBY原子自增 String rankKey buildMatchKillKey(event.getMatchId()); ZSetOperationsString, String zSet stringRedisTemplate.opsForZSet(); zSet.incrementScore(rankKey, event.getPlayerId(), 1); log.info(事件处理成功 matchId{} playerId{} eventId{}, event.getMatchId(), event.getPlayerId(), event.getEventId()); return true; } /** * 根据比赛编号生成击杀排行榜 key */ public static String buildMatchKillKey(String matchId) { return tournament:kill:count: matchId; } }这里需要注意一个顺序问题先写数据库再更新 Redis。如果在 Redis 更新失败数据库事件表已经写入会造成数据不一致。生产环境建议引入本地消息表或者事务消息本文为了保持 Demo 简洁先把核心逻辑跑通一致性问题会在最佳实践部分讨论。4.4 实现结算与防“偷鸡”结算服务是防“偷鸡”的关键完整代码如下package com.example.tournament.service; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.time.Duration; import java.time.LocalDateTime; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Set; import java.util.UUID; Slf4j Service RequiredArgsConstructor public class SettlementService { private static final String LOCK_KEY_PREFIX tournament:lock:settle:; private final StringRedisTemplate stringRedisTemplate; private final JdbcTemplate jdbcTemplate; /** * 结算入口先获取分布式锁再执行真正的结算逻辑 */ public MapString, Object settle(String matchId) { String lockKey LOCK_KEY_PREFIX matchId; String lockValue UUID.randomUUID().toString().replace(-, ); Boolean locked stringRedisTemplate.opsForValue() .setIfAbsent(lockKey, lockValue, Duration.ofSeconds(10)); if (locked null || !locked) { MapString, Object result new HashMap(); result.put(code, 1); result.put(message, 系统繁忙另一笔结算正在处理中); return result; } try { return doSettle(matchId); } finally { // 释放锁时校验 value避免误删其他请求的锁 String current stringRedisTemplate.opsForValue().get(lockKey); if (lockValue.equals(current)) { stringRedisTemplate.delete(lockKey); } } } /** * 真正执行结算逻辑使用数据库乐观锁保证只结算一次 */ Transactional public MapString, Object doSettle(String matchId) { MapString, Object result new HashMap(); // 1. 查询当前版本号 ListInteger versions jdbcTemplate.query( SELECT settle_version FROM match_settlement WHERE match_id ?, (rs, rowNum) - rs.getInt(settle_version), matchId ); if (versions.isEmpty()) { result.put(code, 1); result.put(message, 比赛不存在); return result; } int currentVersion versions.get(0); // 2. 乐观锁状态机更新只有 status0 且版本号匹配时才能成功 int updated jdbcTemplate.update( UPDATE match_settlement SET status 1, settle_version settle_version 1, settle_time NOW() WHERE match_id ? AND status 0 AND settle_version ?, matchId, currentVersion ); if (updated 0) { result.put(code, 1); result.put(message, 该场次已结算或已关停禁止重复结算); return result; } // 3. 从 Redis 读取击杀排行取第一名作为胜者 String rankKey KillEventService.buildMatchKillKey(matchId); SetString players stringRedisTemplate.opsForZSet().reverseRange(rankKey, 0, 0); String winnerId (players null || players.isEmpty()) ? null : players.iterator().next(); // 4. 回写胜者 int resultUpdated jdbcTemplate.update( UPDATE match_settlement SET winner_id ? WHERE match_id ? AND status 1, winnerId, matchId ); if (resultUpdated ! 1) { throw new IllegalStateException(结算结果更新失败事务回滚); } // 5. 写入审计日志 String reqNo REQ- UUID.randomUUID().toString().replace(-, ).substring(0, 16); jdbcTemplate.update( INSERT INTO settle_audit_log(match_id, settle_version, req_no, description, create_time) VALUES(?,?,?,?,?), matchId, currentVersion 1, reqNo, 结算成功胜者 (winnerId null ? 无 : winnerId), LocalDateTime.now() ); result.put(code, 0); result.put(message, 结算成功); result.put(matchId, matchId); result.put(winner, winnerId null ? : winnerId); return result; } }这段代码有三个重点第一Redis 分布式锁用setIfAbsent加锁并设置 10 秒过期时间防止持有锁的线程崩溃后锁无法释放。第二数据库乐观锁通过status 0 AND settle_version ?条件判断保证只有一个请求能完成状态流转。第三审计日志记录了本次结算的版本号、请求流水号和描述信息后续如果出现问题可以通过req_no反查整个流程。4.5 实现排行查询接口为了方便验证结果再加一个查询击杀排行的接口。修改TournamentControllerpackage com.example.tournament.controller; import com.example.tournament.dto.KillEventDTO; import com.example.tournament.service.KillEventService; import com.example.tournament.service.SettlementService; import lombok.RequiredArgsConstructor; import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.data.redis.core.ZSetOperations.TypedTuple; import org.springframework.web.bind.annotation.*; import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Set; RestController RequestMapping(/api/v1) RequiredArgsConstructor public class TournamentController { private final KillEventService killEventService; private final SettlementService settlementService; private final StringRedisTemplate stringRedisTemplate; /** * 上报击杀事件 */ PostMapping(/kill/event) public MapString, Object reportKill(RequestBody KillEventDTO event) { boolean handled killEventService.report(event); MapString, Object result new HashMap(); result.put(code, handled ? 0 : 1); result.put(message, handled ? 计数成功 : 重复事件已忽略); return result; } /** * 触发结算 */ PostMapping(/settle/{matchId}) public MapString, Object settle(PathVariable(matchId) String matchId) { return settlementService.settle(matchId); } /** * 查询击杀排行 */ GetMapping(/rank/{matchId}) public MapString, Object rank(PathVariable(matchId) String matchId) { String key KillEventService.buildMatchKillKey(matchId); SetTypedTupleString tuples stringRedisTemplate.opsForZSet() .reverseRangeWithScores(key, 0, 9); ListMapString, Object list new ArrayList(); if (tuples ! null) { for (TypedTupleString tuple : tuples) { MapString, Object item new HashMap(); item.put(playerId, tuple.getValue()); item.put(kills, tuple.getScore().intValue()); list.add(item); } } MapString, Object result new HashMap(); result.put(code, 0); result.put(data, list); return result; } }reverseRangeWithScores表示按分数从高到低取出指定区间的成员这里取的是前 10 名。4.6 启动并验证效果启动之前确认 MySQL 中存在tournament_demo数据库并且已经执行过schema.sql和初始化 INSERT。然后启动项目mvn spring-boot:run打开另一个终端先用 curl 模拟连续击杀事件curl -X POST http://localhost:8080/api/v1/kill/event \ -H Content-Type: application/json \ -d {matchId:M001,playerId:P001,eventId:E1001,killSeq:1}预期返回{code:0,message:计数成功}连续上报 7 次不同eventId再查询排行curl http://localhost:8080/api/v1/rank/M001预期能看到P001的击杀数为 7。现在验证幂等把同一个eventIdE1001再上报一次curl -X POST http://localhost:8080/api/v1/kill/event \ -H Content-Type: application/json \ -d {matchId:M001,playerId:P001,eventId:E1001,killSeq:1}预期返回{code:1,message:重复事件已忽略}击杀数仍然为 7没有被多算。接着验证并发结算。同时发起多个结算请求curl -X POST http://localhost:8080/api/v1/settle/M001 curl -X POST http://localhost:8080/api/v1/settle/M001 curl -X POST http://localhost:8080/api/v1/settle/M001 wait预期只有一个请求返回“结算成功”其余返回“该场次已结算或已关停”。这样就成功拦截了“偷鸡”请求。5. 常见问题与排查思路在写这类系统时下面的问题出现频率很高。问题现象常见原因解决思路击杀数比实际少事件重复消费被忽略或 Redis 数据丢失检查唯一索引与消息重投机制开启 Redis AOF 持久化结算结果被后提交请求覆盖并发情况下没有做状态控制数据库 CAS 更新 Redis 分布式锁重复事件被多次计数缺少全局唯一事件号客户端生成eventId数据库加唯一索引Redis 中改了两个 key 后报错Redis 集群跨槽位操作限制使用 hash tag确保相关 key 落在同一槽位数据库事务和 Redis 操作不一致先更新 Redis 后写失败引入本地消息表或者改用事务消息这里重点解释两个高频问题。第一个是“击杀数比实际少”。如果事件先写 Redis 再写数据库数据库写入失败后 Redis 已经加了一计数就会多。如果先写数据库再更新 RedisRedis 更新失败后计数就会少。两种方案都有问题生产环境可以采用“事件先落库再投递消息队列异步更新 Redis”配合定时对账任务把不一致的数据纠正回来。第二个是“结算被覆盖”。单纯加 Redis 锁并不能完全防止问题。如果锁过期了或者业务处理超过了锁的过期时间第二个请求还是能进入结算逻辑。因此数据库的状态机更新是必须的它是所有并发控制手段中最后一道也是最可靠的一道防线。即使锁失效UPDATE ... WHERE status 0 AND settle_version ?也能保证只有一个请求成功。6. 最佳实践与工程建议6.1 幂等策略要放在最底层不要相信任何网络请求只到达一次。客户端会超时重试网关会重发消息队列也可能重投。在这个前提下事件幂等不能只靠代码判断必须依赖数据库唯一索引作为最终防线。每个事件都要有全局唯一的eventId并且这个 ID 的生成规则要提前设计好。简单场景可以使用 UUID高并发场景建议使用雪花算法或全局发号器。6.2 涉及资产的操作必须审计只要结算系统涉及到积分、奖金、优惠券就必须有审计日志。至少记录三类信息请求流水号用于串联一次完整请求。业务关键数据例如比赛编号、结算版本、胜者编号。操作时间和操作来源。当出现问题需要排查时没有审计日志的系统几乎等于“盲盒”。有了日志即使被“偷鸡”也能快速定位是哪一台机器、哪一个请求、哪一步操作导致的结果异常。6.3 注意 Redis 热点键和持久化在大型赛事中某一场热门比赛的计数器可能成为热点 Key。所有玩家都在往同一个 ZSet 上写单个 Redis 节点的压力会很大。常见方案是对 key 做分片例如按玩家 ID 哈希拆成多个子 key结算时再合并。但分片会带来合并成本需要结合实际流量评估。持久化也要重点配置。计数器如果使用的是 Redis 纯内存模式宕机可能丢失一部分数据。生产环境建议开启 AOF 持久化同时配合定时快照和数据库对账任务。6.4 结算服务应独立部署、独立配置奖励结算属于资金敏感操作不建议和普通业务接口放在同一个服务里混跑。隔离的意义在于即使普通业务流量突然暴涨也不会影响结算服务的稳定性。独立部署后可以针对性配置线程池、超时时间、重试策略和监控报警。结算接口还应该加上管理员权限校验只允许合法调用方触发。7. 总结与学习路线这篇文章从“锦标赛7杀被偷鸡”的场景出发拆解了赛事结算系统的三个核心设计事件幂等、Redis 原子计数、结算状态机。通过一个可运行的 Spring Boot Demo完整实现了击杀事件上报、击杀榜查询、并发结算防重复并且验证了重复事件和并发请求的拦截效果。如果你正准备设计一个比赛计数器或者奖励结算模块建议先把事件幂等和结算状态机这两层做扎实再考虑性能优化。多花时间去验证并发场景下的测试用例尤其是重复提交、并发结算、宕机恢复这些边界情况远比堆砌中间件更有价值。下一步可以继续学习消息队列的可靠投递和顺序保证。分布式事务方案例如本地消息表、Seata、事务消息。流式计算框架在实时对局分析中的应用。Redis 集群在热点数据场景下的分片设计。动手写一遍这套 Demo再用并发请求反复测试比只看文章理解得更深。如果遇到其他“偷鸡”场景也欢迎结合这篇文章里的思路继续扩展。
返回列表