后端复盘(1):服务端状态归属设计,为什么关键状态不能交给客户端
- 本文来自《后端系统设计复盘:从项目到通用后端》专栏。
- 文章内容源于我正在实现的一个 Balatro 风格实时游戏后端项目,但本文不会重点讲某个具体游戏功能怎么写,而是抽出一个更通用的后端设计问题:在一个需要持续交互的系统里,哪些状态应该由服务端维护,为什么关键状态不能交给客户端。
- 这个问题看起来像是游戏后端里的状态同步问题,但后来我发现,库存、订单、任务、权限、奖励系统,其实都在面对同一个问题:谁才是业务事实的最终维护者。
这篇文章想表达的核心观点是:
- 客户端可以保存展示状态,但不能作为关键业务状态的最终依据。
- 只要一个状态会影响业务结果、流程推进、资源变化或权限判断,服务端就必须拥有最终判断权。
- 后端不应该只是前端参数的搬运工,而应该负责维护可信状态、校验业务规则,并返回最新结果。
文章目录
一、这个问题是怎么出现的
在实现这个项目的过程中,我最先遇到的一个基础问题其实很直接:
发牌、出牌、补牌这些状态,到底应该由谁维护?
这里先简单解释一下当前项目里的几个概念,避免读者被游戏名词劝退。
| 当前项目里的概念 | 可以理解成 | 普通业务里的类比 |
|---|---|---|
| 手牌 | 当前用户可操作的数据集合 | 购物车商品、待处理任务、当前审批单 |
| 牌堆 | 后续可补充的数据来源 | 库存池、任务池、候选资源池 |
| 出牌 | 用户提交一次有效操作 | 下单、领取、提交审批 |
| 补牌 | 系统根据规则补充新数据 | 补库存、分配任务、推进流程 |
| 分数 | 本次操作产生的业务结果 | 金额、积分、奖励、审核结果 |
如果只从实现难度看,把很多状态交给前端维护会很方便。
比如前端自己维护:
- 当前手牌
- 剩余牌堆
- 出牌次数
- 当前分数
- 当前阶段进度
这样后端看起来会轻很多。前端发来什么,后端就根据参数算一下,然后返回一个结果,好像也能把功能先跑起来。
但继续往后想,就会发现这个方案的问题很大。
因为这些数据并不只是“展示用数据”,它们会直接影响后续业务结果。
例如:
- 当前手牌决定用户能执行什么操作
- 剩余牌堆决定后续能补充什么内容
- 出牌次数决定当前回合是否还能继续
- 当前分数决定是否通过阶段
- 当前进度决定下一步应该进入哪个流程
也就是说,这些状态一旦出错,影响的不是页面展示,而是整个业务流程。
所以我在这个项目里得到的第一个设计结论是:
只要一个状态会影响业务结果,它就不应该只由客户端维护。
客户端可以展示状态,也可以发起操作,但真正可信的状态,应该由服务端维护。
二、客户端状态为什么不适合作为最终依据
客户端当然需要状态。
没有状态,前端无法渲染页面,也无法给用户及时反馈。比如按钮是否高亮、卡牌是否被选中、弹窗是否打开、动画是否播放,这些都离不开前端状态。
但问题在于:
客户端状态适合展示,不适合作为最终业务判断依据。
因为从服务端角度看,客户端传来的所有内容都只能算“请求意图”,而不是“事实本身”。
比如客户端告诉后端:
{
"playerId": "player_1",
"selectedCards": ["AH", "KH"],
"action": "play"
}
这只能说明:
用户希望执行一次
play操作,并且选择了AH、KH。
但服务端不能直接相信这些前提都成立。
它不能默认相信:
- 这两张牌一定在用户手里
- 当前用户一定还有出牌次数
- 当前流程一定还处于可操作状态
- 当前请求一定没有被重复发送
- 当前参数一定没有被篡改
所以服务端必须重新判断:
| 服务端需要判断的问题 | 为什么不能只信客户端 |
|---|---|
| 这名用户是否存在 | 客户端可以传任意 playerId |
| 当前流程是否允许操作 | 前端展示状态可能已经过期 |
| 这些牌是否真的在当前手牌中 | 客户端参数可能被篡改 |
| 是否存在重复选择 | 前端校验可能失效或被绕过 |
| 是否超过选择数量限制 | 业务规则必须由服务端兜底 |
| 当前是否还有操作次数 | 次数会影响后续流程和结算 |
只有这些校验都通过,服务端才能真正修改状态。
这就是服务端状态维护的核心价值:
不是为了重复前端逻辑,而是为了保证业务事实可信。
三、什么状态必须放在服务端
并不是所有状态都必须放在服务端。
有些状态只影响展示,比如:
- 当前按钮是否高亮
- 某个弹窗是否打开
- 卡牌动画是否播放中
- 当前 tab 选中了哪个
- 鼠标 hover 到了哪里
这些状态即使错了,通常也只是影响页面表现,不会改变业务结果。
但另一类状态就不一样了。
只要它会影响后续流程、结算结果、资源变化或权限判断,就应该由服务端维护。
可以简单整理成下面这张表:
| 状态类型 | 是否适合只放客户端 | 最终判断方 | 原因 |
|---|---|---|---|
| 页面动画状态 | 可以 | 前端 | 只影响展示效果 |
| 弹窗开关状态 | 可以 | 前端 | 不影响业务结果 |
| 当前 tab / hover 状态 | 可以 | 前端 | 只影响交互体验 |
| 当前手牌 / 库存 / 资源数量 | 不适合 | 服务端 | 会影响后续操作 |
| 剩余操作次数 | 不适合 | 服务端 | 会影响是否允许继续执行 |
| 当前分数 / 金额 / 结算结果 | 不适合 | 服务端 | 会影响胜负、支付、奖励 |
| 阶段进度 / 流程状态 | 不适合 | 服务端 | 会影响下一步进入哪个流程 |
| 权限 / 资格 / 是否可领取 | 不适合 | 服务端 | 会影响用户是否能获得权益 |
所以判断一个状态归属时,可以先问一句:
如果这个状态被前端篡改,会不会影响最终业务结果?
如果答案是“会”,那它就不应该只由客户端维护。
这个判断在很多业务系统里都适用。
| 判断问题 | 如果答案是“是” | 状态更适合放在哪里 |
|---|---|---|
| 会影响用户能不能操作吗? | 是 | 服务端 |
| 会影响资源是否扣减吗? | 是 | 服务端 |
| 会影响奖励是否发放吗? | 是 | 服务端 |
| 会影响流程是否推进吗? | 是 | 服务端 |
| 只影响页面展示吗? | 是 | 前端 |
一句话概括就是:
展示状态可以在前端,业务事实必须在服务端。
四、服务端维护状态,不等于前端什么都不管
这里容易产生一个误解:
既然状态由服务端维护,那前端是不是就不能保存状态了?
不是这样的。
更合理的分工应该是:
也就是说,前端当然可以缓存当前页面需要展示的数据。
但这些数据只应该作为“展示副本”,而不是“最终事实”。
比如在一次“出牌”操作里,前后端的职责可以拆成这样:
| 步骤 | 前端负责什么 | 服务端负责什么 |
|---|---|---|
| 初始展示 | 根据服务端返回的数据渲染当前手牌 | 提供当前可信的手牌状态 |
| 用户操作 | 收集用户选择的卡牌,并提交操作意图 | 不直接相信参数,而是准备重新校验 |
| 规则校验 | 可以做基础交互校验 | 校验卡牌是否真实存在、流程是否允许、次数是否足够 |
| 状态更新 | 等待最新结果并重新渲染 | 更新真实手牌、牌堆、次数、分数等状态 |
| 结果返回 | 根据返回结果刷新页面 | 返回最新状态或失败原因 |
也就是说,前端可以维护展示副本,但不能决定这次操作是否真实有效。 真正决定业务事实的,仍然是服务端保存的状态和规则。
这个流程里,前端也有状态,但它不是最终决策者。
真正决定“这次操作是否有效”的,是服务端。
这也是很多后端系统里通用的设计方式:
客户端提交意图,服务端判断事实。
五、一个更通用的状态流转模型
如果把具体业务拿掉,服务端状态维护大概可以抽象成下面这个流程:
这个流程的重点在于:
服务端不是只处理客户端发来的参数,而是要结合“当前真实状态”一起判断。
因为同一个请求,在不同状态下,结果可能完全不同。
比如用户点击“领取奖励”。
如果当前状态是:rewardStatus = "pending";,那可能允许领取。
但如果当前状态已经是:rewardStatus = "claimed";,那就应该拒绝重复领取。
同样一个接口,同样一个用户动作,是否能成功取决于服务端当前状态。
这也是为什么后端不能只写成“参数处理器”,而应该成为“状态推进器”。
六、状态归属判断:看它是否影响结果
在实际项目里,我会倾向于用一个简单判断标准:
这个状态是否会影响最终业务结果?
如果会,它就应该由服务端维护。
| 业务场景 | 客户端可以展示什么 | 服务端必须维护什么 | 如果只信客户端会怎样 |
|---|---|---|---|
| 游戏场景 | 当前手牌、分数、次数 | 真实手牌、牌堆、得分、阶段进度 | 非法出牌、重复操作、错误结算 |
| 电商场景 | 库存展示、优惠信息、订单金额预览 | 真实库存、优惠券状态、订单金额、支付状态 | 超卖、优惠滥用、金额被篡改 |
| 活动任务 | 任务进度、奖励展示 | 完成状态、领取状态、活动有效期 | 重复领取、越权领取、过期领取 |
| 权限系统 | 按钮展示、入口隐藏 | 用户角色、权限范围、操作资格 | 越权访问或非法修改 |
1. 游戏场景
例如当前手牌、牌堆剩余数量、剩余出牌次数、当前分数、当前阶段进度,这些都会影响后续操作和最终结算。
所以它们应该由服务端维护。
客户端可以展示这些数据,但不能自己决定这些数据是否成立。
2. 电商场景
例如商品库存、优惠券是否可用、订单金额、支付状态、是否已发货,这些状态也不能只靠前端维护。
前端可以展示“库存还剩 3 件”,但下单时,服务端仍然要重新判断库存是否足够。
因为库存可能已经被其他用户占用或扣减。
3. 任务 / 活动场景
例如用户是否完成任务、奖励是否已领取、活动是否过期、当前积分是否满足条件,这些数据都会影响用户是否能获得权益。
所以它们也必须由服务端维护。
如果只靠前端判断,就很容易出现重复领取、越权领取、过期领取等问题。
4. 权限场景
例如当前用户是否有权限访问、当前角色是否能修改数据、当前操作是否超出范围,这些更不能交给客户端。
因为权限判断本身就是服务端必须守住的边界。
七、服务端状态设计需要注意什么
服务端维护状态,不只是把数据存在内存、Redis 或数据库里就结束了。
更重要的是要想清楚:
这个状态什么时候创建?什么时候更新?什么时候清理?
也就是说,状态一定有生命周期。
例如一个运行中的业务状态,通常会经历:
如果状态只创建不清理,就容易残留脏数据。
如果状态更新时机不清楚,就容易出现前后不一致。
如果状态生命周期没有定义,后面加功能时,就会越来越难判断:
- 这个字段现在还能不能用?
- 这个状态是否已经过期?
- 当前请求是否还能继续修改它?
- 失败后是否应该清空?
- 成功后是否应该保留?
所以服务端维护状态时,至少要回答下面几个问题:
| 检查项 | 要问的问题 | 如果没想清楚,可能出现的问题 |
|---|---|---|
| 状态归属 | 这个状态属于用户、订单、任务,还是某个流程阶段? | 字段归属混乱,后续不知道放哪里 |
| 创建时机 | 这个状态什么时候生成? | 请求进来时找不到状态,或重复初始化 |
| 更新时机 | 哪些操作会修改它?成功和失败时是否都修改? | 状态更新不一致,前后流程对不上 |
| 清理时机 | 它什么时候结束,什么时候删除或持久化? | 残留脏数据,影响后续请求 |
| 可信来源 | 这个状态以客户端为准,还是服务端为准? | 前端参数被当成业务事实 |
| 返回方式 | 前端应该拿到完整状态,还是只拿局部结果? | 前端自己推导关键状态,导致不一致 |
这些问题回答清楚之后,状态设计才不容易乱。
八、不要把服务端写成“前端参数的搬运工”
如果服务端只是接收前端参数,然后简单返回计算结果,那么项目早期可能能跑。
但随着业务复杂度增加,很容易出现问题。
比如:
- 前端传什么,后端就信什么
- 前端说当前分数是多少,后端就用多少
- 前端说当前还有几次机会,后端就信几次
- 前端说奖励未领取,后端就允许领取
这种设计短期很省事,但长期会让服务端失去最重要的价值:
维护可信业务规则。
更合理的方式应该是:
- 前端告诉后端:我想做什么
- 后端根据当前状态判断:你能不能做
- 如果能做:后端修改状态
- 如果不能做:后端返回失败原因
也就是说:
| 角色 | 负责什么 |
|---|---|
| 客户端 | 提交操作意图 |
| 服务端 | 校验状态、执行业务规则、返回最新结果 |
这时,服务端才真正成为系统里的规则中心,而不是前端参数的转发器。
可以再做一个对比:
| 对比项 | 参数搬运工式后端 | 规则中心式后端 |
|---|---|---|
| 对客户端参数的态度 | 前端传什么就信什么 | 只把参数当成操作意图 |
| 状态来源 | 依赖客户端传入 | 读取服务端真实状态 |
| 校验逻辑 | 校验较少,甚至不校验 | 基于当前状态重新校验 |
| 状态更新 | 可能由前端推导 | 由服务端统一更新 |
| 返回结果 | 返回简单成功提示 | 返回处理后的最新状态 |
| 常见风险 | 篡改、重复操作、状态不一致 | 逻辑更稳,业务事实可信 |
九、一个简单的后端状态设计示例
假设有一个简化的操作系统,用户可以执行某个动作,每次动作都会消耗次数,并改变当前状态。
不要让前端传:
{
"remainingTimes": 3,
"currentScore": 120
}
然后服务端直接相信。
更合理的是,前端只传操作意图:
{
"userId": "u_001",
"action": "submit",
"selectedItems": ["A", "B"]
}
服务端自己维护状态:
type UserRuntimeState = {
userId: string;
remainingTimes: number;
currentScore: number;
status: "running" | "finished";
};
处理流程类似这样:
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,服务端自己读取当前状态、判断是否允许操作、更新状态并返回最新结果。
十、通用设计结论
这次复盘后,我对“服务端状态归属”有了一个更清晰的判断:
状态是否应该由服务端维护,不取决于它是不是重要,而取决于它是否会影响业务结果。
如果一个状态只影响展示,可以放在前端。
如果一个状态会影响:
- 是否允许操作
- 是否进入下一阶段
- 是否结算成功
- 是否发放奖励
- 是否扣减资源
- 是否拥有权限
那它就应该由服务端维护。
客户端可以缓存它、展示它、提交基于它的操作。
但服务端必须拥有最终判断权。
十一、本篇小结
这一篇主要复盘了一个最基础但很重要的后端设计问题:
为什么关键状态不能交给客户端?
最终得到的结论是:
- 客户端状态适合展示,不适合作为最终业务依据
- 客户端提交的是操作意图,不是业务事实
- 服务端必须维护会影响业务结果的状态
- 服务端需要基于当前状态校验请求是否合法
- 状态设计不仅要考虑存在哪里,还要考虑什么时候创建、更新和清理
- 后端不应该只是参数搬运工,而应该维护可信业务规则
如果用一句话总结:
前端负责交互和展示,后端负责状态和规则。
这也是后续继续讨论状态流转、生命周期拆分、接口职责收敛、奖励结算边界的基础。
下一篇会继续复盘:
从功能接口到状态流转:后端系统复杂度是怎么出现的。
🃏 关于这个项目
本复盘专栏的内容,来自我正在实现的一个 Balatro 风格实时游戏后端项目。
主线系列 《实时游戏后端工程实践:从 Balatro 出发》 会记录项目从 0 到 1 的实现过程;而本专栏会从项目里抽出一些更通用的后端设计问题,把具体功能背后的设计取舍讲清楚。
如果你对状态设计、接口拆分、规则系统、奖励系统和游戏后端工程实践感兴趣,也可以顺着主线系列继续看。
更多推荐





所有评论(0)