一个异步生成游戏功能的落地复盘:Redis Stream + WebSocket + 状态补偿
前言
最近在做一个项目,里面有个调用扣子的工作流去生成小游戏的功能,要做成异步生成的形式,原本以为就是把接口改成异步就差不多了,真正做起来才发现,这类功能麻烦的从来不是"怎么调用 AI",而是异步链路怎么做得稳。
这个功能看起来很普通:前端提交参数,后端生成一个游戏,最后把结果返回给用户。但一次生成经常要跑几分钟,前端不可能一直傻等,用户还会断线、刷新、重复提交,服务也不可能保证永远不重启。
联调一段时间后,问题陆续暴露出来了:
- 纯轮询体验差,状态变化不够及时
- 只靠 WebSocket,断线重连后很容易漏消息
- 服务重启后,正在执行中的任务需要恢复
- 任务失败后不能只是简单报错,很多时候还要能重试
- 前端拿到重复消息、乱序消息时,状态可能被旧消息覆盖
所以后面我做的就不只是"把生成逻辑搬到后台"这么简单,而是围绕这个功能补了一整套异步状态同步能力:
- 用 Redis Stream 做任务队列
- 用 WebSocket 做实时状态推送
- 用 MySQL 落任务状态和事件流
- 用
status_version处理重复消息和乱序消息 - 用
/game/pending和/game/status/events做断线后的状态恢复
这篇文章就按真实落地过程,把这套链路是怎么一步步收敛出来的、过程中踩过哪些坑,以及最后为什么会落到 Redis Stream + WebSocket + 状态补偿 这个方案,完整复盘一下。
一、这个问题到底难在哪
游戏生成这个功能,业务目标其实很直接:
- 用户提交一组参数
- 后端异步生成游戏
- 前端能持续看到任务状态
- 最终拿到成功结果,或者明确知道失败原因
真正麻烦的是周边这些问题:
- 任务耗时长,接口不能一直同步阻塞
- 前端不能只看到"提交成功",还得知道它现在在排队还是在生成
- 服务重启后,不能把处理中任务直接搞丢
- 用户断线再回来时,前端得把状态补回来
- WebSocket 消息可能重复,也可能乱序
- 失败任务不能一挂到底,很多时候还要能重试
说白了,这里真正要做的不是一个"生成接口",而是一套围绕异步任务的状态同步机制。
二、改造前我实际遇到过哪些问题
这部分我想单独提出来说一下,因为很多方案文章只写设计,不写真实问题。实际推动我把这条链路补完整的,恰恰是这些联调时遇到的现象:
- 本地能收到
queued / generating / success三条消息,测试环境却只收到两条 - 前端明明已经提交成功了,但后续状态没继续往下走
- 某个任务数据库里已经变成
generating,前端却没收到对应推送 - 页面断线重连之后,中间那段状态变化完全丢失
- 任务失败后只是短暂报错,服务一重启,原本计划中的重试也没了
- 有些任务会一直卡在
generating,看上去像在执行,实际上已经挂住了
这些问题单看都不算复杂,但一旦叠在一起,就会发现:这已经不是"异步执行"的问题,而是"异步任务怎么同步、怎么恢复、怎么兜底"的问题
三、为什么最后选了 Redis Stream + WebSocket
1. 纯轮询能做,但体验确实一般
最容易想到的做法其实很朴素:
- 提交任务
- 返回任务 ID
- 前端每隔几秒查一次状态
这套做法不是不能用,但问题也很明显:
- 状态变化不够及时
- 轮询太频繁会浪费资源
- 轮询太慢,用户会觉得页面像卡住了一样
对于"排队中 -> 生成中 -> 成功/失败"这种状态链路,实时推送的体验明显更合适。
2. 进程内异步太轻,但不够稳
另一种很省事的做法,是在 Go 服务里直接起 goroutine 处理。
开发阶段这么写当然快,但只要想上线,很快就会碰到问题:
- 服务一重启,内存里的任务就没了
- 很难恢复那些执行到一半的任务
- 重试和超时恢复都不太好做
所以我一开始就没有把进程内内存队列当成最终方案。
3. Redis Stream 适合这个体量
这个项目本身不是特别重型的消息系统场景,没有必要一上来就 Kafka、RabbitMQ 全套拉满。Redis Stream 对我来说是一个比较合适的折中:
- 接入成本低
- 性能足够
- 支持 Consumer Group
- 支持 ACK
- 支持
XPENDING/XAUTOCLAIM - 出问题时也有能力做 pending 恢复
它很适合拿来做这种"异步任务执行通道"。
4. WebSocket 负责把状态尽快推给前端
Redis Stream 解决的是"任务怎么异步执行",不是"前端怎么实时看到变化"。
所以我把状态通知这层交给了 WebSocket:
- 任务受理后推
queued - worker 真正开始执行时推
generating - 执行结束后推
success或failed
这样前端就不用一直主动轮询了。
最后整个主链路大概长这样:
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:负责事件补拉
这一点我很有感触:异步链路一旦职责混在一起,后面出问题会特别难排查;但只要边界够清楚,很多问题其实都能落到某一层去解决。
五、状态机一定要先想清楚
我这块最后保留的状态并不多,就四个:
queuedgeneratingsuccessfailed
状态少一点不是坏事。异步系统里,状态多未必代表设计得好,很多时候反而会让前后端都更难维护。
真正关键的是两点:
- 状态切换边界是否清晰
- 前端能不能稳定感知到这些变化
为了把第二件事做好,我后来又补了两个很关键的东西:
status_versiongame_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
这个变化其实是被线上表现逼出来的。
最早链路跑通的时候,本地看起来没什么问题:
- 提交任务
- 收到
queued - 收到
generating - 收到
success
但到了联调和测试环境,问题很快就出来了:
- 有时前端只收到了入队消息
- 有时
generating没收到 - 页面断线重连后,中间的状态变化完全没了
这时候我才意识到,只有 WebSocket 实时推送是远远不够的。
所以后来我补了两类接口。
1. /game/pending:拿当前态快照
这个接口只干一件事:返回当前还在进行中的任务。
也就是说,它只关心:
queuedgenerating
它不是给前端看历史的,而是用来在这些时机快速纠偏:
- 页面进入
- 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。这个时候,如果没有恢复机制,这条任务就会变成很麻烦的悬挂状态。
所以我加了:
XPENDINGXAUTOCLAIM
这样服务重启后,新 worker 可以重新认领那些长时间没确认的消息。
至少不会出现"机器一重启,处理中任务全靠运气"这种情况。
3. queued -> generating 要带条件切换
异步消费里还有一个很典型的问题:同一任务被重复消费。
所以我后面把 queued -> generating 改成了带状态条件的切换。只有当前任务还是 queued,它才允许进入 generating。
这一步不复杂,但很有必要。很多异步系统的问题,不是代码没写,而是缺少这种看起来很小的状态保护。
八、真正费时间的,不是跑通,而是补边界
如果只看 happy path,异步生成这套东西并不算难。难的是后面那些你一开始不一定会想到,但上线后迟早会撞到的问题。
1. 入队失败后的脏 queued
最开始的版本里,只要任务记录创建成功,它在数据库里就已经是 queued。
如果这时候 Redis 入队失败,就会出现一种很尴尬的情况:
- 数据库里看着它在排队
- 实际上它根本没进队列
这类数据最麻烦的地方在于,它不是直接报错,而是"看起来没问题,实际上永远不会动"。
后来我做了两件事:
- 把"创建任务记录 + 首条 queued 事件"收进同一个事务
- 如果提交阶段 Redis 入队失败,就直接把任务收口成
failed
这样至少不会长期留下那种假排队记录。
2. 同一用户并行任务数量限制
这个问题一开始看着像产品规则,后面做着做着发现其实也是系统保护。
如果不限制,一个用户完全可以连续点很多次提交,最后你会发现:
- 队列被打满
- 前端页面一堆进行中任务
- 排查体验也会变差
所以我后面加了一个限制:
同一用户
queued + generating总数达到上限时,不允许继续提交。
这个限制很朴素,但非常有必要。
3. 重试不能靠 goroutine 睡眠
任务失败后,第一反应很容易是:
- sleep 一段时间
- 再重新塞回队列
但这种做法有个大问题:服务一重启,睡眠中的重试就没了。
所以我后来把重试做成了持久化调度:
- 失败后写
next_retry_at - 后台扫描器定时找出到期任务
- 到点重新入队
这样即使服务重启,重试计划也不会跟着丢。
4. generating 不能无限挂着
长任务最怕的就是卡死。
比如:
- 下游工作流超时
- 外部依赖一直不返回
- 某次执行过程异常中断
如果不管它,这类任务会一直停在 generating,前端也会一直以为它还在跑。
所以我后面给任务加了 timeout_at,再配一个定时扫描:
- 超时且还能重试,就回退到
queued - 超时且重试次数用完了,就标记成
failed
这一步做完之后,整个状态机才算闭环。
九、状态更新和事件写入,一定要一起成功
引入 game_status_event 之后,我很快又碰到一个更深的问题:状态和事件有可能不同步。
比如:
- 数据库状态更新成功了,但事件写入失败
- 事件写进去了,但状态更新没成功
这类问题最烦的地方在于,前后端都会被误导。前端现在开始依赖:
status_versionevent_id/game/status/events
如果状态和事件不一致,前端就很难恢复出正确状态。
所以后面我做的最关键的一步,就是把这些主链路都收进事务:
queued -> generatinggenerating -> successgenerating -> failedgenerating -> 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 的方案,已经让我对这个"异步生成游戏"功能的上线更有底了。