- 本文来自《后端系统设计复盘:从游戏项目到通用后端》专栏。
- 文章内容源于我正在实现的一个 Balatro 风格实时游戏后端项目,但本文不会重点讲某个具体游戏功能怎么写,而是抽出一个更通用的后端设计问题:为什么一个后端接口最开始看起来只是功能调用,但做着做着就会变成状态流转。
- 这个问题不只存在于游戏后端,在电商下单、活动奖励、审批流程、任务系统里也很常见。
这篇文章想表达的核心观点是:
- 后端接口一开始看起来像是在"执行功能",但只要这个操作会影响后续流程,它就会逐渐变成一次"状态推进"。
- 状态流转型接口的重点,不只是本次请求算对了没有,而是请求结束后,系统是否进入了正确状态。
- 后端复杂度不是突然出现的,而是从状态开始互相影响、接口开始推动流程时慢慢出现的。
文章目录
- 一、这个问题是怎么出现的
- 二、功能接口为什么一开始看起来很简单
- 三、从"算结果"到"改状态"
- 四、一次操作为什么会牵扯多个状态
- 五、功能接口和状态流转的区别
- 六、为什么状态流转会让后端变复杂
-
- [1. 同一个请求,在不同状态下结果不同](#1. 同一个请求,在不同状态下结果不同)
- [2. 一个状态更新,可能影响多个后续判断](#2. 一个状态更新,可能影响多个后续判断)
- [3. 状态没有及时更新,会导致后续流程错误](#3. 状态没有及时更新,会导致后续流程错误)
- 七、状态流转需要一个清晰的处理顺序
- 八、状态流转里,返回结果不只是"提示成功"
- 九、从功能接口到状态流转的通用场景
-
- [1. 电商下单](#1. 电商下单)
- [2. 活动奖励领取](#2. 活动奖励领取)
- [3. 审批流程](#3. 审批流程)
- 十、我现在对后端复杂度的理解
-
- [1. 复杂度不是来自代码多,而是来自状态互相影响](#1. 复杂度不是来自代码多,而是来自状态互相影响)
- [2. 状态流转型接口设计检查表](#2. 状态流转型接口设计检查表)
- 十一、这次复盘得到的设计结论
-
- [1. 后端接口不只是函数入口](#1. 后端接口不只是函数入口)
- [2. 状态流转要先于代码实现](#2. 状态流转要先于代码实现)
- [3. 前端不应该推导关键状态](#3. 前端不应该推导关键状态)
- [4. 返回结果应该表达最新状态](#4. 返回结果应该表达最新状态)
- 十二、本篇小结
一、这个问题是怎么出现的
- 下面我会用一次游戏操作举例,但这里讨论的不是游戏特有问题,而是很多后端系统都会遇到的"接口从功能调用变成状态推进"的问题。
- 在电商系统里,它可能是一次下单;在活动系统里,它可能是一次奖励领取;在审批系统里,它可能是一次提交审批。表面上看只是一个接口,实际却会带动一串状态变化。
在项目早期设计这些接口时,我并不是没有意识到后端接口会维护状态,只是当时更多把它们当一个个具体功能来拆分。
比如有一个功能,就先写一个对应的接口:
- 发牌:返回一组手牌
- 出牌:接收用户选择的牌
- 弃牌:移除用户选择的牌
- 计分:根据牌型算出分数
这里先简单解释一下几个游戏相关概念,避免读者被游戏名词劝退。
| 项目里的概念 | 可以理解成 | 普通业务里的类比 |
|---|---|---|
| 手牌 | 当前用户可操作的数据集合 | 购物车商品、待处理任务、当前审批单 |
| 牌堆 | 后续可补充的数据来源 | 库存池、任务池、候选资源池 |
| 出牌 | 用户提交一次有效操作 | 下单、领取、提交审批 |
| 弃牌 | 用户放弃或移除一部分内容 | 删除商品、取消任务、撤回选择 |
| 计分 | 根据规则计算本次结果 | 计算金额、发放奖励、判断审批结果 |
在最早期,每个功能看起来都比较独立,前端调用一下,后端处理一下,然后返回结果。
这种感觉很像:请求进来 -> 执行函数 -> 返回结果
但随着项目往后推进,我慢慢发现事情不是这么简单。因为有些接口执行完之后,并不是"返回结果就结束了",它还会改变后续流程。
比如一次出牌操作,表面看只是:
用户选择一部分当前可操作的数据,后端判断这次操作会产生什么结果。
但真正放到一个持续运行的系统里,它其实会影响很多状态:
- 当前手牌会减少
- 牌堆会被继续抽取
- 剩余出牌次数会减少
- 当前操作分数会变化
- 累计分数会变化
- 当前阶段是否结束也可能变化
也就是说,这个接口已经不只是"算一个结果",而是在推动系统状态往前走。
这时候我才意识到:
后端系统复杂度不是从代码行数开始的,而是从"一个操作会影响后续状态"开始的。
二、功能接口为什么一开始看起来很简单
在项目早期,接口通常都比较像一个独立函数。
比如一个牌型判断接口,输入几张牌,返回一个牌型结果:
json
输入:["AH", "KH", "QH", "JH", "10H"]
输出:Royal Flush
这个过程里,服务端并不需要关心太多上下文。
它只需要回答一个问题:
这几张牌是什么牌型?
这种接口的特点很明显。
| 特点 | 说明 |
|---|---|
| 输入清楚 | 接口需要什么参数比较明确 |
| 输出清楚 | 返回结果比较稳定 |
| 不依赖历史状态 | 不太关心用户之前做过什么 |
| 不影响后续流程 | 调用完成后,系统状态通常不会推进 |
| 测试成本低 | 相同输入通常得到相同输出 |
所以它很像一个纯计算函数。
这种功能很好写,也很好测试,因为只要保证:相同输入 -> 得到相同输出,基本就可以了。
但业务系统不可能永远停留在这种状态。
一旦接口开始依赖"当前用户处于什么状态",并且执行之后还会改变这个状态,它就不再只是一个普通的功能接口,而是开始进入状态流转了。
三、从"算结果"到"改状态"
真正让接口复杂起来的,不是参数变多了,而是它开始修改状态了。
比如发牌,如果只是单独看发牌,好像就是:
从牌堆里取几张牌返回给前端。
但如果服务端要维护真实牌堆,那发牌就不是简单地返回几张牌,而是一次状态变更。
它实际做的是:牌堆减少 -> 用户手牌增加 -> 服务端保存新的牌堆状态。
也就是说,发牌的本质不是"返回数组",而是:
修改服务端维护的运行时状态。
出牌也是一样。
如果只从接口入参看,出牌可能就是:
json
{
"selectedCards": ["AH", "KH"],
"action": "play"
}
但服务端真正要做的事情远不止这些。
它要判断:
- 这些牌是否真的在当前手牌中
- 当前用户是否还有出牌次数
- 当前流程是否还在进行中
- 这次操作是否需要计分
- 出牌后是否需要补牌
- 补牌后手牌状态是什么
- 当前分数是否达到目标
- 当前流程是否应该结束
这时候,接口已经不再是:输入参数 -> 返回结果。
而是变成了:读取当前状态 -> 校验当前操作 -> 执行业务规则 -> 更新运行时状态 -> 判断流程推进 -> 返回最新状态
这就是从功能接口到状态流转的变化:
接口不再只是处理一次请求,而是在读取状态、校验状态、修改状态,并推动系统进入下一个状态。
#mermaid-svg-FP9XeWQFZufq8z8E{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-FP9XeWQFZufq8z8E .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-FP9XeWQFZufq8z8E .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-FP9XeWQFZufq8z8E .error-icon{fill:#552222;}#mermaid-svg-FP9XeWQFZufq8z8E .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-FP9XeWQFZufq8z8E .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-FP9XeWQFZufq8z8E .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-FP9XeWQFZufq8z8E .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-FP9XeWQFZufq8z8E .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-FP9XeWQFZufq8z8E .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-FP9XeWQFZufq8z8E .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-FP9XeWQFZufq8z8E .marker{fill:#333333;stroke:#333333;}#mermaid-svg-FP9XeWQFZufq8z8E .marker.cross{stroke:#333333;}#mermaid-svg-FP9XeWQFZufq8z8E svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-FP9XeWQFZufq8z8E p{margin:0;}#mermaid-svg-FP9XeWQFZufq8z8E .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-FP9XeWQFZufq8z8E .cluster-label text{fill:#333;}#mermaid-svg-FP9XeWQFZufq8z8E .cluster-label span{color:#333;}#mermaid-svg-FP9XeWQFZufq8z8E .cluster-label span p{background-color:transparent;}#mermaid-svg-FP9XeWQFZufq8z8E .label text,#mermaid-svg-FP9XeWQFZufq8z8E span{fill:#333;color:#333;}#mermaid-svg-FP9XeWQFZufq8z8E .node rect,#mermaid-svg-FP9XeWQFZufq8z8E .node circle,#mermaid-svg-FP9XeWQFZufq8z8E .node ellipse,#mermaid-svg-FP9XeWQFZufq8z8E .node polygon,#mermaid-svg-FP9XeWQFZufq8z8E .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-FP9XeWQFZufq8z8E .rough-node .label text,#mermaid-svg-FP9XeWQFZufq8z8E .node .label text,#mermaid-svg-FP9XeWQFZufq8z8E .image-shape .label,#mermaid-svg-FP9XeWQFZufq8z8E .icon-shape .label{text-anchor:middle;}#mermaid-svg-FP9XeWQFZufq8z8E .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-FP9XeWQFZufq8z8E .rough-node .label,#mermaid-svg-FP9XeWQFZufq8z8E .node .label,#mermaid-svg-FP9XeWQFZufq8z8E .image-shape .label,#mermaid-svg-FP9XeWQFZufq8z8E .icon-shape .label{text-align:center;}#mermaid-svg-FP9XeWQFZufq8z8E .node.clickable{cursor:pointer;}#mermaid-svg-FP9XeWQFZufq8z8E .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-FP9XeWQFZufq8z8E .arrowheadPath{fill:#333333;}#mermaid-svg-FP9XeWQFZufq8z8E .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-FP9XeWQFZufq8z8E .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-FP9XeWQFZufq8z8E .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-FP9XeWQFZufq8z8E .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-FP9XeWQFZufq8z8E .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-FP9XeWQFZufq8z8E .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-FP9XeWQFZufq8z8E .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-FP9XeWQFZufq8z8E .cluster text{fill:#333;}#mermaid-svg-FP9XeWQFZufq8z8E .cluster span{color:#333;}#mermaid-svg-FP9XeWQFZufq8z8E div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-FP9XeWQFZufq8z8E .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-FP9XeWQFZufq8z8E rect.text{fill:none;stroke-width:0;}#mermaid-svg-FP9XeWQFZufq8z8E .icon-shape,#mermaid-svg-FP9XeWQFZufq8z8E .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-FP9XeWQFZufq8z8E .icon-shape p,#mermaid-svg-FP9XeWQFZufq8z8E .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-FP9XeWQFZufq8z8E .icon-shape .label rect,#mermaid-svg-FP9XeWQFZufq8z8E .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-FP9XeWQFZufq8z8E .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-FP9XeWQFZufq8z8E .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-FP9XeWQFZufq8z8E :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 状态流转
功能接口
允许
拒绝
开始依赖状态
输入参数
执行计算
返回结果
读取当前状态
判断是否允许
执行业务规则
更新多个状态
返回最新状态
返回失败原因
四、一次操作为什么会牵扯多个状态
一开始按功能拆接口时,很容易把一个接口理解成一个独立动作。
比如:
play就是出牌discard就是弃牌draw就是补牌score就是算分
但实际写下去会发现,这些动作不是完全独立的。
一次出牌,至少包含下面几件事:
选择手牌 -> 校验选择是否合法 -> 移除已选择的牌 -> 从牌堆补牌 -> 计算本次分数 -> 扣除出牌次数 -> 累加总分 -> 判断是否结束 -> 返回最新状态
真正落到服务端状态维护时,这个动作背后会连出一整串状态变化。
它不是一个点,而是一条链路。
#mermaid-svg-Jj7TZI4U2WCVdrjg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-Jj7TZI4U2WCVdrjg .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-Jj7TZI4U2WCVdrjg .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-Jj7TZI4U2WCVdrjg .error-icon{fill:#552222;}#mermaid-svg-Jj7TZI4U2WCVdrjg .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-Jj7TZI4U2WCVdrjg .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-Jj7TZI4U2WCVdrjg .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-Jj7TZI4U2WCVdrjg .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-Jj7TZI4U2WCVdrjg .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-Jj7TZI4U2WCVdrjg .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-Jj7TZI4U2WCVdrjg .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-Jj7TZI4U2WCVdrjg .marker{fill:#333333;stroke:#333333;}#mermaid-svg-Jj7TZI4U2WCVdrjg .marker.cross{stroke:#333333;}#mermaid-svg-Jj7TZI4U2WCVdrjg svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-Jj7TZI4U2WCVdrjg p{margin:0;}#mermaid-svg-Jj7TZI4U2WCVdrjg .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-Jj7TZI4U2WCVdrjg .cluster-label text{fill:#333;}#mermaid-svg-Jj7TZI4U2WCVdrjg .cluster-label span{color:#333;}#mermaid-svg-Jj7TZI4U2WCVdrjg .cluster-label span p{background-color:transparent;}#mermaid-svg-Jj7TZI4U2WCVdrjg .label text,#mermaid-svg-Jj7TZI4U2WCVdrjg span{fill:#333;color:#333;}#mermaid-svg-Jj7TZI4U2WCVdrjg .node rect,#mermaid-svg-Jj7TZI4U2WCVdrjg .node circle,#mermaid-svg-Jj7TZI4U2WCVdrjg .node ellipse,#mermaid-svg-Jj7TZI4U2WCVdrjg .node polygon,#mermaid-svg-Jj7TZI4U2WCVdrjg .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-Jj7TZI4U2WCVdrjg .rough-node .label text,#mermaid-svg-Jj7TZI4U2WCVdrjg .node .label text,#mermaid-svg-Jj7TZI4U2WCVdrjg .image-shape .label,#mermaid-svg-Jj7TZI4U2WCVdrjg .icon-shape .label{text-anchor:middle;}#mermaid-svg-Jj7TZI4U2WCVdrjg .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-Jj7TZI4U2WCVdrjg .rough-node .label,#mermaid-svg-Jj7TZI4U2WCVdrjg .node .label,#mermaid-svg-Jj7TZI4U2WCVdrjg .image-shape .label,#mermaid-svg-Jj7TZI4U2WCVdrjg .icon-shape .label{text-align:center;}#mermaid-svg-Jj7TZI4U2WCVdrjg .node.clickable{cursor:pointer;}#mermaid-svg-Jj7TZI4U2WCVdrjg .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-Jj7TZI4U2WCVdrjg .arrowheadPath{fill:#333333;}#mermaid-svg-Jj7TZI4U2WCVdrjg .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-Jj7TZI4U2WCVdrjg .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-Jj7TZI4U2WCVdrjg .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-Jj7TZI4U2WCVdrjg .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-Jj7TZI4U2WCVdrjg .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-Jj7TZI4U2WCVdrjg .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-Jj7TZI4U2WCVdrjg .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-Jj7TZI4U2WCVdrjg .cluster text{fill:#333;}#mermaid-svg-Jj7TZI4U2WCVdrjg .cluster span{color:#333;}#mermaid-svg-Jj7TZI4U2WCVdrjg div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-Jj7TZI4U2WCVdrjg .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-Jj7TZI4U2WCVdrjg rect.text{fill:none;stroke-width:0;}#mermaid-svg-Jj7TZI4U2WCVdrjg .icon-shape,#mermaid-svg-Jj7TZI4U2WCVdrjg .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-Jj7TZI4U2WCVdrjg .icon-shape p,#mermaid-svg-Jj7TZI4U2WCVdrjg .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-Jj7TZI4U2WCVdrjg .icon-shape .label rect,#mermaid-svg-Jj7TZI4U2WCVdrjg .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-Jj7TZI4U2WCVdrjg .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-Jj7TZI4U2WCVdrjg .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-Jj7TZI4U2WCVdrjg :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;}#mermaid-svg-Jj7TZI4U2WCVdrjg .action>*{fill:aliceblue!important;stroke:cornflowerblue!important;stroke-width:2px!important;color:black!important;}#mermaid-svg-Jj7TZI4U2WCVdrjg .action span{fill:aliceblue!important;stroke:cornflowerblue!important;stroke-width:2px!important;color:black!important;}#mermaid-svg-Jj7TZI4U2WCVdrjg .action tspan{fill:black!important;}#mermaid-svg-Jj7TZI4U2WCVdrjg .check>*{fill:cornsilk!important;stroke:orange!important;color:black!important;}#mermaid-svg-Jj7TZI4U2WCVdrjg .check span{fill:cornsilk!important;stroke:orange!important;color:black!important;}#mermaid-svg-Jj7TZI4U2WCVdrjg .check tspan{fill:black!important;}#mermaid-svg-Jj7TZI4U2WCVdrjg .resource>*{fill:honeydew!important;stroke:mediumseagreen!important;color:black!important;}#mermaid-svg-Jj7TZI4U2WCVdrjg .resource span{fill:honeydew!important;stroke:mediumseagreen!important;color:black!important;}#mermaid-svg-Jj7TZI4U2WCVdrjg .resource tspan{fill:black!important;}#mermaid-svg-Jj7TZI4U2WCVdrjg .flow>*{fill:lavender!important;stroke:mediumpurple!important;color:black!important;}#mermaid-svg-Jj7TZI4U2WCVdrjg .flow span{fill:lavender!important;stroke:mediumpurple!important;color:black!important;}#mermaid-svg-Jj7TZI4U2WCVdrjg .flow tspan{fill:black!important;} 操作后变化
流程状态变化
阶段是否结束
是否进入结算
资源状态变化
手牌 / 牌堆
次数 / 分数
操作前校验
手牌是否合法
次数是否足够
流程是否进行中
一次用户操作
也就是说,一次用户操作并不是只改变一个字段。这里最容易忽略的是:
每一步都可能改变后面的判断。
比如:
- 移除手牌后,手牌数量变了
- 补牌后,牌堆数量变了
- 扣除次数后,用户是否还能继续操作变了
- 累加分数后,当前阶段是否达成目标也变了。
所以一次用户操作,实际是在推动多个状态同步变化。
这也是后端复杂度开始上来的地方,因为从这一刻开始,后端要保证的不只是"这次结果算对了",还要保证:
这次操作之后,整个系统状态仍然是对的。
五、功能接口和状态流转的区别
可以先简单对比一下。
| 对比项 | 功能接口 | 状态流转型接口 |
|---|---|---|
| 核心问题 | 这次请求要返回什么? | 这次操作后,系统应该变成什么? |
| 主要关注点 | 输入、计算、输出 | 当前状态、操作合法性、状态更新 |
| 是否依赖上下文 | 通常较少 | 强依赖当前运行状态 |
| 是否影响后续流程 | 通常较弱 | 通常会影响下一步判断 |
| 测试重点 | 输入输出是否符合预期 | 状态推进是否正确、边界是否被拦截 |
| 常见风险 | 算错、字段返回错 | 状态错乱、重复执行、前后端不一致 |
| 适合的设计方式 | 按函数逻辑拆分 | 按状态生命周期拆分 |
功能接口更像是:
我问你一个问题,你给我一个答案。
状态流转更像是:
我做了一个动作,你要判断这个动作是否合法,并把系统推进到下一个正确状态。
这两者的思考方式是不一样的。
- 前者重点是:算得对不对
- 后者重点是:推进得对不对
六、为什么状态流转会让后端变复杂
因为一旦进入状态流转,后端就要开始处理更多边界情况。
1. 同一个请求,在不同状态下结果不同
比如用户点击"出牌"。
- 如果当前流程还在进行中,并且还有出牌次数,那这个请求可以执行;
- 如果当前流程已经结束,或者出牌次数已经用完,那同样的请求就应该被拒绝。
也就是说,接口能不能执行,不只取决于参数,还取决于当前状态。
| 请求 | 当前状态 | 后端应该怎么处理 |
|---|---|---|
action = play |
游戏进行中,还有出牌次数 | 允许执行 |
action = play |
游戏已经结束 | 拒绝执行 |
action = play |
没有出牌次数 | 拒绝执行 |
action = play |
手牌里没有这些牌 | 拒绝执行 |
这时候接口就不能只看入参了,它必须读取服务端当前状态,再决定下一步。
2. 一个状态更新,可能影响多个后续判断
比如一次出牌后,剩余出牌次数减少。
这看起来只是一个数字变化,但它会影响:
- 用户是否还能继续出牌
- 当前阶段是否应该结束
- 结算时是否还有剩余次数奖励
- 前端按钮是否应该禁用
- 后续请求是否应该被拦截
所以状态字段不是孤立的。很多字段看起来只是一个值,但它背后连着后续流程判断。
3. 状态没有及时更新,会导致后续流程错误
比如服务端返回给前端的"剩余出牌次数"是新的,但服务端自己保存的运行时状态没有更新,那么下一次请求进来时,服务端读取到的仍然是旧状态。
这样就会出现很麻烦的问题:
| 不一致的位置 | 可能结果 |
|---|---|
| 前端展示的是新状态,服务端保存的是旧状态 | 用户看到和实际判断不一致 |
| 服务端没有扣减次数 | 用户可能重复操作 |
| 服务端没有更新分数 | 阶段结束判断可能错误 |
| 服务端没有更新流程状态 | 已结束流程还可能继续执行 |
所以状态流转里很重要的一点是:
返回给前端的数据,必须和服务端真实保存的状态一致。
否则系统迟早会出现前后不一致的问题。
七、状态流转需要一个清晰的处理顺序
当接口从功能调用变成状态流转后,处理顺序就变得很重要。
如果顺序混乱,代码很容易变成:先改一点状态,再校验一点参数,再算一点结果,中间又补一次状态,最后再判断是否结束。
短期可能能跑,但后续维护会很痛苦。
我现在更倾向把一次状态流转拆成几个固定步骤:
| 步骤 | 做什么 | 为什么重要 |
|---|---|---|
| 读取当前状态 | 获取服务端可信状态 | 后续判断不能只依赖前端入参 |
| 校验请求是否合法 | 判断当前状态下能不能执行 | 避免错误状态下继续推进 |
| 执行业务规则 | 计算本次操作结果 | 把规则计算集中处理 |
| 更新运行时状态 | 写入本次变化 | 保证服务端状态真实变化 |
| 判断流程是否结束 | 判断是否进入结算或下一阶段 | 避免漏结算、漏推进 |
| 组装返回结果 | 返回最新状态快照 | 保证前端基于服务端状态渲染 |
用图表示,大概是:
#mermaid-svg-i0XMl4z65WiAv7lu{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-i0XMl4z65WiAv7lu .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-i0XMl4z65WiAv7lu .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-i0XMl4z65WiAv7lu .error-icon{fill:#552222;}#mermaid-svg-i0XMl4z65WiAv7lu .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-i0XMl4z65WiAv7lu .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-i0XMl4z65WiAv7lu .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-i0XMl4z65WiAv7lu .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-i0XMl4z65WiAv7lu .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-i0XMl4z65WiAv7lu .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-i0XMl4z65WiAv7lu .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-i0XMl4z65WiAv7lu .marker{fill:#333333;stroke:#333333;}#mermaid-svg-i0XMl4z65WiAv7lu .marker.cross{stroke:#333333;}#mermaid-svg-i0XMl4z65WiAv7lu svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-i0XMl4z65WiAv7lu p{margin:0;}#mermaid-svg-i0XMl4z65WiAv7lu .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-i0XMl4z65WiAv7lu .cluster-label text{fill:#333;}#mermaid-svg-i0XMl4z65WiAv7lu .cluster-label span{color:#333;}#mermaid-svg-i0XMl4z65WiAv7lu .cluster-label span p{background-color:transparent;}#mermaid-svg-i0XMl4z65WiAv7lu .label text,#mermaid-svg-i0XMl4z65WiAv7lu span{fill:#333;color:#333;}#mermaid-svg-i0XMl4z65WiAv7lu .node rect,#mermaid-svg-i0XMl4z65WiAv7lu .node circle,#mermaid-svg-i0XMl4z65WiAv7lu .node ellipse,#mermaid-svg-i0XMl4z65WiAv7lu .node polygon,#mermaid-svg-i0XMl4z65WiAv7lu .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-i0XMl4z65WiAv7lu .rough-node .label text,#mermaid-svg-i0XMl4z65WiAv7lu .node .label text,#mermaid-svg-i0XMl4z65WiAv7lu .image-shape .label,#mermaid-svg-i0XMl4z65WiAv7lu .icon-shape .label{text-anchor:middle;}#mermaid-svg-i0XMl4z65WiAv7lu .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-i0XMl4z65WiAv7lu .rough-node .label,#mermaid-svg-i0XMl4z65WiAv7lu .node .label,#mermaid-svg-i0XMl4z65WiAv7lu .image-shape .label,#mermaid-svg-i0XMl4z65WiAv7lu .icon-shape .label{text-align:center;}#mermaid-svg-i0XMl4z65WiAv7lu .node.clickable{cursor:pointer;}#mermaid-svg-i0XMl4z65WiAv7lu .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-i0XMl4z65WiAv7lu .arrowheadPath{fill:#333333;}#mermaid-svg-i0XMl4z65WiAv7lu .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-i0XMl4z65WiAv7lu .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-i0XMl4z65WiAv7lu .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-i0XMl4z65WiAv7lu .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-i0XMl4z65WiAv7lu .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-i0XMl4z65WiAv7lu .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-i0XMl4z65WiAv7lu .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-i0XMl4z65WiAv7lu .cluster text{fill:#333;}#mermaid-svg-i0XMl4z65WiAv7lu .cluster span{color:#333;}#mermaid-svg-i0XMl4z65WiAv7lu div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-i0XMl4z65WiAv7lu .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-i0XMl4z65WiAv7lu rect.text{fill:none;stroke-width:0;}#mermaid-svg-i0XMl4z65WiAv7lu .icon-shape,#mermaid-svg-i0XMl4z65WiAv7lu .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-i0XMl4z65WiAv7lu .icon-shape p,#mermaid-svg-i0XMl4z65WiAv7lu .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-i0XMl4z65WiAv7lu .icon-shape .label rect,#mermaid-svg-i0XMl4z65WiAv7lu .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-i0XMl4z65WiAv7lu .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-i0XMl4z65WiAv7lu .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-i0XMl4z65WiAv7lu :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 阶段三:返回结果
阶段二:推进状态
阶段一:进入接口
不通过
通过
否
是
读取当前状态
校验请求
执行业务规则
更新运行时状态
流程是否结束
返回最新状态
返回结算结果
返回错误
不修改状态
这个顺序的好处是:
- 校验失败时,不修改状态
- 状态更新集中发生
- 流程结束判断有明确位置
- 返回结果来自最新状态
- 后续扩展时更容易插入新逻辑
这也是我后来写很多接口时慢慢形成的习惯:
先不要急着写具体逻辑,先把状态推进顺序想清楚。
八、状态流转里,返回结果不只是"提示成功"
在简单接口里,返回结果可能只是:
json
{
"code": 200,
"message": "success"
}
但在状态流转型接口里,这通常是不够的。
因为前端不只是需要知道"成功了",还需要知道:
成功之后,当前系统状态变成什么了。
比如一次出牌成功后,前端可能需要:
- 当前最新手牌
- 剩余出牌次数
- 剩余弃牌次数
- 当前得分
- 累计分数
- 当前阶段是否结束
- 如果结束,结算结果是什么
所以状态流转型接口的返回值,往往不是一个简单提示,而是一份"最新状态快照"。
| 返回内容 | 为什么需要 |
|---|---|
| 最新手牌 | 前端需要重新渲染用户可操作内容 |
| 剩余次数 | 前端需要判断按钮是否可用 |
| 当前得分 | 前端需要展示本次操作结果 |
| 累计分数 | 前端需要展示阶段进度 |
| 阶段是否结束 | 前端需要切换页面或展示结算 |
| 结算结果 | 前端需要展示奖励、失败原因或下一步操作 |
这也是为什么前端不应该自己猜状态。
比如前端不能自己认为:
我刚才出了 2 张牌,所以我本地从手牌里删掉 2 张,再自己补 2 张就好了。
更稳的方式是:前端提交操作 -> 服务端处理并更新状态 -> 服务端返回最新手牌和状态 -> 前端按服务端返回重新渲染
这里其实也是一个很重要的后端设计原则:
前端可以负责展示和交互,但关键业务状态最好由服务端统一计算、保存和返回。
九、从功能接口到状态流转的通用场景
这个问题不只存在于游戏后端。很多普通后端系统也是这样慢慢变复杂的。
| 场景 | 表面上的接口 | 实际上的状态流转 |
|---|---|---|
| 电商下单 | 创建订单 | 校验商品、锁库存、算金额、进入待支付 |
| 活动奖励 | 点击领取 | 校验资格、检查库存、防重复领取、发放奖励 |
| 审批流程 | 提交审批 | 校验权限、推进节点、流转审批人、更新单据状态 |
| 任务系统 | 完成任务 | 校验完成条件、更新进度、发放奖励、解锁下一任务 |
| 游戏操作 | 出牌 / 弃牌 | 校验手牌、扣次数、更新分数、判断阶段结束 |
1. 电商下单
早期看,下单接口可能只是:
提交商品 ID 和数量 -> 生成订单
但真正进入状态流转后,它会变成:
读取库存 -> 校验商品是否可售 -> 校验优惠券是否可用 -> 锁定库存 -> 计算订单金额 -> 生成订单 -> 等待支付
这里的复杂度不只是"创建订单"本身,而是订单状态、库存状态、优惠券状态、支付状态之间开始产生关系。
2. 活动奖励领取
早期看,领取奖励就是:
用户点击领取 -> 发放奖励
但真正做起来会发现要判断:用户是否完成任务、活动是否还有效、奖励是否已经领取、库存是否充足、是否满足领取次数限制,以及发放失败时是否需要回滚状态。
这也不是一个简单接口,而是一条状态流转链路。
3. 审批流程
提交审批看起来也只是一个接口。
但它背后可能涉及:当前单据状态是否可提交、当前用户是否有权限、下一审批人是谁、是否需要多级审批、审批通过后是否进入下一阶段,以及驳回后状态回到哪里。
所以审批系统的核心也不是"提交按钮",而是状态推进。
十、我现在对后端复杂度的理解
1. 复杂度不是来自代码多,而是来自状态互相影响
以前我会觉得,后端复杂度可能主要来自代码量多、逻辑分支多、表结构复杂、接口数量多。
这些当然也会带来复杂度。
但现在做这个项目后,我会更明显地感觉到:
真正让后端系统变复杂的,是状态之间开始互相影响。
当一个接口只是单纯计算时,它很容易控制;但当一个接口会修改状态,并且这个状态会影响后续流程时,系统复杂度就开始出现了。
比如:
出牌影响手牌 -> 手牌影响下一次操作 -> 下一次操作影响得分 -> 得分影响是否结算 -> 结算影响是否进入下一阶段 -> 阶段影响下一次初始化
这些东西一串起来,系统就不再是几个函数的组合,而是一个持续推进的状态网络。
所以后端设计里真正需要想清楚的是:
- 当前状态是什么
- 这个操作能不能执行
- 执行后哪些状态会变化
- 变化后是否触发下一阶段
- 返回给前端的状态是否和服务端一致
这些问题想清楚了,代码通常不会太离谱。但如果这些没想清楚,代码即使能跑,后面也很容易重构。
2. 状态流转型接口设计检查表
所以我现在再设计这类接口时,会尽量先问自己下面几个问题:
| 检查项 | 要问的问题 | 如果没想清楚,可能出现的问题 |
|---|---|---|
| 当前状态 | 当前用户 / 对象处于什么状态? | 同一个请求在错误状态下被执行 |
| 操作合法性 | 这个状态下能不能执行这个操作? | 越权操作、重复操作、流程错乱 |
| 状态变化 | 执行后哪些字段会被修改? | 只改了一部分状态,导致数据不一致 |
| 后续影响 | 这些变化会不会触发下一阶段? | 该结算没结算,该结束没结束 |
| 返回结果 | 前端拿到的是不是最新状态? | 前端展示和服务端真实状态不一致 |
| 异常处理 | 中间失败时状态要不要回滚? | 部分成功、部分失败,留下脏状态 |
这个表不一定能覆盖所有情况,但至少能提醒自己:
状态流转型接口,重点不是"这次有没有成功",而是"成功之后系统是否进入了正确状态"。
十一、这次复盘得到的设计结论
这次从功能接口到状态流转的过程,让我得到几个比较明确的结论。
| 设计结论 | 对应含义 |
|---|---|
| 后端接口不只是函数入口 | 很多接口表面是一个动作,实际上是一次状态推进 |
| 状态流转要先于代码实现 | 先想清楚推进顺序,再写代码会更稳 |
| 前端不应该推导关键状态 | 关键业务状态最好由服务端统一计算和返回 |
| 返回结果应该表达最新状态 | 状态流转型接口返回的不是成功提示,而是处理后的状态快照 |
| 复杂度来自状态关系 | 一个状态变化影响另一个状态时,系统复杂度就开始出现 |
1. 后端接口不只是函数入口
很多接口表面看是一个动作,但实际上可能是一次状态推进。
所以设计接口时,不应该只问:
这个接口接收什么参数,返回什么结果?
还应该问:
这个接口执行后,系统状态发生了什么变化?
2. 状态流转要先于代码实现
如果一个接口会影响多个状态,最好先把状态流转顺序画出来。
比如:读取状态 -> 校验 -> 执行规则 -> 更新状态 -> 判断结束 -> 返回结果
先有这个顺序,再写代码,会比边写边补判断稳定很多。
3. 前端不应该推导关键状态
前端可以做展示,也可以做交互反馈。
但关键业务状态,比如次数、分数、进度、奖励、权限,最好都由服务端返回。否则前后端很容易各算一套,最后状态对不上。
4. 返回结果应该表达最新状态
状态流转型接口返回的不是"成功提示",而是处理后的最新状态。
这样前端才能基于服务端状态重新渲染,而不是自己猜。
十二、本篇小结
这一篇主要复盘了一个问题:
为什么后端接口做着做着,会从功能调用变成状态流转?
我的理解是:
只要一个操作会影响后续流程,它就不再是简单功能接口,而是一次状态推进。
所以后端复杂度不是突然出现的,它往往是在这些时刻慢慢出现的:
- 一个接口开始依赖当前状态
- 一个操作开始修改多个字段
- 一个状态变化开始影响后续判断
- 一个返回结果开始承载最新状态
- 一个流程开始需要判断是否进入下一阶段
如果用一句话总结:
功能接口关注本次返回什么,状态流转关注系统接下来变成什么。
这也是后续继续做状态拆分、生命周期设计、接口职责收敛和返回结构分层时,很重要的基础。
下一篇会继续复盘:
聚合状态拆分设计:如何避免一个对象变成状态垃圾桶。
🃏 关于这个项目
本复盘专栏的内容,来自我正在实现的一个 Balatro 风格实时游戏后端项目。
主线系列 《实时游戏后端工程实践:从 Balatro 出发》 会记录项目从 0 到 1 的实现过程;而本专栏会从项目里抽出一些更通用的后端设计问题,把具体功能背后的设计取舍讲清楚。
如果你对状态设计、接口拆分、规则系统、奖励系统和游戏后端工程实践感兴趣,也可以顺着主线系列继续看。