后端复盘(1):服务端状态归属设计,为什么关键状态不能交给客户端

  • 本文是《后端系统设计复盘:从游戏项目到通用后端》专栏中的第一篇。
  • 这个专栏的内容,来自我正在实现的一个 Balatro 风格实时游戏后端项目。主线实现过程记录在 《实时游戏后端工程实践:从 Balatro 出发》 专栏中。
  • 不过这篇不会重点讲某个具体游戏功能怎么写,而是从项目里抽出一个更通用的后端问题:在一个需要持续交互的系统里,哪些状态应该由服务端维护,为什么关键状态不能交给客户端。

这篇文章想表达的核心观点是:

  • 客户端可以保存展示状态,但不能作为关键业务状态的最终依据。
  • 只要一个状态会影响业务结果、流程推进、资源变化或权限判断,服务端就必须拥有最终判断权。

文章目录

一、这个问题是怎么出现的

在项目早期,我最先遇到的一个问题其实很简单:

发牌、出牌、补牌这些状态,到底应该由谁维护?

如果只从实现难度看,把很多状态交给前端会很方便。

比如前端自己维护:

  • 当前手牌
  • 剩余牌堆
  • 出牌次数
  • 当前分数
  • 当前阶段进度

这样后端看起来就会轻很多。前端发来什么,后端就根据参数算一下,然后返回结果。

但继续往后想,就会发现这个方案的问题很大。

因为这些数据并不只是"展示用数据",它们会直接影响后续业务结果。

例如:

  • 当前手牌决定玩家能出什么牌
  • 剩余牌堆决定后续能补什么牌
  • 出牌次数决定当前回合是否还能继续
  • 当前分数决定是否通过阶段
  • 当前进度决定下一步应该进入哪个流程

也就是说,这些状态一旦出错,影响的不是页面展示,而是整个业务流程。

所以我最后得出的第一个设计结论是:

⚠️ 只要一个状态会影响业务结果,它就不应该只由客户端维护。

客户端可以展示状态,也可以发起操作,但真正可信的状态,应该由服务端维护。


二、客户端状态为什么不适合作为最终依据

客户端当然需要状态。

没有状态,前端无法渲染页面,也无法给用户及时反馈。

但问题在于:

客户端状态适合展示,不适合作为最终业务判断依据。

因为从服务端角度看,客户端传来的所有内容都只能算"请求意图",而不是"事实本身"。

比如客户端告诉后端:

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
  • 服务端自己读取当前状态
  • 服务端自己判断是否允许操作
  • 服务端自己更新 remainingTimescurrentScore
  • 前端只拿最终结果展示

这才是更稳定的状态归属方式。


十、通用设计结论

这次复盘后,我对"服务端状态归属"有了一个更清晰的判断:

状态是否应该由服务端维护,不取决于它是不是重要,而取决于它是否会影响业务结果。

如果一个状态只影响展示,可以放在前端。

如果一个状态会影响:

  • 是否允许操作
  • 是否进入下一阶段
  • 是否结算成功
  • 是否发放奖励
  • 是否扣减资源
  • 是否拥有权限

那它就应该由服务端维护。

客户端可以缓存它、展示它、提交基于它的操作。

但服务端必须拥有最终判断权。


十一、本篇小结

这一篇主要复盘了一个最基础但很重要的后端设计问题:

为什么关键状态不能交给客户端?

最终得到的结论是:

  • 客户端状态适合展示,不适合作为最终业务依据
  • 客户端提交的是操作意图,不是业务事实
  • 服务端必须维护会影响业务结果的状态
  • 服务端需要基于当前状态校验请求是否合法
  • 状态设计不仅要考虑存在哪里,还要考虑什么时候创建、更新和清理
  • 后端不应该只是参数搬运工,而应该维护可信业务规则

如果用一句话总结:

⚠️ 前端负责交互和展示,后端负责状态和规则。

这也是后续继续讨论状态流转、生命周期拆分、接口职责收敛、奖励结算边界的基础。

下一篇会继续复盘:

从功能接口到状态流转:后端系统复杂度是怎么出现的。