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

发布时间:2026/6/6 8:55:30

一个异步生成游戏功能的落地复盘:Redis Stream + WebSocket + 状态补偿 一个异步生成游戏功能的落地复盘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 WebSocket1. 纯轮询能做但体验确实一般最容易想到的做法其实很朴素提交任务返回任务 ID前端每隔几秒查一次状态这套做法不是不能用但问题也很明显状态变化不够及时轮询太频繁会浪费资源轮询太慢用户会觉得页面像卡住了一样对于排队中 - 生成中 - 成功/失败这种状态链路实时推送的体验明显更合适。2. 进程内异步太轻但不够稳另一种很省事的做法是在 Go 服务里直接起 goroutine 处理。开发阶段这么写当然快但只要想上线很快就会碰到问题服务一重启内存里的任务就没了很难恢复那些执行到一半的任务重试和超时恢复都不太好做所以我一开始就没有把进程内内存队列当成最终方案。3. Redis Stream 适合这个体量这个项目本身不是特别重型的消息系统场景没有必要一上来就 Kafka、RabbitMQ 全套拉满。Redis Stream 对我来说是一个比较合适的折中接入成本低性能足够支持 Consumer Group支持 ACK支持XPENDING/XAUTOCLAIM出问题时也有能力做 pending 恢复它很适合拿来做这种异步任务执行通道。4. WebSocket 负责把状态尽快推给前端Redis Stream 解决的是任务怎么异步执行不是前端怎么实时看到变化。所以我把状态通知这层交给了 WebSocket任务受理后推queuedworker 真正开始执行时推generating执行结束后推success或failed这样前端就不用一直主动轮询了。最后整个主链路大概长这样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负责事件补拉这一点我很有感触异步链路一旦职责混在一起后面出问题会特别难排查但只要边界够清楚很多问题其实都能落到某一层去解决。五、状态机一定要先想清楚我这块最后保留的状态并不多就四个queuedgeneratingsuccessfailed状态少一点不是坏事。异步系统里状态多未必代表设计得好很多时候反而会让前后端都更难维护。真正关键的是两点状态切换边界是否清晰前端能不能稳定感知到这些变化为了把第二件事做好我后来又补了两个很关键的东西status_versiongame_status_event1.status_version前端别被旧消息覆盖了WebSocket 用起来很方便但它不是一个绝对有序、绝对不重复的通道。前端如果只拿着一条消息就直接覆盖状态很容易出问题。所以我给每个任务加了status_version创建任务时初始化为1每次真实状态变化都递增前端只需要记住一条规则同一个record_id只处理版本更大的消息。这样重复消息、乱序消息这些问题基本就被挡住了。2.game_status_eventWS 丢了还能补只靠 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 期间漏掉了什么就从这里补回来。到这一步前端的处理模型才算完整WS 实时推送 pending 当前态快照 status/events 增量补拉这也是我后来跟前端沟通时反复强调的一点不能再只盯着 WebSocket 了。七、Worker 这边真正重要的是恢复能力任务入了 Redis Stream 之后后面就是 worker 消费。这里我最后做的一个很重要的决定是把旧的db polling worker完全收掉只保留Redis Stream WebSocket这条主链路。原因很简单一套业务同时跑两条异步链路排查问题的时候会非常痛苦。你看到的现象可能是一样的但根因完全不同。1. 正常消费Worker 这边就是标准的 Redis Stream Consumer Group 模式XREADGROUP拿消息执行业务完成后XACK2. 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把漏掉的事件补回来。最后前端那边真正可用的处理模式应该是页面进入 / 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 / failedWebSocket 断线后不再只能靠运气恢复状态服务重启后pending 消息可以重新认领失败任务可以按计划重试而不是靠内存里的 sleep 硬撑长时间挂在generating的任务后面也能自动恢复或收口状态更新和事件写入已经做了事务化收口前后端看到的状态会更一致至少对这个项目当前的阶段来说它已经从能跑通变成了比较敢上线。十五、后面还能如果扩展如果后面继续往更稳、更偏生产级的方向做我觉得还可以继续补这几块。1. 漏入队补偿现在最小的窗口在数据库事务已经提交Redis 还没来得及入队这个概率不高但不是零。后面可以加补偿扫描把这类极小概率问题也兜住。2. 多实例 WebSocket 跨节点推送如果以后扩成多实例部署用户连接可能在实例 A任务消费可能在实例 B这时就不能只靠本机内存 Hub 了需要有跨实例的推送总线。3. 运维告警和监控再往前走一步就应该把下面这些监控补上长时间停留在queued的任务长时间停留在generating的任务Redis pending 堆积重试次数异常这些不会改变业务代码但能显著降低线上排查成本。十六、总结这次做完之后我最大的感受是异步系统真正难的地方从来不是把流程串起来而是把那些平时不常出、出了就很难受的边界一个个补上。只想把功能做出来异步 推送差不多就够了。但如果想让它真的在业务里稳稳跑起来就绕不过这些东西状态机事件流恢复能力重试机制超时处理前后端协作事务化收口这也是我后来越来越认同的一点一个异步系统靠得住不是因为用了 Redis、WebSocket 这种组件而是因为每个环节都想清楚了它出问题时该怎么收。如果后面还有时间我也会继续把这套链路往更完整的方向收比如补漏入队补偿、多实例推送以及更细的监控和告警。但至少到现在这套Redis Stream WebSocket的方案已经让我对这个异步生成游戏功能的上线更有底了。

相关新闻