• 本文来自《后端系统设计复盘:从项目到通用后端》专栏。
  • 文章内容源于我正在实现的一个 Balatro 风格实时游戏后端项目,但本文不会重点讲某个具体游戏功能怎么写,而是抽出一个更通用的后端设计问题:在一个需要持续交互的系统里,哪些状态应该由服务端维护,为什么关键状态不能交给客户端
  • 这个问题看起来像是游戏后端里的状态同步问题,但后来我发现,库存、订单、任务、权限、奖励系统,其实都在面对同一个问题:谁才是业务事实的最终维护者。

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

  • 客户端可以保存展示状态,但不能作为关键业务状态的最终依据。
  • 只要一个状态会影响业务结果、流程推进、资源变化或权限判断,服务端就必须拥有最终判断权。
  • 后端不应该只是前端参数的搬运工,而应该负责维护可信状态、校验业务规则,并返回最新结果。

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

在实现这个项目的过程中,我最先遇到的一个基础问题其实很直接:

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

这里先简单解释一下当前项目里的几个概念,避免读者被游戏名词劝退。

当前项目里的概念 可以理解成 普通业务里的类比
手牌 当前用户可操作的数据集合 购物车商品、待处理任务、当前审批单
牌堆 后续可补充的数据来源 库存池、任务池、候选资源池
出牌 用户提交一次有效操作 下单、领取、提交审批
补牌 系统根据规则补充新数据 补库存、分配任务、推进流程
分数 本次操作产生的业务结果 金额、积分、奖励、审核结果

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

比如前端自己维护:

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

这样后端看起来会轻很多。前端发来什么,后端就根据参数算一下,然后返回一个结果,好像也能把功能先跑起来。

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

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

例如:

  • 当前手牌决定用户能执行什么操作
  • 剩余牌堆决定后续能补充什么内容
  • 出牌次数决定当前回合是否还能继续
  • 当前分数决定是否通过阶段
  • 当前进度决定下一步应该进入哪个流程

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

所以我在这个项目里得到的第一个设计结论是:

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

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


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

客户端当然需要状态。

没有状态,前端无法渲染页面,也无法给用户及时反馈。比如按钮是否高亮、卡牌是否被选中、弹窗是否打开、动画是否播放,这些都离不开前端状态。

但问题在于:

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

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

比如客户端告诉后端:

{
  "playerId": "player_1",
  "selectedCards": ["AH", "KH"],
  "action": "play"
}

这只能说明:

用户希望执行一次 play 操作,并且选择了 AHKH

但服务端不能直接相信这些前提都成立。

它不能默认相信:

  • 这两张牌一定在用户手里
  • 当前用户一定还有出牌次数
  • 当前流程一定还处于可操作状态
  • 当前请求一定没有被重复发送
  • 当前参数一定没有被篡改

所以服务端必须重新判断:

服务端需要判断的问题 为什么不能只信客户端
这名用户是否存在 客户端可以传任意 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 的实现过程;而本专栏会从项目里抽出一些更通用的后端设计问题,把具体功能背后的设计取舍讲清楚。

如果你对状态设计、接口拆分、规则系统、奖励系统和游戏后端工程实践感兴趣,也可以顺着主线系列继续看。

Logo

电商企业物流数字化转型必备!快递鸟 API 接口,72 小时快速完成物流系统集成。全流程实战1V1指导,营造开放的API技术生态圈。

更多推荐