公司动态
从杀兽夺寿系统看游戏奖励结算的幂等与并发设计
看到“只剩半年性命绝境觉醒杀兽夺寿系统”这种小说标题很多后端工程师的第一反应可能是这跟技术有什么关系但如果把它当成一份产品需求来拆会发现里面藏着一整套游戏后台系统设计题任务怎么接取、击杀怎么上报、寿命点怎么累计、称号怎么晋升、奖励怎么防止重复发放。这篇博客不讨论小说剧情只讨论“杀兽夺寿系统”背后真正值得写代码的部分——一个具备幂等、防并发、可追溯的奖励结算系统。这类功能的难点不在 CRUD而在结算的一致性。玩家击杀妖兽后上报一次击杀事件服务端要做校验、累计、判定、发奖这个过程一旦遇到网络重试、并发误触、消息重复消费很容易出现“一只妖兽给两次寿命”的事故。更麻烦的是如果设计阶段没把流水和状态机理清楚后面排错会非常痛苦。本文会从零搭建一个最小可运行示例使用 Spring Boot JPA H2把任务接取、击杀上报、寿命结算、称号晋升这条链路完整跑通并重点讲解幂等、乐观锁和账目流水设计。读完这篇文章你能获得三样东西一是能直接运行的 Java 后端代码二是对任务/奖励结算系统的建模思路三是上线前最容易被忽视的坑和排查方法。1. 这篇文章真正要解决的问题小说里的“杀兽夺寿系统”本质是一个典型的游戏业务系统玩家接任务打怪完成任务后获得奖励。现实中的 MMO、卡牌、休闲游戏都有类似模块。很多同学刚接触这类需求时觉得就是“一张任务表 一张玩家属性表完成任务 update 一下”但真正做起来会发现几个高频问题第一奖励重复发放。客户端按钮双击、网络超时后重试、消息队列重复投递都会导致同一个事件被服务端处理多次。如果没有幂等设计玩家就能靠重复请求刷寿命点。第二并发下余额错乱。如果玩家同时击杀多只妖兽多个请求并发修改同一个“寿命余额”字段可能出现丢失更新原来应该加 300 年寿命实际只加了 100 年。第三状态管理混乱。任务从接取、进行中、完成、结算每一步都有明确状态。如果不做状态机限制可能会出现“还没接任务就能领奖励”“任务已结算还能继续上报进度”这类逻辑漏洞。第四奖励不可追溯。玩家说自己少了寿命运营需要能查到每一笔寿命来源。如果没有流水表只有一张余额表这个问题几乎无法回答。从小说设定映射到技术设计大概是这样的小说设定技术概念要解决的问题觉醒系统任务系统任务定义、接取、进度、完成击杀焚天豹、暗天魔龙击杀事件上报上报数据校验、进度累计累积数千载寿命玩家寿命账本余额与流水分离、并发安全升官成为元帅称号/等级晋升达到阈值后自动晋升所以这篇文章真正要解决的是用工程化的方式实现一个可扩展、不易出错的任务奖励结算系统而不是把精力花在“手写一堆 if 判断修改玩家字段”上。2. 基础概念与核心原理2.1 任务系统任务系统由任务定义和任务实例两部分组成。任务定义描述“杀 10 只焚天豹、奖励 100 年寿命”任务实例描述“玩家张三当前任务进度是多少、处于什么状态”。实际项目中任务定义通常由配置后台维护任务实例则是每次接取任务时复制一份动态数据。本文为了最小化 demo会把任务定义直接简化为数据库里的任务模板表并在代码中写死奖励规则。2.2 流水与余额分离这是账务系统的经典设计同样适用游戏奖励。玩家身上只存一个“总寿命”余额但每次余额变动都要写一条流水记录。查余额走玩家表查记录走流水表。这样一旦余额异常可以通过流水重放、对账、回溯。小说里“累积数千载寿命”就是一个余额而“杀了焚天豹活了 100 年杀了暗天魔龙活了 1000 年”就是流水。2.3 幂等幂等是指同一个操作执行多次的结果和执行一次相同。网络重试、消息重复消费、用户快速点击都可能让同一个请求到达服务端多次。处理方式通常有两种一是在入口处用唯一请求 ID 去重二是在数据库层面对业务唯一键加唯一索引利用数据库约束兜底。本文的击杀上报接口会接收一个客户端生成的 requestId同一 requestId 只允许结算一次。2.4 状态机任务实例不能随意变更状态必须按照规定路径流转INIT已创建 - RUNNING进行中 - COMPLETED已完成 - SETTLED已结算接取任务时进入 RUNNING击杀数达到目标时进入 COMPLETED事务内写入流水并更新余额后进入 SETTLED。状态机的好处是逻辑清晰后续加需求时不容易把状态改乱。3. 环境准备与前置条件本文使用 Java 后端常见技术栈具体版本以你本地环境为准JDK 17 或更高版本Maven 3.6Spring Boot 3.xSpring Data JPAH2 数据库内存模式方便本地演示一个 IDE 或命令行终端Spring Boot 3.x 是目前主流版本JDK 17 是它的基础要求。如果你本地是 JDK 8需要先切换版本或者使用 Spring Boot 2.x但代码中的 Jakarta 包名会不同建议直接使用 JDK 17。创建一个 Maven 项目后在pom.xml中加入以下关键依赖dependencies dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-data-jpa/artifactId /dependency dependency groupIdcom.h2database/groupId artifactIdh2/artifactId scoperuntime/scope /dependency dependency groupIdorg.projectlombok/groupId artifactIdlombok/artifactId optionaltrue/optional /dependency /dependencies版本号不写死是因为不同的 Spring Boot 父工程版本会统一管理这些依赖。建议使用 Spring Initializr 生成项目模板再手动调整代码。4. 核心流程拆解4.1 玩家创建玩家是寿命余额的持有者。每个玩家有一条生命周期内唯一对应的寿命账户记录。创建玩家时服务端会同时初始化一个余额为 0 的账户。设计时可以把玩家信息和账户信息分成两张表也可以合并成一张表本文为了演示账户和流水的关系单独建模。4.2 任务接取玩家接取任务后系统创建一条任务实例记录状态为 RUNNING初始击杀数为 0。接取时要校验玩家是否存在、任务是否允许接取、玩家是否已经同时拥有同类任务。这些校验看起来简单但漏掉任意一个都会导致后续结算数据异常。这里容易踩坑的点是“重复接取”。如果玩家快速点击两次接取按钮服务端可能创建两条任务实例。解决思路是给玩家和任务模板的组合加唯一约束或者在业务代码中加分布式锁。本文用数据库唯一索引来兜底。4.3 击杀上报玩家击杀妖兽后客户端把事件发给服务端。上报内容至少包含玩家 ID、任务 ID、妖兽 ID、客户端生成的请求 ID。服务端要做四件事检查请求 ID 是否已经处理过如果处理过直接返回原结果加载任务实例校验状态是否为 RUNNING累加击杀数判断是否达到目标达到则触发结算。如果状态不是 RUNNING 却收到击杀上报应该直接拒绝不能默默更新数据。否则可能出现任务已结算后后续慢网络请求又推进一次进度的问题。4.4 奖励结算结算发生在“最后一次击杀使击杀数达到目标”时。结算动作在一个数据库事务内完成更新任务实例状态为 COMPLETED再置为 SETTLED计算本次应获得的寿命奖励写入一条寿命流水记录原子更新玩家寿命余额。这里最关键的是“先写流水再更新余额”的顺序以及流水表请求 ID 的唯一索引。流水一旦落库且唯一约束生效后续重复请求就不会再写入第二条余额也不会被重复更新。4.5 称号晋升小说里主角累积寿命到一定量从士兵升到将军再升到元帅。技术上就是达到阈值后把玩家的称号字段升级。可以先查当前余额再对照称号配置表返回当前最高称号。晋升动作可以在每次余额更新后执行也可以用定时任务扫描本文采用实时查询并更新称号字段。5. 完整示例与代码实现下面进入可运行代码部分。项目结构如下src/main/java/com/example/lifesystem/ ├── LifeSystemApplication.java ├── controller/PlayerController.java ├── controller/TaskController.java ├── entity/PlayerLifeAccount.java ├── entity/TaskRecord.java ├── entity/LifeLedgerEntry.java ├── repository/PlayerLifeAccountRepository.java ├── repository/TaskRecordRepository.java ├── repository/LifeLedgerEntryRepository.java ├── service/PlayerService.java ├── service/TaskLifeService.java ├── dto/Request.java └── dto/Response.java这里展示完整代码文件路径会标注在注释中。5.1 启动类// src/main/java/com/example/lifesystem/LifeSystemApplication.java package com.example.lifesystem; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; SpringBootApplication public class LifeSystemApplication { public static void main(String[] args) { SpringApplication.run(LifeSystemApplication.class, args); } }5.2 实体类首先是玩家寿命账户。为了让并发更新安全实体中加入version字段由 JPA 乐观锁处理。// src/main/java/com/example/lifesystem/entity/PlayerLifeAccount.java package com.example.lifesystem.entity; import jakarta.persistence.*; Entity Table(name player_life_account, uniqueConstraints UniqueConstraint(name uk_player_id, columnNames playerId)) public class PlayerLifeAccount { Id GeneratedValue(strategy GenerationType.IDENTITY) private Long id; Column(nullable false) private Long playerId; Column(nullable false) private Long totalLife; Column(nullable false) private String title; Version private Long version; protected PlayerLifeAccount() {} public PlayerLifeAccount(Long playerId) { this.playerId playerId; this.totalLife 0L; this.title 普通人; } public Long getPlayerId() { return playerId; } public Long getTotalLife() { return totalLife; } public String getTitle() { return title; } public void addLife(Long life) { this.totalLife life; } public void upgradeTitle(String newTitle) { this.title newTitle; } }Version是 JPA 乐观锁的关键。当两个事务同时更新同一条玩家余额时后提交的事务会因为版本号不一致抛出OptimisticLockException从而避免丢失更新。在这里version 字段也保证了余额的一致性。接下来是任务实例实体。// src/main/java/com/example/lifesystem/entity/TaskRecord.java package com.example.lifesystem.entity; import jakarta.persistence.*; Entity Table(name task_record, uniqueConstraints UniqueConstraint(name uk_player_task, columnNames {playerId, taskId})) public class TaskRecord { public enum Status { INIT, RUNNING, COMPLETED, SETTLED } Id GeneratedValue(strategy GenerationType.IDENTITY) private Long id; Column(nullable false) private Long playerId; Column(nullable false) private Long taskId; Column(nullable false) private String taskName; Column(nullable false) private Long targetKillCount; Column(nullable false) private Long currentKillCount; Enumerated(EnumType.STRING) Column(nullable false, length 32) private Status status; Version private Long version; protected TaskRecord() {} public TaskRecord(Long playerId, Long taskId, String taskName, Long targetKillCount) { this.playerId playerId; this.taskId taskId; this.taskName taskName; this.targetKillCount targetKillCount; this.currentKillCount 0L; this.status Status.RUNNING; } public Long getId() { return id; } public Long getPlayerId() { return playerId; } public Long getTaskId() { return taskId; } public Long getTargetKillCount() { return targetKillCount; } public Long getCurrentKillCount() { return currentKillCount; } public Status getStatus() { return status; } public boolean isRunning() { return status Status.RUNNING; } public void addKillCount(Long count) { this.currentKillCount count; } public void complete() { this.status Status.COMPLETED; } public void settle() { this.status Status.SETTLED; } }任务实例上同样有Version字段防止同一个任务被两个并发请求重复结算。状态流转只在 Service 层显式调用complete()和settle()外部不能直接改状态。然后是流水实体。// src/main/java/com/example/lifesystem/entity/LifeLedgerEntry.java package com.example.lifesystem.entity; import jakarta.persistence.*; import java.time.LocalDateTime; Entity Table(name life_ledger_entry, uniqueConstraints UniqueConstraint(name uk_request_id, columnNames requestId)) public class LifeLedgerEntry { Id GeneratedValue(strategy GenerationType.IDENTITY) private Long id; Column(nullable false, unique true) private String requestId; Column(nullable false) private Long playerId; Column(nullable false) private Long taskRecordId; Column(nullable false) private Long changeAmount; Column(nullable false, length 64) private String reason; Column(nullable false) private LocalDateTime createTime; protected LifeLedgerEntry() {} public LifeLedgerEntry(String requestId, Long playerId, Long taskRecordId, Long changeAmount, String reason) { this.requestId requestId; this.playerId playerId; this.taskRecordId taskRecordId; this.changeAmount changeAmount; this.reason reason; this.createTime LocalDateTime.now(); } public String getRequestId() { return requestId; } }流水表的requestId唯一约束是幂等的最终防线。即使业务代码有 bug数据库也会拒绝重复的请求 ID 写入。5.3 数据访问接口// src/main/java/com/example/lifesystem/repository/PlayerLifeAccountRepository.java package com.example.lifesystem.repository; import com.example.lifesystem.entity.PlayerLifeAccount; import org.springframework.data.jpa.repository.JpaRepository; import java.util.Optional; public interface PlayerLifeAccountRepository extends JpaRepositoryPlayerLifeAccount, Long { OptionalPlayerLifeAccount findByPlayerId(Long playerId); }// src/main/java/com/example/lifesystem/repository/TaskRecordRepository.java package com.example.lifesystem.repository; import com.example.lifesystem.entity.TaskRecord; import org.springframework.data.jpa.repository.JpaRepository; import java.util.Optional; public interface TaskRecordRepository extends JpaRepositoryTaskRecord, Long { OptionalTaskRecord findByPlayerIdAndTaskId(Long playerId, Long taskId); }// src/main/java/com/example/lifesystem/repository/LifeLedgerEntryRepository.java package com.example.lifesystem.repository; import com.example.lifesystem.entity.LifeLedgerEntry; import org.springframework.data.jpa.repository.JpaRepository; import java.util.Optional; public interface LifeLedgerEntryRepository extends JpaRepositoryLifeLedgerEntry, Long { OptionalLifeLedgerEntry findByRequestId(String requestId); }5.4 玩家服务// src/main/java/com/example/lifesystem/service/PlayerService.java package com.example.lifesystem.service; import com.example.lifesystem.entity.PlayerLifeAccount; import com.example.lifesystem.repository.PlayerLifeAccountRepository; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.util.List; Service public class PlayerService { private final PlayerLifeAccountRepository accountRepository; public PlayerService(PlayerLifeAccountRepository accountRepository) { this.accountRepository accountRepository; } Transactional public PlayerLifeAccount createPlayer(Long playerId) { if (accountRepository.findByPlayerId(playerId).isPresent()) { throw new IllegalArgumentException(玩家已存在); } return accountRepository.save(new PlayerLifeAccount(playerId)); } Transactional(readOnly true) public PlayerLifeAccount getPlayer(Long playerId) { return accountRepository.findByPlayerId(playerId) .orElseThrow(() - new IllegalArgumentException(玩家不存在)); } private static final ListString TITLES List.of(普通人, 百夫长, 将军, 元帅); // 根据寿命余额计算称号 public String calcTitleByLife(Long totalLife) { if (totalLife 5000) return TITLES.get(3); if (totalLife 1000) return TITLES.get(2); if (totalLife 100) return TITLES.get(1); return TITLES.get(0); } }这里把称号阈值定义成常量只是为了演示。真实项目会有一张称号配置表支持运营动态调整。5.5 核心任务与结算服务这是整个系统的核心重点看reportKill方法的设计。// src/main/java/com/example/lifesystem/service/TaskLifeService.java package com.example.lifesystem.service; import com.example.lifesystem.entity.LifeLedgerEntry; import com.example.lifesystem.entity.PlayerLifeAccount; import com.example.lifesystem.entity.TaskRecord; import com.example.lifesystem.repository.LifeLedgerEntryRepository; import com.example.lifesystem.repository.PlayerLifeAccountRepository; import com.example.lifesystem.repository.TaskRecordRepository; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.util.Map; Service public class TaskLifeService { private final TaskRecordRepository taskRecordRepository; private final PlayerLifeAccountRepository accountRepository; private final LifeLedgerEntryRepository ledgerEntryRepository; private final PlayerService playerService; public TaskLifeService(TaskRecordRepository taskRecordRepository, PlayerLifeAccountRepository accountRepository, LifeLedgerEntryRepository ledgerEntryRepository, PlayerService playerService) { this.taskRecordRepository taskRecordRepository; this.accountRepository accountRepository; this.ledgerEntryRepository ledgerEntryRepository; this.playerService playerService; } // 任务模板任务 ID - 任务名称目标击杀数 private static final MapLong, TaskTemplate TASK_TEMPLATES Map.of( 1L, new TaskTemplate(击杀焚天豹, 10L, 100L), 2L, new TaskTemplate(击杀暗天魔龙, 3L, 1000L) ); // 妖兽奖励妖兽 ID - 单次寿命奖励 private static final MapLong, Long BOSS_LIFE_REWARDS Map.of( 101L, 10L, 202L, 100L ); Transactional public TaskRecord acceptTask(Long playerId, Long taskId) { TaskTemplate template TASK_TEMPLATES.get(taskId); if (template null) { throw new IllegalArgumentException(任务不存在); } accountRepository.findByPlayerId(playerId) .orElseThrow(() - new IllegalArgumentException(玩家不存在)); taskRecordRepository.findByPlayerIdAndTaskId(playerId, taskId) .ifPresent(t - { throw new IllegalArgumentException(任务已接取); }); return taskRecordRepository.save(new TaskRecord( playerId, taskId, template.name, template.targetKillCount)); } Transactional public Object reportKill(Long playerId, Long taskId, Long bossId, String requestId) { if (requestId null || requestId.isBlank()) { throw new IllegalArgumentException(requestId 不能为空); } // 第一步幂等检查 if (ledgerEntryRepository.findByRequestId(requestId).isPresent()) { return buildSuccess(重复请求已忽略, true); } TaskRecord task taskRecordRepository.findByPlayerIdAndTaskId(playerId, taskId) .orElseThrow(() - new IllegalArgumentException(任务不存在或未接取)); if (!task.isRunning()) { throw new IllegalStateException(任务当前不可上报进度); } Long reward BOSS_LIFE_REWARDS.get(bossId); if (reward null) { throw new IllegalArgumentException(未知妖兽类型); } // 第二步推进任务击杀数 task.addKillCount(1L); boolean completed task.getCurrentKillCount() task.getTargetKillCount(); if (completed) { task.complete(); } taskRecordRepository.save(task); // 第三步任务完成则结算奖励 if (completed) { settleTask(task, playerId, requestId); } return buildSuccess(击杀上报成功, completed); } private void settleTask(TaskRecord task, Long playerId, String requestId) { PlayerLifeAccount account accountRepository.findByPlayerId(playerId) .orElseThrow(() - new IllegalArgumentException(玩家不存在)); // 计算奖励 long totalReward 0L; int taskId task.getTaskId().intValue(); if (taskId 1) { totalReward 100L; } else if (taskId 2) { totalReward 1000L; } // 先写流水 LifeLedgerEntry entry new LifeLedgerEntry( requestId, playerId, task.getId(), totalReward, 任务结算: task.getTaskName()); ledgerEntryRepository.save(entry); // 再更新余额 account.addLife(totalReward); account.upgradeTitle(playerService.calcTitleByLife(account.getTotalLife())); accountRepository.save(account); task.settle(); taskRecordRepository.save(task); } private Object buildSuccess(String msg, boolean completed) { return Map.of( message, msg, completed, completed ); } private static class TaskTemplate { String name; Long targetKillCount; Long lifeReward; TaskTemplate(String name, Long targetKillCount, Long lifeReward) { this.name name; this.targetKillCount targetKillCount; this.lifeReward lifeReward; } } }这里的reportKill方法有几个设计要点值得反复看幂等检查放在最前面如果流水表中已经有相同requestId直接返回不重复结算任务状态必须是RUNNING否则直接拒绝只有任务完成才进入settleTask结算结算顺序是“先流水再账户”一旦流水落库唯一索引就生效了。需要注意Transactional依赖 Spring AOP 代理。如果从同一个类的另一个方法内部调用reportKill事务和幂等都可能失效所以 Service 之间的调用尽量通过注入的 Spring Bean 完成。5.6 控制器// src/main/java/com/example/lifesystem/controller/PlayerController.java package com.example.lifesystem.controller; import com.example.lifesystem.entity.PlayerLifeAccount; import com.example.lifesystem.service.PlayerService; import org.springframework.web.bind.annotation.*; import java.util.Map; RestController RequestMapping(/players) public class PlayerController { private final PlayerService playerService; public PlayerController(PlayerService playerService) { this.playerService playerService; } PostMapping(/{playerId}) public PlayerLifeAccount createPlayer(PathVariable Long playerId) { return playerService.createPlayer(playerId); } GetMapping(/{playerId}) public MapString, Object getPlayer(PathVariable Long playerId) { PlayerLifeAccount account playerService.getPlayer(playerId); return Map.of( playerId, account.getPlayerId(), totalLife, account.getTotalLife(), title, account.getTitle() ); } }// src/main/java/com/example/lifesystem/controller/TaskController.java package com.example.lifesystem.controller; import com.example.lifesystem.entity.TaskRecord; import com.example.lifesystem.service.TaskLifeService; import org.springframework.web.bind.annotation.*; import java.util.Map; RestController RequestMapping(/tasks) public class TaskController { private final TaskLifeService taskLifeService; public TaskController(TaskLifeService taskLifeService) { this.taskLifeService taskLifeService; } PostMapping(/{playerId}/accept/{taskId}) public TaskRecord acceptTask(PathVariable Long playerId, PathVariable Long taskId) { return taskLifeService.acceptTask(playerId, taskId); } PostMapping(/{playerId}/report) public Object reportKill(PathVariable Long playerId, RequestParam Long taskId, RequestParam Long bossId, RequestParam String requestId) { return taskLifeService.reportKill(playerId, taskId, bossId, requestId); } }5.7 配置文件# src/main/resources/application.yml spring: datasource: url: jdbc:h2:mem:lifesystem;DB_CLOSE_DELAY-1;DB_CLOSE_ON_EXITFALSE driver-class-name: org.h2.Driver username: sa password: jpa: hibernate: ddl-auto: update show-sql: true properties: hibernate: format_sql: true h2: console: enabled: true path: /h2-console server: port: 8080H2 配置为内存模式重启后数据会丢失只适合本地演示。生产环境请替换为 MySQL 或 PostgreSQL并配置连接池和合理的 DDL 策略。6. 运行结果与效果验证启动应用mvn spring-boot:run启动成功后日志里会显示 Tomcat 启动在 8080 端口。下面用 curl 验证完整流程。第一步创建玩家curl -X POST http://localhost:8080/players/1预期返回{ playerId: 1, totalLife: 0, title: 普通人 }第二步玩家接取“击杀焚天豹”任务curl -X POST http://localhost:8080/tasks/1/accept/1预期返回任务实例状态为RUNNING击杀数 0。第三步玩家上报击杀焚天豹请求 ID 用req-001curl -X POST \ http://localhost:8080/tasks/1/report?taskId1bossId101requestIdreq-001注意Demo 中击杀焚天豹任务需要 10 只才能完成所以前 9 次上报返回值里completed都是false第 10 次上报返回true。实际测试时可以连续执行 10 次 curl但每次 requestId 要不同for i in $(seq 1 10); do curl -X POST \ http://localhost:8080/tasks/1/report?taskId1bossId101requestIdreq-$i done等到第 10 次预期返回{ message: 击杀上报成功, completed: true }第四步查询玩家信息curl http://localhost:8080/players/1预期返回{ playerId: 1, totalLife: 100, title: 百夫长 }玩家累积了 100 年寿命称号升级为“百夫长”。第五步验证幂等再次使用已经处理过的req-10上报curl -X POST \ http://localhost:8080/tasks/1/report?taskId1bossId101requestIdreq-10预期返回{ message: 重复请求已忽略, completed: true }同时查询玩家寿命仍然应该是 100不会变成 200。这就是幂等防重的作用。如果某个请求失败先看控制台有没有 SQL 异常。比如重复插入相同requestId会报唯一约束冲突重复接取任务会报uk_player_task唯一约束冲突。这些异常通常意味着业务逻辑已经进入错误分支需要根据堆栈定位。也可以打开 H2 控制台默认地址是http://localhost:8080/h2-consoleJDBC URL 填入jdbc:h2:mem:lifesystem用户名为sa密码留空就能直接查看三张表的数据。7. 常见问题与排查思路下面整理几个实际项目中最高频的问题。问题现象可能原因排查方式解决方案玩家奖励被重复发放未做幂等或 requestId 每次不同查看流水表是否有重复记录、核对客户端请求 ID流水表加唯一索引客户端为每个事件生成全局唯一 ID并发击杀导致余额少算多个请求同时读取余额再写回后写覆盖先写打开 SQL 日志观察是否有相同账户的并发 update使用 JPAVersion乐观锁或使用原子 SQLupdate ... set total_life total_life ?任务已完成还能继续上报进度状态机校验缺失查看任务实例状态字段在reportKill入口严格检查RUNNING状态事务方法没生效Service 内部自调用绕过 Spring 代理检查调用链是否通过this调用内部方法使用注入的 Bean 调用或将方法拆分到独立 ServiceH2 重启后数据丢失内存模式数据库检查配置的 JDBC URL生产环境换持久化数据库下游消息重复消费MQ 或任务调度重复投递查看消费端日志确认同一消息处理多次消费端用业务唯一键做幂等不信任消息系统自带 at-least-once 保证高并发下乐观锁异常频繁玩家同时多次上报同一行记录竞争查看异常日志中的OptimisticLockException合并请求或使用队列串行化同一玩家的结算任务每个问题背后都对应一种设计缺失。如果先把幂等、状态机、唯一约束做对大部分坑都能在设计阶段避免。8. 最佳实践与工程建议8.1 流水与余额必须分离余额是“结果”流水是“原因”。只有余额没有流水等于账本只有数字但没有任何凭证。每次奖励变动都应该写一条包含 requestId、玩家 ID、变动值、原因、时间的流水。这样即使线上数据出错也能通过流水恢复。8.2 用唯一索引作为幂等兜底业务代码可以先查一次 requestId 是否存在但查询不是原子操作。两个并发请求可能同时查到不存在然后同时写入。所以数据库唯一索引是必须的。代码里先做一次检查能给用户更快返回真正防重的靠数据库约束。8.3 并发更新用乐观锁或原子 SQLJPA 的Version能解决并发覆盖问题但在冲突频繁时会抛出乐观锁异常需要重试。更高性能的做法是直接用UPDATE player_life_account SET total_life total_life ? WHERE player_id ?。但原子 SQL 不容易记录流水明细实际项目中通常“流水 账户更新”都放在同一个数据库事务里所以用乐观锁已经能覆盖大多数场景。8.4 状态流转必须显式任务状态字段不能随意 set。更规范的做法是引入 StateMachine 模式定义每个状态允许的迁移路径。本文用简单枚举演示真实项目推荐用状态机框架或在 Service 层统一封装状态变更方法禁止外部直接修改。8.5 合理设计任务唯一键如果同一玩家同一时间只能有一个同类型任务给(playerId, taskId)加唯一索引。如果允许同类型任务同时存在多个那么设计上就要增加“任务批次号”之类字段否则业务会乱。唯一索引设计反映的是业务约束必须提前想清楚。8.6 异步化与消息队列击杀上报的请求量通常远大于结算量。可以先把上报事件写入消息队列再由消费者执行进度累计和结算。消费者天然要处理重复投递所以幂等设计是不可省略的。对于同一玩家的多个事件可以按 playerId 分区保证同一玩家的事件被同一消费者顺序处理降低并发冲突。8.7 审计与监控线上系统一定要对结算操作记录审计日志包括操作时间、操作来源、requestId、前后余额。同时监控两个指标一是流水表写入失败次数二是乐观锁冲突次数。这两个指标突增说明系统可能正在被刷或存在并发设计缺陷。8.8 客户端上报数据不能直接信任服务端必须校验任务是否存在、妖兽 ID 是否合法、玩家是否接取了对应任务。小说里的系统是天然的规则引擎而真实系统中规则要由服务端强校验。不能假设客户端只会发正确的数据。9. 总结与后续学习方向通过这个“杀兽夺寿系统”的业务背景我们已经把一个看起来很简单的小说设定拆解成了一个相对完整的游戏后端奖励结算系统。核心不是代码本身而是三个设计思维流水与余额分离、业务唯一键保证幂等、状态机控制任务生命周期。建议你把代码下载下来跑一遍重点观察第 10 次击杀后的结算流程以及重放相同 requestId 时数据库的唯一约束如何拦截。理解这几个点之后再去看电商订单、充值系统、积分系统会发现它们面临的是同一类一致性问题。后续值得深入的方向包括用 Redis 分布式锁改造并发控制、引入消息队列实现事件异步化、把奖励规则抽成可配置的规则引擎、增加对账任务定期核对流水和余额。当你把这些都补齐就是一个可以支撑真实游戏业务的后台系统了。回到文章开头那句话不要被“杀兽夺寿系统”的小说味劝退把它当成产品需求其实是很好的系统设计练习。真正难的不是写 update 语句而是想清楚什么条件下能更新、重复请求怎么拦截、数据坏了怎么恢复。把这些想明白任何任务奖励类需求都能稳稳落地。