- 本文是《后端系统设计复盘:从游戏项目到通用后端》专栏中的第一篇。
- 这个专栏的内容,来自我正在实现的一个 Balatro 风格实时游戏后端项目。主线实现过程记录在 《实时游戏后端工程实践:从 Balatro 出发》 专栏中。
- 不过这篇不会重点讲某个具体游戏功能怎么写,而是从项目里抽出一个更通用的后端问题:在一个需要持续交互的系统里,哪些状态应该由服务端维护,为什么关键状态不能交给客户端。
这篇文章想表达的核心观点是:
- 客户端可以保存展示状态,但不能作为关键业务状态的最终依据。
- 只要一个状态会影响业务结果、流程推进、资源变化或权限判断,服务端就必须拥有最终判断权。
文章目录
- 一、这个问题是怎么出现的
- 二、客户端状态为什么不适合作为最终依据
- 三、什么状态必须放在服务端
- 四、服务端维护状态,不等于前端什么都不管
- 五、一个更通用的状态流转模型
- 六、状态归属判断:看它是否影响结果
-
- [1. 游戏场景](#1. 游戏场景)
- [2. 电商场景](#2. 电商场景)
- [3. 任务 / 活动场景](#3. 任务 / 活动场景)
- [4. 权限场景](#4. 权限场景)
- 七、服务端状态设计需要注意什么
- 八、不要把服务端写成"前端参数的搬运工"
- 九、一个简单的后端状态设计示例
- 十、通用设计结论
- 十一、本篇小结
一、这个问题是怎么出现的
在项目早期,我最先遇到的一个问题其实很简单:
发牌、出牌、补牌这些状态,到底应该由谁维护?
如果只从实现难度看,把很多状态交给前端会很方便。
比如前端自己维护:
- 当前手牌
- 剩余牌堆
- 出牌次数
- 当前分数
- 当前阶段进度
这样后端看起来就会轻很多。前端发来什么,后端就根据参数算一下,然后返回结果。
但继续往后想,就会发现这个方案的问题很大。
因为这些数据并不只是"展示用数据",它们会直接影响后续业务结果。
例如:
- 当前手牌决定玩家能出什么牌
- 剩余牌堆决定后续能补什么牌
- 出牌次数决定当前回合是否还能继续
- 当前分数决定是否通过阶段
- 当前进度决定下一步应该进入哪个流程
也就是说,这些状态一旦出错,影响的不是页面展示,而是整个业务流程。
所以我最后得出的第一个设计结论是:
⚠️ 只要一个状态会影响业务结果,它就不应该只由客户端维护。
客户端可以展示状态,也可以发起操作,但真正可信的状态,应该由服务端维护。
二、客户端状态为什么不适合作为最终依据
客户端当然需要状态。
没有状态,前端无法渲染页面,也无法给用户及时反馈。
但问题在于:
客户端状态适合展示,不适合作为最终业务判断依据。
因为从服务端角度看,客户端传来的所有内容都只能算"请求意图",而不是"事实本身"。
比如客户端告诉后端:
json
{
"playerId": "player_1",
"selectedCards": ["AH", "KH"],
"action": "play"
}
这只能说明:
玩家希望执行一次 play 操作,并且选择了 AH、KH。
但服务端不能直接相信:
- 这两张牌一定在玩家手里
- 当前玩家一定还有出牌次数
- 当前游戏一定还处于可操作状态
- 当前请求一定没有被重复发送
- 当前参数一定没有被篡改
所以服务端必须重新判断:
- 这名玩家是否存在?
- 当前游戏状态是否允许操作?
- 这些牌是否真的在当前手牌中?
- 是否存在重复选择?
- 是否超过选择数量限制?
- 当前是否还有操作次数?
只有这些都通过,服务端才能真正修改状态。
这就是服务端状态维护的核心价值:
不是为了重复前端逻辑,而是为了保证业务事实可信。
三、什么状态必须放在服务端
并不是所有状态都必须放在服务端。
有些状态只影响展示,比如:
- 当前按钮是否高亮
- 某个弹窗是否打开
- 卡牌动画是否播放中
- 当前 tab 选中了哪个
- 鼠标 hover 到了哪里
这些状态即使错了,也通常只是影响页面表现,不会改变业务结果。
但另一类状态就不一样了。
只要它会影响后续流程、结算结果、资源变化或权限判断,就应该由服务端维护。
可以简单整理成下面这张表:
| 状态类型 | 是否适合只放客户端 | 最终判断方 | 原因 |
|---|---|---|---|
| 页面动画状态 | 可以 | 前端 | 只影响展示效果 |
| 弹窗开关状态 | 可以 | 前端 | 不影响业务结果 |
| 当前 tab / hover 状态 | 可以 | 前端 | 只影响交互体验 |
| 当前手牌 / 库存 / 资源数量 | 不适合 | 服务端 | 会影响后续操作 |
| 剩余操作次数 | 不适合 | 服务端 | 会影响是否允许继续执行 |
| 当前分数 / 金额 / 结算结果 | 不适合 | 服务端 | 会影响胜负、支付、奖励 |
| 阶段进度 / 流程状态 | 不适合 | 服务端 | 会影响下一步进入哪个流程 |
| 权限 / 资格 / 是否可领取 | 不适合 | 服务端 | 会影响用户是否能获得权益 |
所以判断一个状态归属时,可以先问一句:
如果这个状态被前端篡改,会不会影响最终业务结果?
如果答案是"会",那它就不应该只由客户端维护。
四、服务端维护状态,不等于前端什么都不管
这里容易产生一个误解:
既然状态由服务端维护,那前端是不是就不能保存状态了?
不是这样的。
更合理的分工应该是:
#mermaid-svg-RcjRLpHpnkPeFM5T{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-RcjRLpHpnkPeFM5T .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-RcjRLpHpnkPeFM5T .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-RcjRLpHpnkPeFM5T .error-icon{fill:#552222;}#mermaid-svg-RcjRLpHpnkPeFM5T .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-RcjRLpHpnkPeFM5T .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-RcjRLpHpnkPeFM5T .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-RcjRLpHpnkPeFM5T .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-RcjRLpHpnkPeFM5T .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-RcjRLpHpnkPeFM5T .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-RcjRLpHpnkPeFM5T .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-RcjRLpHpnkPeFM5T .marker{fill:#333333;stroke:#333333;}#mermaid-svg-RcjRLpHpnkPeFM5T .marker.cross{stroke:#333333;}#mermaid-svg-RcjRLpHpnkPeFM5T svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-RcjRLpHpnkPeFM5T p{margin:0;}#mermaid-svg-RcjRLpHpnkPeFM5T .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-RcjRLpHpnkPeFM5T .cluster-label text{fill:#333;}#mermaid-svg-RcjRLpHpnkPeFM5T .cluster-label span{color:#333;}#mermaid-svg-RcjRLpHpnkPeFM5T .cluster-label span p{background-color:transparent;}#mermaid-svg-RcjRLpHpnkPeFM5T .label text,#mermaid-svg-RcjRLpHpnkPeFM5T span{fill:#333;color:#333;}#mermaid-svg-RcjRLpHpnkPeFM5T .node rect,#mermaid-svg-RcjRLpHpnkPeFM5T .node circle,#mermaid-svg-RcjRLpHpnkPeFM5T .node ellipse,#mermaid-svg-RcjRLpHpnkPeFM5T .node polygon,#mermaid-svg-RcjRLpHpnkPeFM5T .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-RcjRLpHpnkPeFM5T .rough-node .label text,#mermaid-svg-RcjRLpHpnkPeFM5T .node .label text,#mermaid-svg-RcjRLpHpnkPeFM5T .image-shape .label,#mermaid-svg-RcjRLpHpnkPeFM5T .icon-shape .label{text-anchor:middle;}#mermaid-svg-RcjRLpHpnkPeFM5T .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-RcjRLpHpnkPeFM5T .rough-node .label,#mermaid-svg-RcjRLpHpnkPeFM5T .node .label,#mermaid-svg-RcjRLpHpnkPeFM5T .image-shape .label,#mermaid-svg-RcjRLpHpnkPeFM5T .icon-shape .label{text-align:center;}#mermaid-svg-RcjRLpHpnkPeFM5T .node.clickable{cursor:pointer;}#mermaid-svg-RcjRLpHpnkPeFM5T .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-RcjRLpHpnkPeFM5T .arrowheadPath{fill:#333333;}#mermaid-svg-RcjRLpHpnkPeFM5T .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-RcjRLpHpnkPeFM5T .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-RcjRLpHpnkPeFM5T .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-RcjRLpHpnkPeFM5T .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-RcjRLpHpnkPeFM5T .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-RcjRLpHpnkPeFM5T .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-RcjRLpHpnkPeFM5T .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-RcjRLpHpnkPeFM5T .cluster text{fill:#333;}#mermaid-svg-RcjRLpHpnkPeFM5T .cluster span{color:#333;}#mermaid-svg-RcjRLpHpnkPeFM5T 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-RcjRLpHpnkPeFM5T .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-RcjRLpHpnkPeFM5T rect.text{fill:none;stroke-width:0;}#mermaid-svg-RcjRLpHpnkPeFM5T .icon-shape,#mermaid-svg-RcjRLpHpnkPeFM5T .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-RcjRLpHpnkPeFM5T .icon-shape p,#mermaid-svg-RcjRLpHpnkPeFM5T .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-RcjRLpHpnkPeFM5T .icon-shape .label rect,#mermaid-svg-RcjRLpHpnkPeFM5T .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-RcjRLpHpnkPeFM5T .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-RcjRLpHpnkPeFM5T .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-RcjRLpHpnkPeFM5T :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;}#mermaid-svg-RcjRLpHpnkPeFM5T .fe>*{fill:#eef6ff!important;stroke:#5b8ff9!important;stroke-width:1px!important;color:#111!important;}#mermaid-svg-RcjRLpHpnkPeFM5T .fe span{fill:#eef6ff!important;stroke:#5b8ff9!important;stroke-width:1px!important;color:#111!important;}#mermaid-svg-RcjRLpHpnkPeFM5T .fe tspan{fill:#111!important;}#mermaid-svg-RcjRLpHpnkPeFM5T .be>*{fill:#eef9f1!important;stroke:#52c41a!important;stroke-width:1px!important;color:#111!important;}#mermaid-svg-RcjRLpHpnkPeFM5T .be span{fill:#eef9f1!important;stroke:#52c41a!important;stroke-width:1px!important;color:#111!important;}#mermaid-svg-RcjRLpHpnkPeFM5T .be tspan{fill:#111!important;}#mermaid-svg-RcjRLpHpnkPeFM5T .warn>*{fill:#fff7e6!important;stroke:#fa8c16!important;stroke-width:1px!important;color:#111!important;}#mermaid-svg-RcjRLpHpnkPeFM5T .warn span{fill:#fff7e6!important;stroke:#fa8c16!important;stroke-width:1px!important;color:#111!important;}#mermaid-svg-RcjRLpHpnkPeFM5T .warn tspan{fill:#111!important;} 服务端
前端
允许
拒绝
展示状态
收集用户操作
提交操作意图
读取真实状态
校验是否允许
执行业务规则
更新服务端状态
返回最新结果
返回错误原因
也就是说,前端当然可以缓存当前页面需要展示的数据。
但这些数据只应该作为"展示副本",而不是"最终事实"。
例如:
text
服务端返回当前手牌
↓
前端渲染手牌
↓
用户选择几张牌并点击出牌
↓
前端把选择结果发送给服务端
↓
服务端根据自己保存的真实状态重新校验
↓
服务端更新状态并返回最新手牌
↓
前端用最新结果重新渲染
这个流程里,前端也有状态,但它不是最终决策者。
真正决定"这次操作是否有效"的,是服务端。
这也是很多后端系统里通用的设计方式:
⚠️ 客户端提交意图,服务端判断事实。
五、一个更通用的状态流转模型
如果把具体业务拿掉,服务端状态维护大概可以抽象成下面这个流程:
#mermaid-svg-2I4WZJTBJzbhiARz{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-2I4WZJTBJzbhiARz .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-2I4WZJTBJzbhiARz .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-2I4WZJTBJzbhiARz .error-icon{fill:#552222;}#mermaid-svg-2I4WZJTBJzbhiARz .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-2I4WZJTBJzbhiARz .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-2I4WZJTBJzbhiARz .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-2I4WZJTBJzbhiARz .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-2I4WZJTBJzbhiARz .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-2I4WZJTBJzbhiARz .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-2I4WZJTBJzbhiARz .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-2I4WZJTBJzbhiARz .marker{fill:#333333;stroke:#333333;}#mermaid-svg-2I4WZJTBJzbhiARz .marker.cross{stroke:#333333;}#mermaid-svg-2I4WZJTBJzbhiARz svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-2I4WZJTBJzbhiARz p{margin:0;}#mermaid-svg-2I4WZJTBJzbhiARz .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-2I4WZJTBJzbhiARz .cluster-label text{fill:#333;}#mermaid-svg-2I4WZJTBJzbhiARz .cluster-label span{color:#333;}#mermaid-svg-2I4WZJTBJzbhiARz .cluster-label span p{background-color:transparent;}#mermaid-svg-2I4WZJTBJzbhiARz .label text,#mermaid-svg-2I4WZJTBJzbhiARz span{fill:#333;color:#333;}#mermaid-svg-2I4WZJTBJzbhiARz .node rect,#mermaid-svg-2I4WZJTBJzbhiARz .node circle,#mermaid-svg-2I4WZJTBJzbhiARz .node ellipse,#mermaid-svg-2I4WZJTBJzbhiARz .node polygon,#mermaid-svg-2I4WZJTBJzbhiARz .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-2I4WZJTBJzbhiARz .rough-node .label text,#mermaid-svg-2I4WZJTBJzbhiARz .node .label text,#mermaid-svg-2I4WZJTBJzbhiARz .image-shape .label,#mermaid-svg-2I4WZJTBJzbhiARz .icon-shape .label{text-anchor:middle;}#mermaid-svg-2I4WZJTBJzbhiARz .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-2I4WZJTBJzbhiARz .rough-node .label,#mermaid-svg-2I4WZJTBJzbhiARz .node .label,#mermaid-svg-2I4WZJTBJzbhiARz .image-shape .label,#mermaid-svg-2I4WZJTBJzbhiARz .icon-shape .label{text-align:center;}#mermaid-svg-2I4WZJTBJzbhiARz .node.clickable{cursor:pointer;}#mermaid-svg-2I4WZJTBJzbhiARz .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-2I4WZJTBJzbhiARz .arrowheadPath{fill:#333333;}#mermaid-svg-2I4WZJTBJzbhiARz .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-2I4WZJTBJzbhiARz .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-2I4WZJTBJzbhiARz .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-2I4WZJTBJzbhiARz .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-2I4WZJTBJzbhiARz .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-2I4WZJTBJzbhiARz .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-2I4WZJTBJzbhiARz .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-2I4WZJTBJzbhiARz .cluster text{fill:#333;}#mermaid-svg-2I4WZJTBJzbhiARz .cluster span{color:#333;}#mermaid-svg-2I4WZJTBJzbhiARz 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-2I4WZJTBJzbhiARz .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-2I4WZJTBJzbhiARz rect.text{fill:none;stroke-width:0;}#mermaid-svg-2I4WZJTBJzbhiARz .icon-shape,#mermaid-svg-2I4WZJTBJzbhiARz .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-2I4WZJTBJzbhiARz .icon-shape p,#mermaid-svg-2I4WZJTBJzbhiARz .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-2I4WZJTBJzbhiARz .icon-shape .label rect,#mermaid-svg-2I4WZJTBJzbhiARz .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-2I4WZJTBJzbhiARz .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-2I4WZJTBJzbhiARz .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-2I4WZJTBJzbhiARz :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 不通过
通过
客户端操作意图
服务端当前状态
规则校验
拒绝请求
返回失败原因
执行业务规则
生成新状态
返回最新结果
客户端重新渲染
这个流程的重点在于:
服务端不是只处理客户端发来的参数,而是要结合"当前真实状态"一起判断。
因为同一个请求,在不同状态下,结果可能完全不同。
比如:
text
用户点击"领取奖励"
如果当前状态是:
ts
rewardStatus = pending
那可能允许领取。
但如果当前状态已经是:
ts
rewardStatus = claimed
那就应该拒绝重复领取。
同样一个接口,同样一个用户动作,是否能成功取决于服务端当前状态。
这也是为什么后端不能只写成"参数处理器",而应该成为"状态推进器"。
六、状态归属判断:看它是否影响结果
在实际项目里,我会倾向于用一个简单判断标准:
这个状态是否会影响最终业务结果?
如果会,它就应该由服务端维护。
| 业务场景 | 客户端可以展示什么 | 服务端必须维护什么 | 如果只信客户端会怎样 |
|---|---|---|---|
| 游戏场景 | 当前手牌、分数、次数 | 真实手牌、牌堆、得分、阶段进度 | 非法出牌、重复操作、错误结算 |
| 电商场景 | 库存展示、优惠信息、订单金额预览 | 真实库存、优惠券状态、订单金额、支付状态 | 超卖、优惠滥用、金额被篡改 |
| 活动任务 | 任务进度、奖励展示 | 完成状态、领取状态、活动有效期 | 重复领取、越权领取、过期领取 |
| 权限系统 | 按钮展示、入口隐藏 | 用户角色、权限范围、操作资格 | 越权访问或非法修改 |
举几个通用例子。
1. 游戏场景
例如:
- 当前手牌
- 牌堆剩余数量
- 剩余出牌次数
- 当前分数
- 当前关卡进度
这些都会影响后续操作和最终结算。
所以它们应该由服务端维护。
客户端可以展示这些数据,但不能自己决定这些数据是否成立。
2. 电商场景
例如:
- 商品库存
- 优惠券是否可用
- 订单金额
- 支付状态
- 是否已发货
这些状态也不能只靠前端维护。
前端可以展示"库存还剩 3 件",但下单时,服务端仍然要重新判断库存是否足够。
因为库存可能已经被其他用户占用或扣减。
3. 任务 / 活动场景
例如:
- 用户是否完成任务
- 奖励是否已领取
- 活动是否过期
- 当前积分是否满足条件
这些数据都会影响用户是否能获得权益。
所以也必须由服务端维护。
如果只靠前端判断,就很容易出现重复领取、越权领取、过期领取等问题。
4. 权限场景
例如:
- 当前用户是否有权限访问
- 当前角色是否能修改数据
- 当前操作是否超出范围
这些更不能交给客户端。
因为权限判断本身就是服务端必须守住的边界。
七、服务端状态设计需要注意什么
服务端维护状态,不只是把数据存在内存、Redis 或数据库里就结束了。
更重要的是要想清楚:
这个状态什么时候创建?什么时候更新?什么时候清理?
也就是说,状态一定有生命周期。
例如一个运行中的业务状态,通常会经历:
#mermaid-svg-ZQgpHzrzTeOpoqQC{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-ZQgpHzrzTeOpoqQC .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-ZQgpHzrzTeOpoqQC .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-ZQgpHzrzTeOpoqQC .error-icon{fill:#552222;}#mermaid-svg-ZQgpHzrzTeOpoqQC .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-ZQgpHzrzTeOpoqQC .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-ZQgpHzrzTeOpoqQC .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-ZQgpHzrzTeOpoqQC .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-ZQgpHzrzTeOpoqQC .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-ZQgpHzrzTeOpoqQC .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-ZQgpHzrzTeOpoqQC .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-ZQgpHzrzTeOpoqQC .marker{fill:#333333;stroke:#333333;}#mermaid-svg-ZQgpHzrzTeOpoqQC .marker.cross{stroke:#333333;}#mermaid-svg-ZQgpHzrzTeOpoqQC svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-ZQgpHzrzTeOpoqQC p{margin:0;}#mermaid-svg-ZQgpHzrzTeOpoqQC .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-ZQgpHzrzTeOpoqQC .cluster-label text{fill:#333;}#mermaid-svg-ZQgpHzrzTeOpoqQC .cluster-label span{color:#333;}#mermaid-svg-ZQgpHzrzTeOpoqQC .cluster-label span p{background-color:transparent;}#mermaid-svg-ZQgpHzrzTeOpoqQC .label text,#mermaid-svg-ZQgpHzrzTeOpoqQC span{fill:#333;color:#333;}#mermaid-svg-ZQgpHzrzTeOpoqQC .node rect,#mermaid-svg-ZQgpHzrzTeOpoqQC .node circle,#mermaid-svg-ZQgpHzrzTeOpoqQC .node ellipse,#mermaid-svg-ZQgpHzrzTeOpoqQC .node polygon,#mermaid-svg-ZQgpHzrzTeOpoqQC .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-ZQgpHzrzTeOpoqQC .rough-node .label text,#mermaid-svg-ZQgpHzrzTeOpoqQC .node .label text,#mermaid-svg-ZQgpHzrzTeOpoqQC .image-shape .label,#mermaid-svg-ZQgpHzrzTeOpoqQC .icon-shape .label{text-anchor:middle;}#mermaid-svg-ZQgpHzrzTeOpoqQC .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-ZQgpHzrzTeOpoqQC .rough-node .label,#mermaid-svg-ZQgpHzrzTeOpoqQC .node .label,#mermaid-svg-ZQgpHzrzTeOpoqQC .image-shape .label,#mermaid-svg-ZQgpHzrzTeOpoqQC .icon-shape .label{text-align:center;}#mermaid-svg-ZQgpHzrzTeOpoqQC .node.clickable{cursor:pointer;}#mermaid-svg-ZQgpHzrzTeOpoqQC .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-ZQgpHzrzTeOpoqQC .arrowheadPath{fill:#333333;}#mermaid-svg-ZQgpHzrzTeOpoqQC .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-ZQgpHzrzTeOpoqQC .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-ZQgpHzrzTeOpoqQC .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-ZQgpHzrzTeOpoqQC .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-ZQgpHzrzTeOpoqQC .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-ZQgpHzrzTeOpoqQC .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-ZQgpHzrzTeOpoqQC .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-ZQgpHzrzTeOpoqQC .cluster text{fill:#333;}#mermaid-svg-ZQgpHzrzTeOpoqQC .cluster span{color:#333;}#mermaid-svg-ZQgpHzrzTeOpoqQC 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-ZQgpHzrzTeOpoqQC .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-ZQgpHzrzTeOpoqQC rect.text{fill:none;stroke-width:0;}#mermaid-svg-ZQgpHzrzTeOpoqQC .icon-shape,#mermaid-svg-ZQgpHzrzTeOpoqQC .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-ZQgpHzrzTeOpoqQC .icon-shape p,#mermaid-svg-ZQgpHzrzTeOpoqQC .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-ZQgpHzrzTeOpoqQC .icon-shape .label rect,#mermaid-svg-ZQgpHzrzTeOpoqQC .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-ZQgpHzrzTeOpoqQC .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-ZQgpHzrzTeOpoqQC .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-ZQgpHzrzTeOpoqQC :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 否
是
初始化
运行中
处理请求
流程是否结束
进入结算
清理 / 持久化
如果状态只创建不清理,就容易残留脏数据。
如果状态更新时机不清楚,就容易出现前后不一致。
如果状态生命周期没有定义,后面加功能时,就会越来越难判断:
- 这个字段现在还能不能用?
- 这个状态是否已经过期?
- 当前请求是否还能继续修改它?
- 失败后是否应该清空?
- 成功后是否应该保留?
所以服务端维护状态时,至少要回答三个问题:
| 检查项 | 要问的问题 | 如果没想清楚,可能出现的问题 |
|---|---|---|
| 状态归属 | 这个状态属于用户、订单、任务,还是某个流程阶段? | 字段归属混乱,后续不知道放哪里 |
| 创建时机 | 这个状态什么时候生成? | 请求进来时找不到状态,或重复初始化 |
| 更新时机 | 哪些操作会修改它?成功和失败时是否都修改? | 状态更新不一致,前后流程对不上 |
| 清理时机 | 它什么时候结束,什么时候删除或持久化? | 残留脏数据,影响后续请求 |
| 可信来源 | 这个状态以客户端为准,还是服务端为准? | 前端参数被当成业务事实 |
| 返回方式 | 前端应该拿到完整状态,还是只拿局部结果? | 前端自己推导关键状态,导致不一致 |
这三个问题回答清楚之后,状态设计才不容易乱。
八、不要把服务端写成"前端参数的搬运工"
如果服务端只是接收前端参数,然后简单返回计算结果,那么项目早期可能能跑。
但随着业务复杂度增加,很容易出现问题。
比如⬇️:
- 前端传什么,后端就信什么
- 前端说当前分数是多少,后端就用多少
- 前端说当前还有几次机会,后端就信几次
- 前端说奖励未领取,后端就允许领取
这种设计短期很省事,但长期会让服务端失去最重要的价值:
维护可信业务规则。
更合理的方式应该是⬇️:
- 前端告诉后端:我想做什么
- 后端根据当前状态判断:你能不能做
- 如果能做:后端修改状态
- 如果不能做:后端返回失败原因
也就是⬇️:
- 客户端:提交操作意图
- 服务端:校验状态 + 执行业务规则 + 返回最新结果
| 对比项 | 参数搬运工式后端 | 规则中心式后端 |
|---|---|---|
| 对客户端参数的态度 | 前端传什么就信什么 | 只把参数当成操作意图 |
| 状态来源 | 依赖客户端传入 | 读取服务端真实状态 |
| 校验逻辑 | 校验较少,甚至不校验 | 基于当前状态重新校验 |
| 状态更新 | 可能由前端推导 | 由服务端统一更新 |
| 返回结果 | 返回简单成功提示 | 返回处理后的最新状态 |
| 常见风险 | 篡改、重复操作、状态不一致 | 逻辑更稳,业务事实可信 |
这时,服务端才真正成为系统里的规则中心,而不是前端参数的转发器。
九、一个简单的后端状态设计示例
假设有一个简化的操作系统,用户可以执行某个动作,每次动作都会消耗次数,并改变当前状态。
不要让前端传:
json
{
"remainingTimes": 3,
"currentScore": 120
}
然后服务端直接相信。
更合理的是,前端只传操作意图:
json
{
"userId": "u_001",
"action": "submit",
"selectedItems": ["A", "B"]
}
服务端自己维护状态:
ts
type UserRuntimeState = {
userId: string;
remainingTimes: number;
currentScore: number;
status: "running" | "finished";
};
处理流程类似这样:
ts
function handleUserAction(userId: string, selectedItems: string[]) {
const state = getUserRuntimeState(userId);
if (!state) {
return {
code: 404,
message: "state not found",
};
}
if (state.status !== "running") {
return {
code: 400,
message: "current process is not running",
};
}
if (state.remainingTimes <= 0) {
return {
code: 400,
message: "no remaining times",
};
}
const score = calculateScore(selectedItems);
state.remainingTimes -= 1;
state.currentScore += score;
if (state.remainingTimes <= 0) {
state.status = "finished";
}
return {
code: 200,
data: {
remainingTimes: state.remainingTimes,
currentScore: state.currentScore,
status: state.status,
},
};
}
这里的关键不是代码本身,而是职责边界:
- 前端只提交
selectedItems - 服务端自己读取当前状态
- 服务端自己判断是否允许操作
- 服务端自己更新
remainingTimes和currentScore - 前端只拿最终结果展示
这才是更稳定的状态归属方式。
十、通用设计结论
这次复盘后,我对"服务端状态归属"有了一个更清晰的判断:
状态是否应该由服务端维护,不取决于它是不是重要,而取决于它是否会影响业务结果。
如果一个状态只影响展示,可以放在前端。
如果一个状态会影响:
- 是否允许操作
- 是否进入下一阶段
- 是否结算成功
- 是否发放奖励
- 是否扣减资源
- 是否拥有权限
那它就应该由服务端维护。
客户端可以缓存它、展示它、提交基于它的操作。
但服务端必须拥有最终判断权。
十一、本篇小结
这一篇主要复盘了一个最基础但很重要的后端设计问题:
为什么关键状态不能交给客户端?
最终得到的结论是:
- 客户端状态适合展示,不适合作为最终业务依据
- 客户端提交的是操作意图,不是业务事实
- 服务端必须维护会影响业务结果的状态
- 服务端需要基于当前状态校验请求是否合法
- 状态设计不仅要考虑存在哪里,还要考虑什么时候创建、更新和清理
- 后端不应该只是参数搬运工,而应该维护可信业务规则
如果用一句话总结:
⚠️ 前端负责交互和展示,后端负责状态和规则。
这也是后续继续讨论状态流转、生命周期拆分、接口职责收敛、奖励结算边界的基础。
下一篇会继续复盘:
从功能接口到状态流转:后端系统复杂度是怎么出现的。