为什么分布式系统要避免隐式共享状态?
简化版
分布式系统不是不能有共享状态,而是不能让状态的存在位置、所有权和更新规则对调用方不可见。隐式共享状态会让请求结果依赖“恰好落到哪台机器、谁刚改过内存、缓存是否同步”,从而破坏水平扩容、故障切换和并发正确性;应把必要状态放进有明确一致性协议的存储,并显式定义版本、生命周期和冲突处理。
详细版
隐式共享状态通常有三种表现:把会话只放在某个实例内存里,却假设后续请求还会落到该实例;多个实例读写同一份数据,却没有并发控制和一致性约定;通过本地缓存、静态变量或文件悄悄影响业务结果,却没有失效、恢复和观测机制。
它主要造成四类问题:
- 路由耦合:请求必须粘在特定实例上,实例重启或扩缩容后状态丢失。
- 并发冲突:多个副本同时修改同一对象,产生丢失更新、重复执行或覆盖新值。
- 故障不可恢复:状态只在进程内,故障接管节点不知道旧节点做到了哪一步。
- 行为不可解释:同样的请求落到不同副本得到不同结果,排障时也找不到统一事实来源。
工程上应先区分计算状态与业务事实。可重建的临时计算状态可以保留在本地,但要允许丢失;会影响余额、库存、权限、任务阶段等业务事实,要进入数据库、日志或协调系统,并通过唯一约束、乐观锁、事务、幂等键或共识机制保护。若为了性能使用缓存,要写清缓存是旁路副本还是事实来源、如何失效,以及读到旧值能容忍多久。
完整版教学
一、什么叫“隐式”共享状态
共享状态本身并不是错误:数据库中的订单、Redis 中的限流计数、配置中心里的开关都属于多节点共同访问的状态。问题出在“隐式”——代码没有把状态依赖体现在接口和协议中,却默认所有节点看见同一个值、按同一顺序修改,或者默认下一次请求仍由原节点处理。
例如登录接口把 userId 写入节点 A 的内存 Map,后续查询接口只从当前节点的 Map 取身份。负载均衡把第二个请求转给节点 B 时,B 会认为用户未登录;开启粘性会话只是暂时掩盖依赖,A 重启后状态仍会消失。这里真正缺失的是可恢复、可迁移的会话协议,而不是一条负载均衡规则。
记忆钩子:判断状态是否危险,要问三件事——它在哪里、谁能改、节点换掉后如何恢复。答不清的共享状态就是隐患。
二、状态为什么会阻碍水平扩展
无状态计算节点的好处是任意副本都能处理任意请求,扩容只需增加副本,缩容也不必搬迁业务事实。节点若持有不可丢的本地状态,调度器就不能随意替换它;一次普通滚动发布也会变成状态迁移问题。
假设集群有 10 个实例,每个实例保存 2 万个活跃会话,共 20 万个。扩容到 20 个实例后,新节点没有旧会话;若仍按原哈希路由,流量分配不均,若重新散列,大量用户会掉线。把会话存入具备过期策略的共享存储后,计算实例可以替换,但代价是每次访问多一次网络 I/O,因此常配合短时本地缓存并明确失效上限。
隐式本地状态:客户端 -> 负载均衡 -> 节点 A(会话只在 A)
节点 B(看不到)
显式状态: 客户端 -> 任意计算节点 -> 会话存储(统一键、TTL、版本)
三、并发读写为何会产生错误
当多个节点能够更新同一对象时,网络只负责传输,不会自动提供串行语义。最典型的是丢失更新:节点 A 和 B 同时读到库存 10,A 扣 3 写回 7,B 扣 4 写回 6,最终库存为 6,而正确结果应为 3。即使每个节点内部都加了线程锁,也只能保护本进程,挡不住另一个进程的写入。
应根据不变量选择约束。库存不能为负可以用数据库条件更新;配置修改可用版本号做 CAS;同一支付请求只能生效一次可用业务幂等键和唯一约束。关键不是把所有操作都改成分布式锁,而是让共享事实的更新规则由所有参与者共同遵守。
UPDATE inventory
SET available = available - 3, version = version + 1
WHERE sku_id = 42
AND available >= 3
AND version = 17;
-- 影响行数为 0 表示条件不成立或发生并发冲突,调用方不能假装成功
四、哪些状态可以留在本地
判断标准不是“本地快不快”,而是状态丢失后能否从权威来源重建,以及旧值会不会破坏业务不变量。连接池、编译后的模板、热点只读数据副本通常可以留在本地;余额、库存扣减结果、工作流阶段和权限授予记录通常不能只留在本地。
| 状态示例 | 是否适合仅存本地 | 原因与保护方式 |
|---|---|---|
| HTTP 连接池 | 适合 | 节点重启后可重建,不是业务事实 |
| 商品详情缓存 | 有条件适合 | 允许短暂旧值,需 TTL、版本或主动失效 |
| 用户登录会话 | 通常不适合 | 需要跨实例访问和故障恢复,可使用签名令牌或共享会话存储 |
| 账户余额 | 不适合 | 必须有持久化事实来源和原子并发控制 |
| 批处理临时进度 | 有条件适合 | 若可从输入重放可以本地保存,否则需持久化检查点 |
“可重建”也要算成本。一个缓存理论上能重建,但若冷启动要扫描 5 亿行并耗时 2 小时,它在灾难恢复层面就不是可以随便丢的状态,需要快照、预热或分批恢复方案。
五、显式化状态要设计哪些协议
把状态移到 Redis 或数据库并不代表问题结束。团队还要约定键的业务含义、创建者、修改者、版本规则、过期策略、并发冲突和失败恢复,否则只是把隐式内存状态变成隐式远程状态。
可以把协议写成一张状态契约:owner 表示哪个服务负责写,readers 表示谁可读,version 决定如何拒绝旧写入,ttl 决定生命周期,source_of_truth 指明权威数据源。跨服务共享一张表尤其危险,因为任何服务都可能绕过对方的校验;更稳妥的方式通常是由状态所有者通过 API 或事件发布变化。
state: user-risk-level
owner: risk-service
source_of_truth: risk_level_table
readers: [order-service, payment-service]
update_rule: compare-and-set by version
cache_ttl_seconds: 30
stale_read_tolerance_seconds: 30
六、本地缓存如何避免重新变成隐式事实源
缓存应被定义为权威数据的副本,而不是无法解释的第二套真相。读取路径要规定未命中时去哪里加载,写入路径要规定先写数据库还是先删缓存,异常时要规定允许返回旧值还是必须失败。不同业务容忍度不同:商品文案旧 30 秒通常问题不大,冻结账户状态旧 30 秒可能直接造成资损。
以 100 个实例、每实例缓存同一用户权限为例,一次权限撤销若只发出“尽力而为”的广播,99 个实例更新、1 个漏掉,就仍可能放行请求。高风险判断应回源权威存储、使用很短 TTL,或让权限版本进入令牌并由服务端校验;不能只说“我们用了缓存,所以访问一致”。
缓存命中率也不是唯一指标,还要观察旧值年龄、失效消息积压、回源 QPS 和版本不一致数。命中率 99% 但那 1% 恰好是撤权失败,并不是高质量系统。
七、故障恢复与可观测性
隐式状态最棘手的地方是故障后没人知道旧节点完成了什么。长任务只在内存中记录“已处理到第 800 条”,节点崩溃后,新节点可能从头执行造成重复,也可能错误地从第 801 条开始漏掉尚未提交的副作用。可靠方案应把检查点和副作用提交关系设计清楚,并让重复执行具备幂等性。
观测数据至少要包含状态版本、状态来源、缓存年龄、并发冲突次数和恢复次数。日志只写“处理失败”不足以定位,应关联业务键、请求幂等键和 Trace ID,但不能把密码、令牌等敏感状态直接打入日志。
请求进入 -> 读取权威版本 v17 -> 执行业务
-> 条件提交 v17 -> v18
-> 成功后记录幂等结果
冲突路径 -> 影响行数 0 -> 重新读取或明确返回冲突
故障恢复 -> 按幂等键查询结果,而不是盲目再次扣减
八、常见误区与追问
- 误区:无状态服务就是系统里完全没有状态。 无状态指计算实例不持有不可迁移的会话或业务事实,状态仍然存在,只是被放进职责明确、可恢复的组件中。
- 误区:开启粘性会话就解决了本地会话问题。 它只能提高请求回到同一节点的概率,无法解决节点重启、缩容、跨机房切换和热点倾斜。
- 误区:把数据放进 Redis 就天然一致。 Redis 只提供其命令和部署模式承诺的语义,数据库与缓存双写、故障切换和过期仍需单独设计。
- 误区:分布式锁可以保护所有共享状态。 锁有租约过期、持有者暂停和错误释放等边界,关键业务还需 fencing token、版本检查或数据库约束兜底。
- 追问:JWT 是否意味着服务完全无状态? JWT 把部分会话状态交给客户端携带,但撤销、密钥轮换、权限即时变更和刷新令牌仍可能需要服务端状态。
- 追问:本地缓存什么时候是合理的? 当数据可从权威来源重建、允许明确时长的旧值,并具备容量限制、失效和观测机制时合理。
- 追问:如何发现代码中的隐式状态? 检查静态可变字段、本地文件、进程内 Map、ThreadLocal、单机定时任务和依赖粘性路由的会话,并进行重启、换节点和并发写测试。
九、加强记忆
把这道题记成“位置、所有权、并发、恢复”四步。先指出状态放在哪里,再说明谁有权修改;接着给出多个节点同时读写时的约束,最后验证节点重启或切换后能否恢复。共享状态不可怕,可怕的是系统把它藏在进程内存、缓存副作用或路由假设里,却没有统一事实来源和冲突协议。