news 2026/9/30 2:58:40

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

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
赛事结算防偷鸡:幂等、Redis原子计数与状态机实践

相信不少玩竞技游戏的朋友都遇到过这种场面:你这边一路顺风,打出7杀,对面眼看就要崩盘,这局稳了,1000奖励几乎已经是囊中之物。结果对面一波gank突然提速,直接把节奏打乱,最后结算时反倒被“偷鸡”翻盘。游戏里的胜负可以下一局再打,但把同样的问题搬到后端工程里,尤其是赛事平台、积分活动、奖金结算这类系统,就没有那么轻松了。

“杀掉7个人就能拿到1000奖励”这种玩法,本质上是实时对局事件统计加奖励结算系统。所谓“被偷鸡”,在技术层面往往对应三种问题:击杀计数被重复请求刷错、结算状态被并发覆盖、以及缺少幂等保护导致同一场次被重复结算。本文就用一个可运行的锦标赛结算系统 Demo,把事件幂等、Redis 原子计数、结算状态机这几个核心设计拆开讲清楚,并附上完整代码和验证命令。

1. 背景与核心概念

1.1 从“7杀被偷鸡”说起

在竞技赛事中,“打出7杀”是一个典型的对局事件。玩家每击杀一次,系统需要记录一条击杀事件,并把这个事件累加到玩家本场比赛的击杀数上。

当比赛结束时,系统再根据击杀排行决定胜者,并发放对应的奖励。可以看到,整个过程分为两个阶段:

  1. 比赛进行中:高频写入击杀事件,实时累计计数。
  2. 比赛结束后:一次性结算结果,发放积分或奖金。

如果这两个阶段没有做好防护,就会出现标题描述的情况:明明已经拿到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.18
MySQL5.7+ / 8.0事件表和结算表存储
Redis6.x+实时计数与分布式锁
IDE / 命令行IDEA + curl编写代码和验证接口

如果你的项目已经使用了 Spring Boot 3.x,注意javax包名调整为jakarta,Redis 配置方式也有细微差异。本文重点演示设计思路,版本需要根据你的项目实际情况调整。

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.sql

pom.xml核心依赖如下:

<?xml version="1.0" encoding="UTF-8"?> <project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd"> <modelVersion>4.0.0</modelVersion> <parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>2.7.18</version> <relativePath/> </parent> <groupId>com.example</groupId> <artifactId>tournament-champion-demo</artifactId> <version>1.0.0</version> <properties> <java.version>1.8</java.version> </properties> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-redis</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-jdbc</artifactId> </dependency> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <scope>runtime</scope> </dependency> <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <optional>true</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 结构存储:

  • key:tournament:kill:count:{matchId}
  • member:playerId
  • score:击杀数

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) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 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) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 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) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='结算审计日志表';

三张表的职责很清晰:

  • kill_event记录事件数据,唯一索引负责幂等。
  • match_settlement记录比赛状态和结算结果,settle_version用于乐观锁。
  • settle_audit_log记录结算请求流水,属于审计机制的一部分。

4.2 编写基础配置

在src/main/resources/application.yml中配置数据源和 Redis:

server: port: 8080 spring: datasource: url: jdbc:mysql://localhost:3306/tournament_demo?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/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; }

核心服务类KillEventService:

package 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()); ZSetOperations<String, 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 Map<String, 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) { Map<String, 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 Map<String, Object> doSettle(String matchId) { Map<String, Object> result = new HashMap<>(); // 1. 查询当前版本号 List<Integer> 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. 乐观锁状态机更新:只有 status=0 且版本号匹配时才能成功 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); Set<String> 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 实现排行查询接口

为了方便验证结果,再加一个查询击杀排行的接口。修改TournamentController:

package 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 Map<String, Object> reportKill(@RequestBody KillEventDTO event) { boolean handled = killEventService.report(event); Map<String, Object> result = new HashMap<>(); result.put("code", handled ? 0 : 1); result.put("message", handled ? "计数成功" : "重复事件已忽略"); return result; } /** * 触发结算 */ @PostMapping("/settle/{matchId}") public Map<String, Object> settle(@PathVariable("matchId") String matchId) { return settlementService.settle(matchId); } /** * 查询击杀排行 */ @GetMapping("/rank/{matchId}") public Map<String, Object> rank(@PathVariable("matchId") String matchId) { String key = KillEventService.buildMatchKillKey(matchId); Set<TypedTuple<String>> tuples = stringRedisTemplate.opsForZSet() .reverseRangeWithScores(key, 0, 9); List<Map<String, Object>> list = new ArrayList<>(); if (tuples != null) { for (TypedTuple<String> tuple : tuples) { Map<String, Object> item = new HashMap<>(); item.put("playerId", tuple.getValue()); item.put("kills", tuple.getScore().intValue()); list.add(item); } } Map<String, 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。

现在验证幂等:把同一个eventId=E1001再上报一次:

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 已经加了一,计数就会多。如果先写数据库再更新 Redis,Redis 更新失败后计数就会少。两种方案都有问题,生产环境可以采用“事件先落库,再投递消息队列异步更新 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,再用并发请求反复测试,比只看文章理解得更深。如果遇到其他“偷鸡”场景,也欢迎结合这篇文章里的思路继续扩展。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/30 2:58:09

Cisco IOS SIP语音网关配置与排障:从dial-peer到IMS对接

简介&#xff1a;面向网络工程师与VoIP运维人员的技术文档&#xff0c;介绍采用SIP协议的Cisco IOS语音网关在企业IP通信中的角色与部署要点。内容涵盖PSTN与IP网络之间的信令转换、SIP中继、PBX互联&#xff0c;以及QoS、呼叫准入控制、会话边界控制器、应急故障切换等关键特性…

作者头像 李华
网站建设 2026/9/30 2:57:09

Selenium自动化测试报告:unittest与pytest实战

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/30 2:56:58

小型校园网组建全流程:VLAN划分、三层交换与NAT出口配置

简介&#xff1a;这份东北大学计算机网络实验报告围绕“小型校园网的设计与组建”展开&#xff0c;适合计算机网络课程学生、实验报告参考者及刚接触路由交换配置的入门者。实验模拟总校与分校组网&#xff1a;总校一个局域网20台PC&#xff0c;分校通过VLAN划分为两个各10台PC…

作者头像 李华
网站建设 2026/9/30 2:56:40

深信服超融合HCI部署避坑指南:硬件、网络与存储关键配置

简介&#xff1a;本资源是深信服超融合HCI&#xff08;Hyper-Converged Infrastructure&#xff09;6.7.0R3版本的官方用户及部署手册&#xff0c;面向IT基础设施工程师、虚拟化运维人员与超融合系统实施技术人员&#xff0c;聚焦超融合架构落地中的核心问题&#xff1a;从底层…

作者头像 李华
网站建设 2026/9/30 2:55:49

OpenCV车道线实时检测:从Canny到霍夫变换的完整实现

简介&#xff1a;介绍一个面向计算机视觉学习者与自动驾驶初学者的OpenCV车道实时检测实现示例。资源以PDF文档形式给出完整工程思路&#xff0c;从将视频各帧读取为图片、创建多边形掩码并应用、执行图像阈值化&#xff0c;到通过霍夫线变换检测车道线段、逐帧绘制标记并合并写…

作者头像 李华
网站建设 2026/9/30 2:55:33

SAP PS收入类项目结果分析与结算全流程详解

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华