
最近在聊到原神世界任务的时候“全世界的玩家团结一心”这句话很容易让人产生共鸣。从产品设计角度看这是二游在内容包装上的功力但从技术角度看这句话背后其实藏着一个非常经典的分布式协作场景成千上万名玩家对同一个任务目标并发提交进度系统既要保证进度不混乱、不超发、不重复结算还要把最新进度实时同步给所有参与者。这篇文章不聊剧情也不做游戏攻略而是把“世界任务”当成一个分布式系统场景来拆解。我会从全服任务的技术本质出发分析它背后涉及的任务状态管理、并发控制、实时推送等问题然后用 Spring Boot Redis WebSocket 从零搭建一个简化版的全服协作任务 Demo。无论你是对游戏后端感兴趣还是想了解 Redis 并发方案这篇内容都可以作为一次完整的落地演练。1. 背景与核心概念1.1 从“全世界玩家团结”看世界任务的设计在《原神》这类头部二游里有些世界任务会刻意制造一种“全服玩家都在为同一个目标努力”的氛围。比如某些限时活动会把某个区域贡献值、集体击杀数、探索进度等做成全服共享目标玩家每次完成任务都能看到总进度向前推进一截这种正反馈就是“团结一心”感觉的来源。从玩家视角看世界任务是共同命运感的载体从开发者视角看它的本质是一个多人在线协作系统。一个任务目标对应一条共享数据所有玩家都可以修改这条数据系统需要保证每次修改都不会被并发覆盖整体进度不会超过任务目标而且完成后的奖励不能重复发放。理解了这个本质许多后端问题就清晰了它不是简单写一个UPDATE task SET progress progress 1就能完成的因为数据库行锁在超高并发下会成为瓶颈跨服务、跨实例时的数据一致性更是核心难点。1.2 任务系统在技术上的本质如果把任务系统按玩家规模划分可以分成单机任务、小队任务和全服任务三种它们在技术上面对的问题完全不同。任务类型玩家规模核心问题常用技术手段单机任务1 人任务状态持久化、进度保存数据库表、文件存储小队任务2 ~ 4 人多人协作、状态同步、超时处理数据库事务 行锁、状态机全服任务成百上千人高并发写入、不超发、不重复结算、实时反馈Redis 原子操作、分布式锁、WebSocket/MQ 推送全服任务最难的地方不是业务逻辑本身而是“并发”。假设有 10000 个玩家同时提交贡献如果使用数据库行锁那么同一时刻只能有一个事务在修改进度其他请求全部排队等待数据库连接池和锁等待很快就会被耗尽。更好的思路是使用 Redis 这类高性能中间件做计数与状态流转再用异步方式把最终结果落库。1.3 为什么需要掌握这类实现“全服任务”这种玩法并不只存在于游戏里。公益平台的全网捐款进度、众筹平台的目标金额、团队打卡活动的总里程本质上都是同一个模型一个共享目标 海量参与者 实时进度反馈。掌握这套设计之后你可以把它迁移到很多真实业务场景里。所以本文选择用游戏场景切入但底层技术是通用的。接下来我会先搭建环境再逐步实现一个可运行的全服世界任务 Demo。2. 环境准备与项目结构2.1 环境依赖说明本文代码采用以下环境你本机版本不需要完全一致但建议保持大版本接近JDK 8 以上示例使用 Java 8 语法Maven 3.6 以上Redis 6 以上需要支持INCR、SETNX、SET EX等基础命令Spring Boot 2.7.x文章示例基于 2.7版本可按个人习惯调整浏览器建议使用 Chrome 或 Edge如果你还没有安装 Redis可以先启动一个本地 Redis 服务。Mac 上可以使用brew install redisWindows 可以使用 WSL 或直接下载 Redis 安装包Linux 可以通过apt install redis-server安装。确保执行redis-cli ping返回PONG。2.2 项目结构规划先看完整的项目结构后面再逐个文件编写。world-task-demo ├── pom.xml └── src └── main ├── java │ └── com │ └── example │ └── worldtask │ ├── WorldTaskApplication.java │ ├── config │ │ └── WebSocketConfig.java │ ├── controller │ │ └── TaskController.java │ ├── handler │ │ └── TaskWebSocketHandler.java │ └── service │ └── WorldTaskService.java └── resources ├── application.yml └── static └── index.html这个结构是标准的 Spring Boot 分层结构controller负责 HTTP 接口service负责核心业务逻辑config负责 WebSocket 配置handler处理 WebSocket 消息static放前端演示页面。2.3 引入 Maven 依赖在pom.xml中引入 Web、Redis、WebSocket 三个 Starter。?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 artifactIdworld-task-demo/artifactId version1.0.0/version nameworld-task-demo/name description全服世界任务协作 Demo/description properties java.version8/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-websocket/artifactId /dependency /dependencies build plugins plugin groupIdorg.springframework.boot/groupId artifactIdspring-boot-maven-plugin/artifactId /plugin /plugins /build /project这里需要说明一点spring-boot-starter-parent使用 2.7.18 只是作为示例。如果你使用 Spring Boot 3.x需要把javax.annotation.Resource替换为jakarta.annotation.Resource同时注意 WebSocket 配置类包的调整。本文示例以 Spring Boot 2.7 为主。3. 核心设计任务进度如何安全累计在正式编写代码之前先梳理三个核心设计点。它们是整个全服世界任务系统能否正常工作的关键。3.1 使用 Redis INCR 原子自增解决并发覆盖假设任务进度初始是 0玩家 A 和玩家 B 同时提交贡献。如果使用先读再写的逻辑读取当前进度 0计算新进度 0 1 1写回进度 1两个请求同时执行时都读到了 0最后都写回 1实际进度应该是 2但结果变成了 1这就是典型的并发丢失更新问题。数据库可以通过行锁避免但在高并发下行锁会让所有写请求串行化。Redis 提供了INCR命令它是一个原子操作多个客户端同时执行也不会互相覆盖。也就是说只要每次贡献都调用INCR key value最终进度一定是所有提交值的总和。这个原子特性正是全服任务计数最需要的。比数据库锁轻量得多而且 Redis 的操作延迟极低可以扛住大量并发请求。注意Redis 中的INCR只能操作整数。如果你的游戏任务存在小数进度可以改用INCRBYFLOAT但要注意浮点数精度问题实际项目中更推荐使用整数表示最小粒度比如“以千分之一为单位”的进度。3.2 使用 SETNX 分布式锁防止重复结算任务进度到达目标值后需要触发“结算”逻辑比如给玩家发奖励、写任务完成记录等。这里的风险在于如果两个玩家同时提交使进度刚好跨过目标值两个请求都可能判断出“任务已完成”然后重复执行结算。解决思路是使用分布式锁。Redis 提供了SETNX命令当 key 不存在时才能设置成功这个特性可以充当互斥锁SET world:task:lock:defaultTask 1 NX EX 10NX表示只有当 key 不存在时才设置EX 10表示锁的自动过期时间为 10 秒设置成功说明当前实例获得了锁只有它能执行结算获得锁后还需要二次检查“当前进度是否大于等于目标”因为可能有人在你加锁之前已经完成了结算。这种“加锁 - 二次检查 - 执行结算 - 释放锁”的模式可以防止绝大多数重复结算问题。不过要注意这种简单分布式锁在极端场景下还有锁过期、Redis 主从切换等问题。生产环境推荐使用 Redisson 的看门狗机制或者基于 ZooKeeper 实现。3.3 使用 WebSocket 解决进度实时同步全服任务的体验好不好很大程度上取决于“实时反馈”。如果玩家贡献后要刷新页面才能看到进度就会失去那种“全世界玩家共同努力”的氛围。HTTP 轮询当然可以实现但效率低、延迟不可控。WebSocket 更适合这种场景它可以在服务端和客户端之间建立一条全双工长连接服务端可以主动向所有客户端推送最新进度。在这个 Demo 中我会在内存中保存所有 WebSocket 会话当任务进度变化时通过遍历会话向每个在线玩家广播最新数据。这样做在单机演示中完全够用如果是多实例部署则需要通过 Redis Pub/Sub 或消息中间件把广播消息同步到所有实例。3.4 数据最终一致性思路为了便于演示这个 Demo 把任务进度直接存储在 Redis 中。但真实项目里Redis 通常是“加速层”数据库才是最终数据源。推荐的落库思路是玩家提交贡献时直接用 RedisINCR更新内存进度同时把一条贡献流水写入消息队列消费端异步把流水落地到数据库定时任务比对 Redis 进度和数据库总和一旦发现不一致就告警并补偿这样既保证了高并发下的进度体验也保留了可追溯、可对账的数据基础。4. 完整实战用 Demo 演示全服世界任务接下来进入核心实战环节。我会把项目从零到运行完整地过一遍。4.1 创建项目结构和启动类使用 IDEA 创建 Spring Boot 项目时选择 Web、Redis、WebSocket 依赖即可如果你手动创建按 2.2 的目录结构建包。启动类放在com.example.worldtask包下。// 文件路径src/main/java/com/example/worldtask/WorldTaskApplication.java package com.example.worldtask; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; SpringBootApplication public class WorldTaskApplication { public static void main(String[] args) { SpringApplication.run(WorldTaskApplication.class, args); } }4.2 编写核心配置在src/main/resources/application.yml中配置服务端口和 Redis 连接信息。server: port: 8080 spring: application: name: world-task-demo redis: host: localhost port: 6379 database: 0如果你的 Redis 设置了密码需要在spring.redis.password中补充。这里的配置比较基础实际项目中还会配置连接池、超时时间、序列化器等。4.3 编写世界任务服务层服务层是整个 Demo 的核心。它需要完成几个职责查询任务进度、处理玩家贡献、判断任务完成并结算、向 WebSocket 会话广播进度。先看完整代码// 文件路径src/main/java/com/example/worldtask/service/WorldTaskService.java package com.example.worldtask.service; import com.fasterxml.jackson.databind.ObjectMapper; import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.stereotype.Service; import org.springframework.web.socket.TextMessage; import org.springframework.web.socket.WebSocketSession; import javax.annotation.Resource; import java.time.Duration; import java.util.HashMap; import java.util.Map; import java.util.concurrent.CopyOnWriteArraySet; Service public class WorldTaskService { private static final String PROGRESS_KEY_PREFIX world:task:progress:; private static final String TARGET_KEY_PREFIX world:task:target:; private static final String LOCK_KEY_PREFIX world:task:lock:; private static final String FINISHED_KEY_PREFIX world:task:finished:; private static final long DEFAULT_TARGET 10000; Resource private StringRedisTemplate stringRedisTemplate; private final CopyOnWriteArraySetWebSocketSession sessions new CopyOnWriteArraySet(); private final ObjectMapper objectMapper new ObjectMapper(); /** * WebSocket 连接建立时调用 */ public void register(WebSocketSession session) { sessions.add(session); pushProgress(session, defaultTask); } /** * WebSocket 连接关闭时调用 */ public void unregister(WebSocketSession session) { sessions.remove(session); } /** * 查询任务当前进度 */ public MapString, Object queryProgress(String taskId) { long progress getLong(PROGRESS_KEY_PREFIX taskId); long target getLong(TARGET_KEY_PREFIX taskId); if (target 0) { target DEFAULT_TARGET; } boolean finished hasKey(FINISHED_KEY_PREFIX taskId); MapString, Object result new HashMap(); result.put(taskId, taskId); result.put(progress, progress); result.put(target, target); result.put(finished, finished); return result; } /** * 玩家提交贡献 */ public MapString, Object contribute(String taskId, long value) { if (value 0) { throw new IllegalArgumentException(贡献值必须大于 0); } String finishedKey FINISHED_KEY_PREFIX taskId; if (hasKey(finishedKey)) { MapString, Object result queryProgress(taskId); result.put(message, 任务已经完成无法继续贡献); return result; } String targetKey TARGET_KEY_PREFIX taskId; long target getLong(targetKey); if (target 0) { target DEFAULT_TARGET; stringRedisTemplate.opsForValue().set(targetKey, String.valueOf(target)); } String progressKey PROGRESS_KEY_PREFIX taskId; // 1. 使用 Redis 原子自增避免并发覆盖 long newProgress stringRedisTemplate.opsForValue().increment(progressKey, value); // 2. 判断是否达到目标 if (newProgress target) { tryLockAndSettle(taskId, newProgress, target); } // 3. 广播最新进度给所有在线客户端 MapString, Object data queryProgress(taskId); broadcast(data); return data; } /** * 加锁并执行任务结算 */ private void tryLockAndSettle(String taskId, long progress, long target) { String lockKey LOCK_KEY_PREFIX taskId; Boolean locked stringRedisTemplate.opsForValue() .setIfAbsent(lockKey, 1, Duration.ofSeconds(10)); if (!Boolean.TRUE.equals(locked)) { return; } try { // 二次检查防止重复结算 String finishedKey FINISHED_KEY_PREFIX taskId; if (hasKey(finishedKey)) { return; } long currentProgress getLong(PROGRESS_KEY_PREFIX taskId); if (currentProgress target) { // 在这里执行真正的结算逻辑发放奖励、写数据库、更新任务状态 System.out.println(世界任务完成任务ID taskId 最终进度 currentProgress 目标 target); stringRedisTemplate.opsForValue().set(finishedKey, 1); } } finally { stringRedisTemplate.delete(lockKey); } } /** * 向所有在线 WebSocket 会话广播进度 */ private void broadcast(MapString, Object data) { if (sessions.isEmpty()) { return; } String message toJson(data); for (WebSocketSession session : sessions) { if (session.isOpen()) { try { synchronized (session) { session.sendMessage(new TextMessage(message)); } } catch (Exception e) { // 单个会话发送失败不影响其他会话 } } } } /** * 给单个 WebSocket 会话推送任务进度 */ private void pushProgress(WebSocketSession session, String taskId) { try { MapString, Object data queryProgress(taskId); String message toJson(data); synchronized (session) { session.sendMessage(new TextMessage(message)); } } catch (Exception e) { // 忽略推送异常 } } private long getLong(String key) { String value stringRedisTemplate.opsForValue().get(key); return value null ? 0L : Long.parseLong(value); } private boolean hasKey(String key) { return Boolean.TRUE.equals(stringRedisTemplate.hasKey(key)); } private String toJson(Object obj) { try { return objectMapper.writeValueAsString(obj); } catch (Exception e) { return {}; } } }这段代码有几个值得强调的细节。第一increment是原子操作它能保证在并发环境下进度值不会因为读改写而丢失更新。即使 1000 个玩家同时提交Redis 也会逐个执行自增。第二setIfAbsent(lockKey, 1, Duration.ofSeconds(10))对应的是 Redis 的SET lockKey 1 NX EX 10它能实现分布式锁。拿到锁之后先检查任务是否已经处于完成状态如果没有再执行结算最后在finally中释放锁。第三CopyOnWriteArraySet适合这种“读多写少”的 WebSocket 会话集合因为它的迭代遍历不会抛并发修改异常。第四synchronized (session)避免了多个线程同时向同一个 WebSocket 会话发消息时出现消息交错。4.4 编写控制器与 WebSocket 接入控制器提供两个 HTTP 接口一个是贡献进度一个是查询进度。// 文件路径src/main/java/com/example/worldtask/controller/TaskController.java package com.example.worldtask.controller; import com.example.worldtask.service.WorldTaskService; import org.springframework.web.bind.annotation.*; import javax.annotation.Resource; import java.util.Map; RestController RequestMapping(/api/task) public class TaskController { Resource private WorldTaskService worldTaskService; PostMapping(/{taskId}/contribute) public MapString, Object contribute(PathVariable String taskId, RequestParam long value) { return worldTaskService.contribute(taskId, value); } GetMapping(/{taskId}/progress) public MapString, Object progress(PathVariable String taskId) { return worldTaskService.queryProgress(taskId); } }然后是 WebSocket 的配置类和处理器。// 文件路径src/main/java/com/example/worldtask/config/WebSocketConfig.java package com.example.worldtask.config; import com.example.worldtask.handler.TaskWebSocketHandler; import org.springframework.context.annotation.Configuration; import org.springframework.web.socket.config.annotation.EnableWebSocket; import org.springframework.web.socket.config.annotation.WebSocketConfigurer; import org.springframework.web.socket.config.annotation.WebSocketHandlerRegistry; import javax.annotation.Resource; Configuration EnableWebSocket public class WebSocketConfig implements WebSocketConfigurer { Resource private TaskWebSocketHandler taskWebSocketHandler; Override public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) { registry.addHandler(taskWebSocketHandler, /ws/task) .setAllowedOrigins(*); } }// 文件路径src/main/java/com/example/worldtask/handler/TaskWebSocketHandler.java package com.example.worldtask.handler; import com.example.worldtask.service.WorldTaskService; import org.springframework.stereotype.Component; import org.springframework.web.socket.CloseStatus; import org.springframework.web.socket.TextMessage; import org.springframework.web.socket.WebSocketSession; import org.springframework.web.socket.handler.TextWebSocketHandler; import javax.annotation.Resource; Component public class TaskWebSocketHandler extends TextWebSocketHandler { Resource private WorldTaskService worldTaskService; Override public void afterConnectionEstablished(WebSocketSession session) throws Exception { worldTaskService.register(session); } Override protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception { // 客户端可以发送任务ID来查询某个任务进度这里简单回传当前进度 String taskId message.getPayload(); worldTaskService.pushProgressByTaskId(session, taskId); } Override public void afterConnectionClosed(WebSocketSession session, CloseStatus status) throws Exception { worldTaskService.unregister(session); } }这里我调用了一个pushProgressByTaskId方法但前面服务层里没有定义。为了保持一致要么在服务层补上这个方法要么简化 Handler 的逻辑。这里选择在服务层补充一个公开方法public void pushProgressByTaskId(WebSocketSession session, String taskId) { pushProgress(session, taskId); }同时把pushProgress的访问权限改为当前类的内部调用即可。这样 Handler 就能正常编译。4.5 编写前端演示页面为了让实时推送效果更直观我写一个简单的index.html放到src/main/resources/static/index.html。页面展示任务进度条并提供“贡献 1、贡献 10、贡献 100”三个按钮点击后调用后端接口。!DOCTYPE html html langzh-CN head meta charsetUTF-8 meta nameviewport contentwidthdevice-width, initial-scale1.0 title全服世界任务演示/title style body { font-family: Microsoft YaHei, sans-serif; padding: 40px; } .progress-wrap { margin: 24px 0; } progress { width: 600px; height: 24px; } button { margin-right: 12px; padding: 10px 20px; font-size: 16px; cursor: pointer; } /style /head body h2全服世界任务演示/h2 p任务IDdefaultTask/p p当前进度span idprogress0/span / span idtarget10000/span/p div classprogress-wrap progress idprogressBar value0 max10000/progress /div p idfinishedTip stylecolor: green; font-weight: bold;/p h3模拟玩家提交贡献/h3 button onclickcontribute(1)贡献 1/button button onclickcontribute(10)贡献 10/button button onclickcontribute(100)贡献 100/button script let taskId defaultTask; // 建立 WebSocket 连接 const protocol location.protocol https: ? wss:// : ws://; const ws new WebSocket(protocol location.host /ws/task); ws.onopen function () { console.log(WebSocket connected); }; ws.onmessage function (event) { const data JSON.parse(event.data); document.getElementById(progress).innerText data.progress; document.getElementById(target).innerText data.target; document.getElementById(progressBar).value data.progress; document.getElementById(progressBar).max data.target; if (data.finished) { document.getElementById(finishedTip).innerText 任务已完成感谢所有玩家的贡献; } }; ws.onerror function (error) { console.error(WebSocket error, error); }; // 提交贡献 async function contribute(value) { try { const resp await fetch(/api/task/${taskId}/contribute?value${value}, { method: POST }); const data await resp.json(); document.getElementById(progress).innerText data.progress; document.getElementById(progressBar).value data.progress; if (data.finished) { document.getElementById(finishedTip).innerText 任务已完成感谢所有玩家的贡献; } } catch (e) { console.error(contribute error, e); } } /script /body /html这个页面同时展示了两种数据更新路径点击按钮后通过 HTTP 接口贡献进度响应回来后立即更新页面服务端通过 WebSocket 广播进度其他浏览器的页面不需要点击按钮也会自动更新所以你可以打开两个浏览器窗口一个窗口点击贡献另一个窗口就能看到进度实时变化这正好模拟了“全世界玩家共同推进任务”的效果。4.6 运行与验证启动步骤非常清晰# 1. 启动 Redis redis-server # 2. 启动 Spring Boot 项目 mvn spring-boot:run启动成功后浏览器访问http://localhost:8080你会看到任务进度页面。为了测试并发效果可以在命令行里用curl模拟并发请求for i in {1..5}; do curl -X POST http://localhost:8080/api/task/defaultTask/contribute?value100 done或者在多个浏览器标签页同时点击“贡献 100”观察进度是否准确累加。4.7 结果说明接口POST /api/task/defaultTask/contribute?value100的预期响应如下{ taskId: defaultTask, progress: 100, target: 10000, finished: false }当进度累计到 10000 时响应中的finished会变为true控制台会打印类似下面的日志世界任务完成任务IDdefaultTask最终进度10100目标10000同时所有打开页面的浏览器都会收到 WebSocket 推送显示“任务已完成感谢所有玩家的贡献”。注意最终进度可能大于目标值因为多个玩家同时提交时最后一次增量可能让进度直接超过目标。这是正常的结算逻辑只需要判断progress target即可。5. 常见问题与排查思路在实现和运行这个 Demo 的过程中你很可能会遇到一些典型问题我整理成了一份排查清单。问题现象常见原因解决思路启动报错 Connection refusedRedis 没有启动或 spring.redis.host/port 配置错误先执行redis-cli ping检查 Redis 是否可用进度每次都被覆盖不是累加没有使用INCR而是先 GET 再 SET改用 Redis 原子自增命令任务完成时重复发奖没有加分布式锁或加锁后没有二次检查使用 SETNX 锁并在锁内再次确认完成状态浏览器页面收不到实时推送WebSocket 地址不对或服务端没有广播检查/ws/task路径、setAllowedOrigins配置、服务端 sessions 是否为空任务进度重启后丢失纯 Redis 内存存储未开启持久化Redis 配置 AOF 或 RDB生产环境最终落库贡献接口提示 400参数value未传或小于等于 0检查RequestParam是否传参服务端对 value 做合法性校验任务完成后仍可继续贡献没有检查FINISHED_KEY在contribute入口判断任务是否已完成这里重点展开两个问题。第一个是任务重复结算。很多同学以为加了锁就万事大吉但实际上如果两个请求同时进入tryLockAndSettle第一个请求加锁成功第二个请求在setIfAbsent返回 false 后直接返回这是正常的。真正容易出问题的是第一个请求执行完结算后释放锁第二个请求随后又获得了锁然后再次执行结算。所以锁内必须加上FINISHED_KEY二次检查才能保证幂等。第二个是 WebSocket 连接被断开。如果浏览器通过 Nginx 访问后端Nginx 默认对长连接支持可能不够需要配置proxy_read_timeout和 WebSocket 升级相关的请求头。此外setAllowedOrigins(*)在跨域场景下虽然方便但生产环境必须限制为可信域名否则会有安全风险。6. 最佳实践与工程建议Demo 能跑通只是第一步把它做成一个可靠的全服任务系统还需要考虑很多工程细节。6.1 服务端权威校验永远不要信任客户端提交的数值。在真实游戏中玩家客户端可能被破解、篡改、模拟请求所以服务端必须对每个提交请求做身份认证、参数校验、数值限制和频控。比如单个玩家每次最多贡献多少、每天最多贡献多少次这些规则都应该由服务端决定。6.2 请求幂等与防重玩家可能因为网络超时重复点击提交如果没有幂等处理任务进度会被多算。常见的做法是引入幂等键例如userId taskId requestId在 Redis 中记录已处理过的请求 ID。对于奖励发放更不能重复执行建议在数据库层增加唯一索引用“消费流水号”保证每笔奖励只发放一次。6.3 缓存与数据库一致性本文 Demo 把任务进度放在 Redis 中但 Redis 并不是天然可靠的持久化存储。生产环境需要设计一个“最终一致性”方案任务进度实时更新在 Redis异步消费流水写入数据库定时任务做对账。如果 Redis 和数据库不一致应该有告警和补偿机制而不是直接把 Redis 当成唯一数据源。6.4 限流与防刷全服任务一旦上线很容易引来脚本刷量。需要在网关层做全局限流同时在应用层做玩家维度限流。例如使用 Redis 的滑动窗口记录每个玩家的提交频率发现异常 IP 或异常行为可以自动拉黑。这里还要强调一点任何限流和风控都应该只针对非法请求不能影响正常玩家体验。6.5 可观测性与告警任务系统的状态变化需要被监控