一个异步生成游戏功能的落地复盘:Redis Stream + WebSocket + 状态补偿

一个异步生成游戏功能的落地复盘:Redis Stream + WebSocket + 状态补偿

前言

​ 最近在做一个项目,里面有个调用扣子的工作流去生成小游戏的功能,要做成异步生成的形式,原本以为就是把接口改成异步就差不多了,真正做起来才发现,这类功能麻烦的从来不是"怎么调用 AI",而是异步链路怎么做得稳。

​ 这个功能看起来很普通:前端提交参数,后端生成一个游戏,最后把结果返回给用户。但一次生成经常要跑几分钟,前端不可能一直傻等,用户还会断线、刷新、重复提交,服务也不可能保证永远不重启。

联调一段时间后,问题陆续暴露出来了:

  • 纯轮询体验差,状态变化不够及时
  • 只靠 WebSocket,断线重连后很容易漏消息
  • 服务重启后,正在执行中的任务需要恢复
  • 任务失败后不能只是简单报错,很多时候还要能重试
  • 前端拿到重复消息、乱序消息时,状态可能被旧消息覆盖

所以后面我做的就不只是"把生成逻辑搬到后台"这么简单,而是围绕这个功能补了一整套异步状态同步能力:

  • 用 Redis Stream 做任务队列
  • 用 WebSocket 做实时状态推送
  • 用 MySQL 落任务状态和事件流
  • status_version 处理重复消息和乱序消息
  • /game/pending/game/status/events 做断线后的状态恢复

​ 这篇文章就按真实落地过程,把这套链路是怎么一步步收敛出来的、过程中踩过哪些坑,以及最后为什么会落到 Redis Stream + WebSocket + 状态补偿 这个方案,完整复盘一下。

一、这个问题到底难在哪

游戏生成这个功能,业务目标其实很直接:

  • 用户提交一组参数
  • 后端异步生成游戏
  • 前端能持续看到任务状态
  • 最终拿到成功结果,或者明确知道失败原因

真正麻烦的是周边这些问题:

  1. 任务耗时长,接口不能一直同步阻塞
  2. 前端不能只看到"提交成功",还得知道它现在在排队还是在生成
  3. 服务重启后,不能把处理中任务直接搞丢
  4. 用户断线再回来时,前端得把状态补回来
  5. WebSocket 消息可能重复,也可能乱序
  6. 失败任务不能一挂到底,很多时候还要能重试

说白了,这里真正要做的不是一个"生成接口",而是一套围绕异步任务的状态同步机制。

二、改造前我实际遇到过哪些问题

​ 这部分我想单独提出来说一下,因为很多方案文章只写设计,不写真实问题。实际推动我把这条链路补完整的,恰恰是这些联调时遇到的现象:

  • 本地能收到 queued / generating / success 三条消息,测试环境却只收到两条
  • 前端明明已经提交成功了,但后续状态没继续往下走
  • 某个任务数据库里已经变成 generating,前端却没收到对应推送
  • 页面断线重连之后,中间那段状态变化完全丢失
  • 任务失败后只是短暂报错,服务一重启,原本计划中的重试也没了
  • 有些任务会一直卡在 generating,看上去像在执行,实际上已经挂住了

​ 这些问题单看都不算复杂,但一旦叠在一起,就会发现:这已经不是"异步执行"的问题,而是"异步任务怎么同步、怎么恢复、怎么兜底"的问题

三、为什么最后选了 Redis Stream + WebSocket

1. 纯轮询能做,但体验确实一般

最容易想到的做法其实很朴素:

  1. 提交任务
  2. 返回任务 ID
  3. 前端每隔几秒查一次状态

这套做法不是不能用,但问题也很明显:

  • 状态变化不够及时
  • 轮询太频繁会浪费资源
  • 轮询太慢,用户会觉得页面像卡住了一样

对于"排队中 -> 生成中 -> 成功/失败"这种状态链路,实时推送的体验明显更合适。

2. 进程内异步太轻,但不够稳

另一种很省事的做法,是在 Go 服务里直接起 goroutine 处理。

开发阶段这么写当然快,但只要想上线,很快就会碰到问题:

  • 服务一重启,内存里的任务就没了
  • 很难恢复那些执行到一半的任务
  • 重试和超时恢复都不太好做

所以我一开始就没有把进程内内存队列当成最终方案。

3. Redis Stream 适合这个体量

这个项目本身不是特别重型的消息系统场景,没有必要一上来就 Kafka、RabbitMQ 全套拉满。Redis Stream 对我来说是一个比较合适的折中:

  • 接入成本低
  • 性能足够
  • 支持 Consumer Group
  • 支持 ACK
  • 支持 XPENDING / XAUTOCLAIM
  • 出问题时也有能力做 pending 恢复

它很适合拿来做这种"异步任务执行通道"。

4. WebSocket 负责把状态尽快推给前端

Redis Stream 解决的是"任务怎么异步执行",不是"前端怎么实时看到变化"。

所以我把状态通知这层交给了 WebSocket:

  • 任务受理后推 queued
  • worker 真正开始执行时推 generating
  • 执行结束后推 successfailed

这样前端就不用一直主动轮询了。

最后整个主链路大概长这样:

text 复制代码
HTTP 提交任务 -> MySQL 落任务 -> Redis Stream 入队 -> Worker 消费
-> 更新任务状态 -> WebSocket 推送前端

四、我最后收敛出来的整体链路

后面把逻辑收完之后,整条链路基本稳定在下面这个结构:

text 复制代码
客户端提交生成请求
    ->
GameService 创建任务记录
    ->
MySQL 写入 game_record + 首条 queued 事件
    ->
Redis Stream 入队
    ->
Game Stream Worker 消费任务
    ->
推进任务状态
    ->
写入 game_status_event
    ->
通过 WebSocket 推给前端

这里每一层我后来都尽量让它职责单一:

  • MySQL:存任务状态和事件,作为最终真相
  • Redis Stream:做调度和消费恢复
  • Worker:真正执行任务,推进状态机
  • WebSocket:负责实时通知
  • /game/pending:负责当前态快照
  • /game/status/events:负责事件补拉

这一点我很有感触:异步链路一旦职责混在一起,后面出问题会特别难排查;但只要边界够清楚,很多问题其实都能落到某一层去解决。

五、状态机一定要先想清楚

我这块最后保留的状态并不多,就四个:

  • queued
  • generating
  • success
  • failed

状态少一点不是坏事。异步系统里,状态多未必代表设计得好,很多时候反而会让前后端都更难维护。

真正关键的是两点:

  1. 状态切换边界是否清晰
  2. 前端能不能稳定感知到这些变化

为了把第二件事做好,我后来又补了两个很关键的东西:

  • status_version
  • game_status_event

1. status_version:前端别被旧消息覆盖了

WebSocket 用起来很方便,但它不是一个"绝对有序、绝对不重复"的通道。前端如果只拿着一条消息就直接覆盖状态,很容易出问题。

所以我给每个任务加了 status_version

  • 创建任务时初始化为 1
  • 每次真实状态变化都递增

前端只需要记住一条规则:

同一个 record_id,只处理版本更大的消息。

这样重复消息、乱序消息这些问题,基本就被挡住了。

2. game_status_event:WS 丢了,还能补

只靠 WebSocket 还有个现实问题:用户断线了,或者页面切后台了,这期间的消息就没了。

所以我后面补了一张事件表 game_status_event,把每次状态变化都记录下来,比如:

  • 任务已入队
  • 游戏生成中
  • 生成成功
  • 生成失败
  • 重试后重新入队

这样前端在重连后,就可以拿 last_event_id 去补拉漏掉的事件。

这一步做完之后,整个系统才不再只是"实时推一推",而是真的有了补偿能力。

六、为什么后来又补了 /game/pending/game/status/events

这个变化其实是被线上表现逼出来的。

最早链路跑通的时候,本地看起来没什么问题:

  1. 提交任务
  2. 收到 queued
  3. 收到 generating
  4. 收到 success

但到了联调和测试环境,问题很快就出来了:

  • 有时前端只收到了入队消息
  • 有时 generating 没收到
  • 页面断线重连后,中间的状态变化完全没了

这时候我才意识到,只有 WebSocket 实时推送是远远不够的。

所以后来我补了两类接口。

1. /game/pending:拿当前态快照

这个接口只干一件事:返回当前还在进行中的任务。

也就是说,它只关心:

  • queued
  • generating

它不是给前端看历史的,而是用来在这些时机快速纠偏:

  • 页面进入
  • WebSocket 重连
  • App 回前台

2. /game/status/events:补回漏掉的事件

这个接口按 last_event_id 拉增量事件。

它的意义很直接:WebSocket 期间漏掉了什么,就从这里补回来。

到这一步,前端的处理模型才算完整:

text 复制代码
WS 实时推送 + pending 当前态快照 + status/events 增量补拉

这也是我后来跟前端沟通时反复强调的一点:不能再只盯着 WebSocket 了。

七、Worker 这边,真正重要的是恢复能力

任务入了 Redis Stream 之后,后面就是 worker 消费。

这里我最后做的一个很重要的决定,是把旧的 db polling worker 完全收掉,只保留 Redis Stream + WebSocket 这条主链路。

原因很简单:一套业务同时跑两条异步链路,排查问题的时候会非常痛苦。你看到的现象可能是一样的,但根因完全不同。

1. 正常消费

Worker 这边就是标准的 Redis Stream Consumer Group 模式:

  • XREADGROUP
  • 拿消息
  • 执行业务
  • 完成后 XACK

2. pending 恢复

这一步是真正让我觉得 Redis Stream 值得用的地方。

如果 worker 执行中挂了,消息可能已经被读走,但还没 ACK。这个时候,如果没有恢复机制,这条任务就会变成很麻烦的悬挂状态。

所以我加了:

  • XPENDING
  • XAUTOCLAIM

这样服务重启后,新 worker 可以重新认领那些长时间没确认的消息。

至少不会出现"机器一重启,处理中任务全靠运气"这种情况。

3. queued -> generating 要带条件切换

异步消费里还有一个很典型的问题:同一任务被重复消费。

所以我后面把 queued -> generating 改成了带状态条件的切换。只有当前任务还是 queued,它才允许进入 generating

这一步不复杂,但很有必要。很多异步系统的问题,不是代码没写,而是缺少这种看起来很小的状态保护。

八、真正费时间的,不是跑通,而是补边界

如果只看 happy path,异步生成这套东西并不算难。难的是后面那些你一开始不一定会想到,但上线后迟早会撞到的问题。

1. 入队失败后的脏 queued

最开始的版本里,只要任务记录创建成功,它在数据库里就已经是 queued

如果这时候 Redis 入队失败,就会出现一种很尴尬的情况:

  • 数据库里看着它在排队
  • 实际上它根本没进队列

这类数据最麻烦的地方在于,它不是直接报错,而是"看起来没问题,实际上永远不会动"。

后来我做了两件事:

  1. 把"创建任务记录 + 首条 queued 事件"收进同一个事务
  2. 如果提交阶段 Redis 入队失败,就直接把任务收口成 failed

这样至少不会长期留下那种假排队记录。

2. 同一用户并行任务数量限制

这个问题一开始看着像产品规则,后面做着做着发现其实也是系统保护。

如果不限制,一个用户完全可以连续点很多次提交,最后你会发现:

  • 队列被打满
  • 前端页面一堆进行中任务
  • 排查体验也会变差

所以我后面加了一个限制:

同一用户 queued + generating 总数达到上限时,不允许继续提交。

这个限制很朴素,但非常有必要。

3. 重试不能靠 goroutine 睡眠

任务失败后,第一反应很容易是:

  • sleep 一段时间
  • 再重新塞回队列

但这种做法有个大问题:服务一重启,睡眠中的重试就没了。

所以我后来把重试做成了持久化调度:

  • 失败后写 next_retry_at
  • 后台扫描器定时找出到期任务
  • 到点重新入队

这样即使服务重启,重试计划也不会跟着丢。

4. generating 不能无限挂着

长任务最怕的就是卡死。

比如:

  • 下游工作流超时
  • 外部依赖一直不返回
  • 某次执行过程异常中断

如果不管它,这类任务会一直停在 generating,前端也会一直以为它还在跑。

所以我后面给任务加了 timeout_at,再配一个定时扫描:

  • 超时且还能重试,就回退到 queued
  • 超时且重试次数用完了,就标记成 failed

这一步做完之后,整个状态机才算闭环。

九、状态更新和事件写入,一定要一起成功

引入 game_status_event 之后,我很快又碰到一个更深的问题:状态和事件有可能不同步。

比如:

  • 数据库状态更新成功了,但事件写入失败
  • 事件写进去了,但状态更新没成功

这类问题最烦的地方在于,前后端都会被误导。前端现在开始依赖:

  • status_version
  • event_id
  • /game/status/events

如果状态和事件不一致,前端就很难恢复出正确状态。

所以后面我做的最关键的一步,就是把这些主链路都收进事务:

  • queued -> generating
  • generating -> success
  • generating -> failed
  • generating -> queued(重试重新入队)
  • timeout -> failed
  • 创建任务 + 首条 queued 事件

原则只有一句话:

一次真实状态变化,状态和对应事件必须一起提交。

WebSocket 推送还是放在事务外,但没关系,前提是数据库里已经有了统一真相。

这一步做完之后,整个方案的稳定性一下子就上来了。

十、前端这边,后面也得换个思路

这套方案落地后,前端不能再用"收到一条 WebSocket 就直接改状态"这种很轻的处理方式了。

后面我们对齐的核心点其实就三条:

1. record_id 是任务唯一标识

收到相同 record_id 的新消息,不是新增一条,而是更新原任务。

2. status_version 用来防重复和防乱序

同一个任务,只处理版本更大的消息。

3. event_id 用来补拉漏消息

前端需要记住 last_event_id,重连后通过 /game/status/events 把漏掉的事件补回来。

最后前端那边真正可用的处理模式应该是:

text 复制代码
页面进入 / WS 重连
    ->
先调 /game/pending 校准当前态
    ->
再调 /game/status/events 补拉漏掉的事件
    ->
后续继续通过 WS 收实时更新

这一步做完之后,断网重连、重复消息、乱序消息这些问题,才算真正有了稳妥的解法。

十一、单实例部署下,这套方案够不够用

这个问题我后面也想得比较多。

如果项目当前是:

  • 单服务器
  • 单进程部署

那这套 Redis Stream + WebSocket + 事件补偿 的方案,已经能解决大部分实际问题:

  • 异步执行
  • 实时状态推送
  • pending 恢复
  • 重试调度
  • 超时恢复
  • 断线重连
  • 消息补拉
  • 防重复、防乱序

对单实例场景来说,它已经比较够用了。

当然,它也不是一点缺口都没有。比如:

  • MySQL 事务提交成功后,Redis 入队之前还有一个很小的窗口
  • 如果以后扩成多实例,WebSocket 跨节点推送又会是新问题

但如果当前目标是单实例上线和稳定交付,这套设计已经有不错的投入产出比。

十二、这次实现里几个印象很深的坑

1. 环境配置不一致,表面上像代码问题

有一段时间最困扰我的现象是:

  • 本地能收到 queued / generating / success
  • 远端却只能收到两条

一开始很容易怀疑是 WebSocket 推送链路有问题,最后查下来根因其实是配置不一致:

  • 本地配置了 game_async
  • 远端没有,所以还在走旧 worker

这件事给我的提醒很直接:异步系统里,如果主链路没有先收敛,后面很多问题看起来都像"偶发 bug"。

2. Stream 和 Group 配错位置,现象会非常怪

还有一次更隐蔽:

  • 本地和测试环境共用了同一套 Stream / Consumer Group
  • 结果任务顺序乱了,状态推送也不稳定

最后发现不是业务逻辑有问题,而是配置写错了位置,多个环境实际上在消费同一条流。

这种问题很像灵异事件,排查起来非常浪费时间。

3. MySQL JSON 列不接受空字符串

事件表里 payload_json 一开始是 JSON 列,但我在一些状态事件里传了空字符串,结果 MySQL 直接报错。

最后我改成了:

  • PayloadJSON*string
  • 只有真正有内容时才写
  • 没有就保持 NULL

这类问题很小,但它会直接影响事件链路是否完整,所以也不能忽略。

十三、这套方案真正带来的价值

回头看,这次实现最有价值的地方,不是"终于把任务丢进 Redis 了",而是把一套异步功能补成了真正能上线用的样子。

它至少回答了这些问题:

  • 任务怎么异步执行:Redis Stream + Worker
  • 状态怎么实时推给前端:WebSocket
  • 服务重启后任务怎么办:pending 认领恢复 + 持久化重试
  • 任务卡住怎么办:超时恢复
  • WebSocket 漏消息怎么办:事件表 + 补拉接口
  • 重复消息和乱序怎么办:record_id + status_version
  • 状态和事件不一致怎么办:事务化收口

把这些拼起来,它就不只是一个异步功能,而是一套比较完整的异步状态同步方案。

十四、最终效果怎么样

这套链路收完之后,至少在我当前这个单服务器、单实例部署的项目里,效果已经比较稳定了:

  • 前端可以实时收到 queued / generating / success / failed
  • WebSocket 断线后,不再只能靠运气恢复状态
  • 服务重启后,pending 消息可以重新认领
  • 失败任务可以按计划重试,而不是靠内存里的 sleep 硬撑
  • 长时间挂在 generating 的任务,后面也能自动恢复或收口
  • 状态更新和事件写入已经做了事务化收口,前后端看到的状态会更一致

至少对这个项目当前的阶段来说,它已经从"能跑通"变成了"比较敢上线"。

十五、后面还能如果扩展

如果后面继续往更稳、更偏生产级的方向做,我觉得还可以继续补这几块。

1. 漏入队补偿

现在最小的窗口在:

  • 数据库事务已经提交
  • Redis 还没来得及入队

这个概率不高,但不是零。后面可以加补偿扫描,把这类极小概率问题也兜住。

2. 多实例 WebSocket 跨节点推送

如果以后扩成多实例部署,用户连接可能在实例 A,任务消费可能在实例 B,这时就不能只靠本机内存 Hub 了,需要有跨实例的推送总线。

3. 运维告警和监控

再往前走一步,就应该把下面这些监控补上:

  • 长时间停留在 queued 的任务
  • 长时间停留在 generating 的任务
  • Redis pending 堆积
  • 重试次数异常

这些不会改变业务代码,但能显著降低线上排查成本。

十六、总结

这次做完之后,我最大的感受是:异步系统真正难的地方,从来不是把流程串起来,而是把那些"平时不常出、出了就很难受"的边界一个个补上。

只想把功能做出来,异步 + 推送 差不多就够了。

但如果想让它真的在业务里稳稳跑起来,就绕不过这些东西:

  • 状态机
  • 事件流
  • 恢复能力
  • 重试机制
  • 超时处理
  • 前后端协作
  • 事务化收口

这也是我后来越来越认同的一点:

一个异步系统靠得住,不是因为用了 Redis、WebSocket 这种组件,而是因为每个环节都想清楚了它出问题时该怎么收。

如果后面还有时间,我也会继续把这套链路往更完整的方向收,比如补漏入队补偿、多实例推送,以及更细的监控和告警。

但至少到现在,这套 Redis Stream + WebSocket 的方案,已经让我对这个"异步生成游戏"功能的上线更有底了。

相关推荐
dong__csdn2 小时前
websocket实现简单的单聊、群聊demo
网络·websocket·网络协议
闪电悠米2 小时前
黑马点评-秒杀优化-02_lua_precheck
开发语言·redis·分布式·缓存·junit·wpf·lua
至天3 小时前
FastAPI 接入 FastAPI-Limiter 以及使用 Redis 进行限流指南
redis·python·fastapi·请求限流
真实的菜3 小时前
Redis 从入门到精通(三):持久化机制 —— RDB 与 AOF 深度解析
数据库·redis·缓存
橙子圆1233 小时前
Redis知识10之缓存
数据库·redis·缓存
情绪总是阴雨天~3 小时前
基于 Docker 的 Milvus + Redis 本地开发环境部署完全指南
redis·docker·milvus
我是一颗柠檬3 小时前
【Redis】Redis缓存应用实战Day12(2026年)
数据库·redis·缓存
zzz_23683 小时前
【Redis】Redis 面试深度系列
数据库·redis·面试
Solis程序员3 小时前
解决双写不一致!Canal+Outbox+Kafka 高可靠事件驱动架构
redis·分布式·架构·kafka·canal