一个异步生成游戏功能的落地复盘:Redis Stream + WebSocket + 状态补偿
2026/6/6 8:55:12 网站建设 项目流程

一个异步生成游戏功能的落地复盘: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

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

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

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

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

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

客户端提交生成请求 -> 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 期间漏掉了什么,就从这里补回来。

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

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把漏掉的事件补回来。

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

页面进入 / 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的方案,已经让我对这个"异步生成游戏"功能的上线更有底了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询