news 2026/9/8 1:53:34

全服世界任务后端实战:Redis并发控制与WebSocket实时推送

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
全服世界任务后端实战:Redis并发控制与WebSocket实时推送

最近在聊到原神世界任务的时候,“全世界的玩家,团结一心”这句话很容易让人产生共鸣。从产品设计角度看,这是二游在内容包装上的功力;但从技术角度看,这句话背后其实藏着一个非常经典的分布式协作场景:成千上万名玩家对同一个任务目标并发提交进度,系统既要保证进度不混乱、不超发、不重复结算,还要把最新进度实时同步给所有参与者。

这篇文章不聊剧情,也不做游戏攻略,而是把“世界任务”当成一个分布式系统场景来拆解。我会从全服任务的技术本质出发,分析它背后涉及的任务状态管理、并发控制、实时推送等问题,然后用 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 以上(需要支持INCRSETNXSET EX等基础命令)
  • Spring Boot 2.7.x(文章示例基于 2.7,版本可按个人习惯调整)
  • 浏览器建议使用 Chrome 或 Edge

如果你还没有安装 Redis,可以先启动一个本地 Redis 服务。Mac 上可以使用brew install redis,Windows 可以使用 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 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>world-task-demo</artifactId> <version>1.0.0</version> <name>world-task-demo</name> <description>全服世界任务协作 Demo</description> <properties> <java.version>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-websocket</artifactId> </dependency> </dependencies> <build> <plugins> <plugin> <groupId>org.springframework.boot</groupId> <artifactId>spring-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 同时提交贡献。如果使用先读再写的逻辑:

  1. 读取当前进度 0
  2. 计算新进度 0 + 1 = 1
  3. 写回进度 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 10
  • NX表示只有当 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 CopyOnWriteArraySet<WebSocketSession> 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 Map<String, 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); Map<String, Object> result = new HashMap<>(); result.put("taskId", taskId); result.put("progress", progress); result.put("target", target); result.put("finished", finished); return result; } /** * 玩家提交贡献 */ public Map<String, Object> contribute(String taskId, long value) { if (value <= 0) { throw new IllegalArgumentException("贡献值必须大于 0"); } String finishedKey = FINISHED_KEY_PREFIX + taskId; if (hasKey(finishedKey)) { Map<String, 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. 广播最新进度给所有在线客户端 Map<String, 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(Map<String, 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 { Map<String, 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 Map<String, Object> contribute(@PathVariable String taskId, @RequestParam long value) { return worldTaskService.contribute(taskId, value); } @GetMapping("/{taskId}/progress") public Map<String, 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 lang="zh-CN"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.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>任务ID:defaultTask</p> <p>当前进度:<span id="progress">0</span> / <span id="target">10000</span></p> <div class="progress-wrap"> <progress id="progressBar" value="0" max="10000"></progress> </div> <p id="finishedTip" style="color: green; font-weight: bold;"></p> <h3>模拟玩家提交贡献</h3> <button onclick="contribute(1)">贡献 1</button> <button onclick="contribute(10)">贡献 10</button> <button onclick="contribute(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?value=100" & done

或者在多个浏览器标签页同时点击“贡献 100”,观察进度是否准确累加。

4.7 结果说明

接口POST /api/task/defaultTask/contribute?value=100的预期响应如下:

{ "taskId": "defaultTask", "progress": 100, "target": 10000, "finished": false }

当进度累计到 10000 时,响应中的finished会变为true,控制台会打印类似下面的日志:

世界任务完成!任务ID=defaultTask,最终进度=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_KEYcontribute入口判断任务是否已完成

这里重点展开两个问题。

第一个是任务重复结算。很多同学以为加了锁就万事大吉,但实际上,如果两个请求同时进入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 可观测性与告警

任务系统的状态变化需要被监控

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

AI Agent一键生成微信小程序:从需求描述到可运行项目全流程实践

这次我们来看一个很有意思的实践&#xff1a;我把AI Agent和微信小程序开发串成了一个完整的Skill&#xff0c;从你输入一句话需求&#xff0c;到最终在微信开发者工具里跑起来&#xff0c;中间所有环节——需求分析、原型设计、页面结构、逻辑代码、云开发配置、甚至首次运行的…

作者头像 李华
网站建设 2026/9/8 1:53:25

10 分钟搭好自己的云端游戏主机:Sunshine 游戏串流服务器新手指南

10 分钟搭好自己的云端游戏主机&#xff1a;Sunshine 游戏串流服务器新手指南 【免费下载链接】Sunshine Self-hosted game stream host for Moonlight. 项目地址: https://gitcode.com/GitHub_Trending/su/Sunshine 从书房主机到客厅电视 书房里放着一台配置不错的游戏…

作者头像 李华
网站建设 2026/9/3 19:45:39

星环科技秋招笔试算法题复盘:高频题型与Python解题思路

星环科技在2024年秋招里的笔试算法题&#xff0c;我在牛客网刷经验帖的时候就已经盯上了。作为一个主攻大数据方向、投了不少基础软件公司的应届生&#xff0c;星环的算法笔试算是秋招路上很有代表性的一场。它的题目难度不低&#xff0c;而且风格很“实用”——和纯互联网大厂…

作者头像 李华
网站建设 2026/9/6 9:20:43

串联谐振电路能量转换全面解析:从储能交换到仿真验证

串联谐振电路是电力电子、射频、天线匹配、感应加热和开关电源里绕不开的基础结构。很多读者对它的印象停留在“谐振时阻抗最小、电流最大”&#xff0c;再深入一点就是“电容和电感上的电压会放大”。但一旦要解释能量在电感、电容、电阻之间具体怎么流动&#xff0c;电源为什…

作者头像 李华
网站建设 2026/9/5 2:50:21

157、基于模型的强化学习:学习动力学模型与规划融合

157、基于模型的强化学习:学习动力学模型与规划融合 上个月在调试一个机械臂插拔USB接口的任务,用DreamerV3训练了整整两天,策略在仿真里已经能稳定插拔了,结果一上真机就翻车——机械臂每次快碰到接口时都会剧烈抖动,然后“啪”地一下怼歪。我盯着reward曲线看了半天,发…

作者头像 李华