news 2026/8/30 9:52:48

从杀兽夺寿系统看游戏奖励结算的幂等与并发设计

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
从杀兽夺寿系统看游戏奖励结算的幂等与并发设计

看到“只剩半年性命绝境觉醒杀兽夺寿系统”这种小说标题,很多后端工程师的第一反应可能是:这跟技术有什么关系?但如果把它当成一份产品需求来拆,会发现里面藏着一整套游戏后台系统设计题:任务怎么接取、击杀怎么上报、寿命点怎么累计、称号怎么晋升、奖励怎么防止重复发放。这篇博客不讨论小说剧情,只讨论“杀兽夺寿系统”背后真正值得写代码的部分——一个具备幂等、防并发、可追溯的奖励结算系统。

这类功能的难点不在 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.6+
  • Spring Boot 3.x
  • Spring Data JPA
  • H2 数据库(内存模式,方便本地演示)
  • 一个 IDE 或命令行终端

Spring Boot 3.x 是目前主流版本,JDK 17 是它的基础要求。如果你本地是 JDK 8,需要先切换版本或者使用 Spring Boot 2.x,但代码中的 Jakarta 包名会不同,建议直接使用 JDK 17。

创建一个 Maven 项目后,在pom.xml中加入以下关键依赖:

<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-jpa</artifactId> </dependency> <dependency> <groupId>com.h2database</groupId> <artifactId>h2</artifactId> <scope>runtime</scope> </dependency> <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <optional>true</optional> </dependency> </dependencies>

版本号不写死,是因为不同的 Spring Boot 父工程版本会统一管理这些依赖。建议使用 Spring Initializr 生成项目模板,再手动调整代码。

4. 核心流程拆解

4.1 玩家创建

玩家是寿命余额的持有者。每个玩家有一条生命周期内唯一对应的寿命账户记录。创建玩家时,服务端会同时初始化一个余额为 0 的账户。设计时可以把玩家信息和账户信息分成两张表,也可以合并成一张表,本文为了演示账户和流水的关系,单独建模。

4.2 任务接取

玩家接取任务后,系统创建一条任务实例记录,状态为 RUNNING,初始击杀数为 0。接取时要校验玩家是否存在、任务是否允许接取、玩家是否已经同时拥有同类任务。这些校验看起来简单,但漏掉任意一个都会导致后续结算数据异常。

这里容易踩坑的点是“重复接取”。如果玩家快速点击两次接取按钮,服务端可能创建两条任务实例。解决思路是给玩家和任务模板的组合加唯一约束,或者在业务代码中加分布式锁。本文用数据库唯一索引来兜底。

4.3 击杀上报

玩家击杀妖兽后,客户端把事件发给服务端。上报内容至少包含:玩家 ID、任务 ID、妖兽 ID、客户端生成的请求 ID。服务端要做四件事:

  1. 检查请求 ID 是否已经处理过,如果处理过直接返回原结果;
  2. 加载任务实例,校验状态是否为 RUNNING;
  3. 累加击杀数;
  4. 判断是否达到目标,达到则触发结算。

如果状态不是 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 JpaRepository<PlayerLifeAccount, Long> { Optional<PlayerLifeAccount> 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 JpaRepository<TaskRecord, Long> { Optional<TaskRecord> 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 JpaRepository<LifeLedgerEntry, Long> { Optional<LifeLedgerEntry> 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 List<String> 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 Map<Long, TaskTemplate> TASK_TEMPLATES = Map.of( 1L, new TaskTemplate("击杀焚天豹", 10L, 100L), 2L, new TaskTemplate("击杀暗天魔龙", 3L, 1000L) ); // 妖兽奖励:妖兽 ID -> 单次寿命奖励 private static final Map<Long, 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 Map<String, 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_EXIT=FALSE 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: 8080

H2 配置为内存模式,重启后数据会丢失,只适合本地演示。生产环境请替换为 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-001

curl -X POST \ "http://localhost:8080/tasks/1/report?taskId=1&bossId=101&requestId=req-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?taskId=1&bossId=101&requestId=req-$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?taskId=1&bossId=101&requestId=req-10"

预期返回:

{ "message": "重复请求已忽略", "completed": true }

同时查询玩家寿命,仍然应该是 100,不会变成 200。这就是幂等防重的作用。

如果某个请求失败,先看控制台有没有 SQL 异常。比如重复插入相同requestId会报唯一约束冲突,重复接取任务会报uk_player_task唯一约束冲突。这些异常通常意味着业务逻辑已经进入错误分支,需要根据堆栈定位。

也可以打开 H2 控制台,默认地址是http://localhost:8080/h2-console,JDBC URL 填入jdbc:h2:mem:lifesystem,用户名为sa,密码留空,就能直接查看三张表的数据。

7. 常见问题与排查思路

下面整理几个实际项目中最高频的问题。

问题现象可能原因排查方式解决方案
玩家奖励被重复发放未做幂等或 requestId 每次不同查看流水表是否有重复记录、核对客户端请求 ID流水表加唯一索引,客户端为每个事件生成全局唯一 ID
并发击杀导致余额少算多个请求同时读取余额再写回,后写覆盖先写打开 SQL 日志,观察是否有相同账户的并发 update使用 JPA@Version乐观锁,或使用原子 SQLupdate ... set total_life = total_life + ?
任务已完成还能继续上报进度状态机校验缺失查看任务实例状态字段reportKill入口严格检查RUNNING状态
事务方法没生效Service 内部自调用,绕过 Spring 代理检查调用链是否通过this调用内部方法使用注入的 Bean 调用,或将方法拆分到独立 Service
H2 重启后数据丢失内存模式数据库检查配置的 JDBC URL生产环境换持久化数据库
下游消息重复消费MQ 或任务调度重复投递查看消费端日志,确认同一消息处理多次消费端用业务唯一键做幂等,不信任消息系统自带 at-least-once 保证
高并发下乐观锁异常频繁玩家同时多次上报,同一行记录竞争查看异常日志中的OptimisticLockException合并请求,或使用队列串行化同一玩家的结算任务

每个问题背后都对应一种设计缺失。如果先把幂等、状态机、唯一约束做对,大部分坑都能在设计阶段避免。

8. 最佳实践与工程建议

8.1 流水与余额必须分离

余额是“结果”,流水是“原因”。只有余额没有流水,等于账本只有数字但没有任何凭证。每次奖励变动,都应该写一条包含 requestId、玩家 ID、变动值、原因、时间的流水。这样即使线上数据出错,也能通过流水恢复。

8.2 用唯一索引作为幂等兜底

业务代码可以先查一次 requestId 是否存在,但查询不是原子操作。两个并发请求可能同时查到不存在,然后同时写入。所以数据库唯一索引是必须的。代码里先做一次检查能给用户更快返回,真正防重的靠数据库约束。

8.3 并发更新用乐观锁或原子 SQL

JPA 的@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 语句,而是想清楚什么条件下能更新、重复请求怎么拦截、数据坏了怎么恢复。把这些想明白,任何任务奖励类需求都能稳稳落地。

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

Expo 完整指南:如何用 React 快速跑通第一个跨端应用

Expo 完整指南&#xff1a;如何用 React 快速跑通第一个跨端应用 【免费下载链接】expo An open-source framework for making universal native apps with React. Expo runs on Android, iOS, and the web. 项目地址: https://gitcode.com/GitHub_Trending/ex/expo Exp…

作者头像 李华
网站建设 2026/8/30 9:51:23

行人多摄像头重识别数据集

摘要&#xff1a;行人多摄像头重识别数据集是一个面向智能监控与计算机视觉研究的重要视觉数据集&#xff0c;主要用于研究不同摄像头视角下的行人身份匹配问题。数据集概述行人多摄像头重识别数据集是一个面向智能监控与计算机视觉研究的重要视觉数据集&#xff0c;主要用于研…

作者头像 李华
网站建设 2026/8/30 9:51:13

英伟达129亿美金收购Hugging Face,直播揭秘开源AI为何越来越值钱!

天际资本与开源中国直播&#xff1a;探讨英伟达129亿美金收购Hugging Face8月28日周五晚&#xff0c;天际资本创始人张倩和开源中国CEO徐勇进行了一场直播&#xff0c;主题聚焦于英伟达收购Hugging Face。直播中&#xff0c;评论区问得最多的问题便是收购价&#xff0c;很多人疑…

作者头像 李华
网站建设 2026/8/30 9:50:28

彻底搞懂JS箭头函数与this指向:从原理到工程实践

写 JS 代码的时候&#xff0c;你有没有被 this 坑过&#xff1f; 明明在 setTimeout 回调里只是想访问一下组件实例的数据&#xff0c;结果 this.name 直接给你报 undefined &#xff1b;明明在事件监听器里想拿到点击的元素&#xff0c;结果 this 指向了 window …

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

从 Vibe Coding 到 Agent 工程:Claude Code 扩展功能新手完整教程

从 Vibe Coding 到 Agent 工程&#xff1a;Claude Code 扩展功能新手完整教程 【免费下载链接】claude-code-best-practice from vibe coding to agentic engineering - practice makes claude perfect 项目地址: https://gitcode.com/GitHub_Trending/cl/claude-code-best-p…

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

Joplin 跨平台笔记同步:把笔记随身带上任何一台设备

Joplin 跨平台笔记同步&#xff1a;把笔记随身带上任何一台设备 【免费下载链接】joplin Joplin - the privacy-focused note taking app with sync capabilities for Windows, macOS, Linux, Android and iOS. 项目地址: https://gitcode.com/GitHub_Trending/jo/joplin …

作者头像 李华