有状态与无状态服务:从Session外置到水平扩展的架构实践

发布时间:2026/9/7 20:35:57
有状态与无状态服务:从Session外置到水平扩展的架构实践 1. 一次线上故障让我重新理解状态这件事大概两年前我们团队接手过一个电商中台项目核心服务是订单管理。当时系统已经跑了大半年平时流量不算大一切看起来都很平稳。直到某次大促预热运营临时加了一波流量单量瞬间冲到平时的五六倍我们紧急把订单服务的实例从 3 个扩容到 10 个。扩容完成后诡异的事情发生了一部分用户登录后购物车直接清空另一部分用户下单时提示会话已过期客服那边瞬间被投诉淹没。排查了很久最后定位到问题就出在 Session 存储上。订单服务的代码里Session 默认存在本地内存原来只有 3 个实例的时候负载均衡策略恰好让同一用户的请求基本都打到同一台机器上所以一直没暴露。扩容之后同一用户的不同请求被分发到了不同实例本地 Session 互相不认账状态就断了。那次事故之后我花了大量时间重新梳理有状态服务和无状态服务这两个概念。说实话这两个词在技术圈里已经被说烂了几乎每篇架构文章都会提一嘴但真正能把它讲清楚、尤其是能在实际项目中做出正确判断的人并不多。很多人只是背了一个无状态可以水平扩展、有状态不能的结论遇到真实场景照样踩坑。这篇文章我想完整地聊一聊我对这两个概念的理解包括底层的存储位置差异、选型时的权衡逻辑、以及如何把一个有状态服务逐步改造成无状态服务的实操路径。内容面向后端开发、架构设计和技术负责人如果你正被 Session 一致性、水平扩展、分布式锁这些问题困扰这篇文章应该能给你一些参考。2. 有状态与无状态的本质差异状态到底藏在哪里要真正理解这两个概念不能停留在有状态就是保存数据、无状态就是不保存数据这种粗浅层面。我的理解是这两者的本质区别在于处理同一个请求时服务是否依赖请求之外、存储在自身内部的上下文数据。2.1 无状态服务每个请求都是独立的完整事务所谓无状态服务指的是服务处理一个请求所需的所有信息都包含在请求本身里面。请求进来服务根据入参计算结果并返回处理完之后不留下任何与业务相关的痕迹。下一个请求进来哪怕是同一个用户发出的服务也不会记得之前发生过什么。用生活化的类比来说无状态服务就像一家快餐店的取餐窗口。顾客报出餐号店员根据餐号去后厨取餐、递出去然后这件事就结束了。店员不需要记住顾客长什么样、上次点了什么、口味偏好是什么。每个顾客过来都是全新的一次互动彼此之间没有任何依赖。在代码层面体现为方法内部只用局部变量不访问实例字段、不修改静态变量、不读写本地文件、不依赖本地内存中的缓存数据。举个例子下面这个计算订单金额的服务就是典型的无状态Service public class OrderAmountCalculator { public BigDecimal calculate(Order order, UserCoupon coupon) { BigDecimal amount order.getTotalPrice(); if (coupon ! null coupon.isValid()) { amount amount.subtract(coupon.getDiscount()); } return amount.max(BigDecimal.ZERO); } }这个 service 里没有任何成员变量所有数据都来自方法参数计算结果只通过返回值传递。哪怕同时有 100 个实例在跑任何一个实例处理同一个订单算出来的结果都是一样的不会因为请求被分发到不同机器而产生差异。2.2 有状态服务处理逻辑依赖服务内部的上下文有状态服务则相反它处理请求时需要依赖存储在服务内部的数据。这些数据可能是内存里的变量、本地的缓存、会话对象、甚至本地磁盘上的临时文件。服务实例之间各自保存着不同的状态同一个请求打到不同实例上可能得到不同的结果。还是用快餐店来类比有状态服务就像一家高档西餐厅。客人第一次来服务员记住了他喜欢靠窗的位置、牛排要七分熟、对花生过敏。第二次这位客人再来服务员如果能认出他就能直接安排好一切。但问题在于如果这位客人换了一个服务员接待新服务员完全不认识他之前记住的那些偏好就全都没用了。代码层面的典型例子就是直接把 Session 存在内存里RestController public class CartController { // 错误示范使用内存存储用户的购物车数据 private MapString, Cart cartStorage new ConcurrentHashMap(); PostMapping(/cart/add) public Cart addToCart(RequestParam String userId, RequestParam String itemId) { Cart cart cartStorage.computeIfAbsent(userId, k - new Cart()); cart.addItem(itemId); return cart; } GetMapping(/cart) public Cart getCart(RequestParam String userId) { return cartStorage.getOrDefault(userId, new Cart()); } }这段代码的问题是cartStorage是实例内存中的成员变量每个服务实例都维护着自己的那份购物车数据。当用户第一次请求打到了实例 A购物车数据存进了 A 的内存第二次请求被负载均衡转发到了实例 BB 里找不到这个用户的购物车于是新建了一个空的。用户视角看到的就是购物车消失了。2.3 一张表说清楚核心差异我整理过一张对照表平时给团队做培训时也经常用这里分享出来对比维度无状态服务有状态服务数据存储位置请求内部参数、外部存储服务实例内部内存、本地文件实例间关系完全对等可随意替换不对等用户可能绑定特定实例水平扩展直接加实例即可需先解决状态同步问题故障恢复杀掉重启没问题重启后本地状态丢失负载均衡策略任意策略轮询、随机必须做会话保持Sticky Session典型场景API 网关、计算服务、定时任务数据库、缓存、WebSocket 连接服务这个表格只是帮助快速建立框架真正的理解还得看它在架构选型中是怎么影响决策的。3. 为什么说无状态服务是水平扩展的前提很多人对无状态 可水平扩展这个结论知其然不知其所以然。其实背后的逻辑非常直白就是状态存储位置决定了节点之间的耦合关系。3.1 扩展的本质把一个请求分给谁都能处理水平扩展的核心目标是通过增加节点数量来提升系统的整体吞吐量。但要让新增节点真正发挥作用前提是任何一个节点都能独立地处理任何一个请求。如果一个请求的处理依赖节点内部的历史状态那么新增节点就无法从其他节点继承这些状态它只能处理那些恰好被分配给它自己的新请求——甚至更糟如果负载均衡没有做会话保持原本属于其他节点的请求会被错误地分给它导致处理失败。这就是我开头提到的线上事故的根因。3 个实例时负载均衡的 IP Hash 策略恰好把同一用户的请求固定到同一台机器系统表面上跑得没问题扩容到 10 个之后Hash 映射变了同一用户的请求开始在不同的实例之间跳转而每个实例的本地 Session 数据都是独立的状态立刻断裂。无状态服务从根本上避免了这个问题。因为服务不保存任何本地状态请求被分给哪个实例都一样新增节点不需要学习任何历史数据直接就能处理所有请求。这也是容器化、弹性伸缩时代普遍追求无状态架构的根本原因——K8s 里 Pod 的销毁和重建如此随意、容器的重启如此频繁如果一个服务把自己的命根子绑在本地内存里那基本上任何风吹草动都会导致灾难。3.2 有状态服务的压舱石式存储设计这里必须澄清一个常见的误解并不是所有系统都必须搞成无状态也不是有状态就低人一等。像数据库、Redis、Kafka 这类基础设施天生就是有状态的它们正是无状态服务背后的状态存储层。你不可能让 MySQL 变成无状态的因为数据的持久性和可靠性本身就需要在节点内部保存状态。关键在于分层业务逻辑层尽量无状态化把需要持久化的状态下沉到专门的状态存储层数据库、缓存、消息队列。这样业务节点可以随意扩缩容而状态存储层作为压舱石来保证数据的可靠性。用一个简单的电商架构来说明无状态层用户服务、订单服务、商品服务可水平任意扩展 ↓ 状态层MySQL订单数据、用户数据、RedisSession、购物车、MQ消息我见过很多团队业务逻辑明明是可以通过请求参数直接计算的却非要把临时结果存到本地内存里美其名曰性能优化。结果一旦流量上来要扩容就发现自己被这个性能优化绑架了——扩容后数据不一致缩容后数据丢失最后只能靠 Sticky Session 这种止痛药来维持表面稳定。3.3 无状态化之后运维变得前所未有的简单这一点是我在实际操作中感受最深的。无状态化改造完成之前每次发版我们都得小心翼翼。由于 Session 在本地发布时要先摘掉一台实例的流量、等服务处理完存量请求再重启、然后再挂回去一台一台滚动整个发布过程得持续将近一个小时期间还得盯着监控看有没有报错。改造完成之后发布就变成了一件很随意的事情。实例直接全部杀掉、新版本拉起来、流量自动切过来整个过程一分钟内完成。服务的启动也从原来要加载本地缓存的用户会话数据变成了纯冷启动启动速度明显加快健康检查也更容易通过。如果你在维护一个有状态服务可以感受一下是不是每次发版都要小心翼翼是不是上线之后偶尔会出现部分用户数据丢失的投诉是不是扩缩容时总是会被会话保持限制住这些问题基本都能通过无状态化改造得到解决。4. Session 外置最有代表性的改造案例聊完理论我们看一个最有代表性的实战改造把 Session 从本地内存迁移到 Redis。这也是我踩过最多坑、也收获最多的一个改造场景。4.1 改造前本地 Session 的三种常见死法本地 Session 在单体应用时期确实够用但在分布式和服务化架构下它有三种非常典型的死法第一种死法扩容丢失。前面已经说过了实例增多后请求被分发到不同实例Session 数据互相看不到。这种情况在微服务架构里尤其严重因为服务拆细之后同一个用户的前端请求会依次经过网关、用户服务、订单服务等多个节点任何一个节点的 Session 不一致都会导致体验断裂。第二种死法重启丢失。发版、机器宕机、资源回收任何导致实例重启的事件都会让内存里的 Session 数据跟着陪葬。用户刚登录完服务一重启就掉线这在用户体验上是很难接受的。第三种死法会话保持绑架。有的团队为了规避前两种问题给负载均衡配置了 Sticky Session让同一用户的请求总是打到同一台实例上。这虽然暂时保住了 Session但也把服务死死地绑定在了用户—实例这个映射关系上。一旦这台实例发生故障或者需要重启维护这台机器上的所有用户都会一起遭殃。4.2 改造方案基于 Redis 的中心化 Session 存储把 Session 外置到 Redis 之后Session 数据就不再属于某一个服务实例而是属于所有服务实例共享的存储层。任何实例处理请求时都从 Redis 读取对应的 Session用完再写回去。实例重启不影响 Session 数据扩容缩容不需要担心请求路由变化。Spring Boot 环境下改造过程非常顺滑引入依赖并指定存储类型即可dependency groupIdorg.springframework.session/groupId artifactIdspring-session-data-redis/artifactId /dependencyspring: session: store-type: redis data: redis: host: redis-001.example.com port: 6379 password: xxxxxxxxx加上这段配置之后Spring 容器会自动用RedisIndexedSessionRepository替换掉默认的MapSessionRepository。原来代码里直接操作HttpSession的地方几乎不需要改动session.setAttribute()和session.getAttribute()的语义保持不变底层已经自动变成 Redis 读写了。这个改造前最值得关注的细节是Session 的序列化方式。默认的 JDK 序列化会把对象序列化成二进制流Redis 里存的是乱码一样的二进制数据排查问题的时候非常痛苦。建议配置成 JSON 序列化Bean public RedisSerializerObject springSessionDefaultRedisSerializer() { return new GenericJackson2JsonRedisSerializer(); }我当年第一次做这个改造时没有主动配置序列化方式默认 JDK 序列化倒是也能跑Session 数据确实写进了 Redis。但后来在 Redis 里排查一个用户的 Session 状态时看到满屏的\xAC\xED\x00\x05t\x00开头整个人是崩溃的。改了 JSON 序列化之后Redis 里可以直接看到 Session 里的字段值排查效率提升了一个量级。4.3 改造后的坑Redis 的超时和不一致Session 外置之后虽然解决了本地存储的问题但并不代表万事大吉。我在实践中踩过的坑主要有两个第一个坑是 Redis 操作超时。本地内存的读写是纳秒级的Redis 的读写则需要一次网络往返虽然通常只有几毫秒但在极端情况下Redis 连接池耗尽、网络抖动、Redis 主从切换Session 操作的延迟会飙升甚至会超时。这个问题在高并发场景下会被放大因为每个请求都要做一次 Session 读写等于给每个请求都增加了一次远程 IO。解决思路是给 Session 操作加合理的超时时间同时做好 Redis 的高可用。更进一步的优化是给读多写少的 Session 数据加一层本地缓存比如把 Session 读取结果短暂地缓存在服务实例本地设置 1-2 秒的过期时间这样大部分请求命中本地缓存不需要访问 Redis。但要注意引入本地缓存之后同一个用户短时间内修改 Session 后立即读取的场景可能会读到旧数据需要根据业务场景权衡。第二个坑是 Session 数据的序列化兼容性。如果你改动了 Session 中存储的对象的类结构比如加了字段、改了字段类型微服务之间存在版本不一致的情况反序列化时可能直接报错。解决方式是给 Session 中存储的对象设计稳定的数据结构增加兼容性字段并且在服务上线前做好新老服务混跑期间的验证。提示Session 外置改造本身并不难真正考验人的是改造后对 Redis 的依赖管理、缓存策略和对象结构的长期演进。建议改造前先想清楚这几个问题再动手。5. 架构选型的核心逻辑什么时候必须保留状态虽然无状态化是大势所趋但我们必须清醒地认识到有些场景天然需要状态强行无状态化反而会增加架构复杂度。选型时不该问能不能做成无状态而该问状态放在哪里最合理。5.1 必须保留状态的典型场景数据库和缓存类中间件是所有系统的基础设施它们需要保证数据持久性和一致性天然就是有状态的。你可能听过无状态 MySQL这种说法但这只是指 MySQL 的接入层比如 Proxy 层无状态底层的存储节点依然要保存数据。你对 MySQL 做的数据分片、主从复制本质都是对有状态节点之间的数据关系进行管理。WebSocket 长连接服务也是典型的有状态场景。一个客户端和某个服务实例建立了 WebSocket 连接之后后续的服务端推送必须通过这条连接不可能让消息打到别的实例。这就意味着服务端必须保存用户—连接—实例的绑定关系。这种场景下纯粹的无状态化做不到替代方案是引入专门的连接注册中心比如 Redis 或 ZooKeeper让任意实例都能查到这个用户当前连在哪台实例上再把消息转发过去。分布式锁服务、分布式 ID 生成器这类并发协调服务本身的设计目标就是在多个节点之间维持统一的状态视图天然有状态。拿 Redis 的分布式锁来说锁的状态谁持有了锁、什么时候过期必须存在 Redis 里任何实例获取锁时都要读取这个全局状态否则锁就失去意义了。5.2 无状态服务的理想适用范围反过来以下类型的服务应该优先做成无状态纯计算型服务订单价格计算、风控规则校验、推荐算法打分、数据清洗转换。输入数据来自请求或存储层处理结果直接返回或写入下游存储不需要在本地保存任何中间状态。API 网关和 BFF 层负责请求转发、鉴权、限流、聚合。它在处理一个请求时不需要记住客户端上一次请求做了什么所有上下文都从请求头或下游服务获取。定时任务调度器分布式任务调度的执行节点。任务定义放数据库执行节点每次从任务中心拉取任务并执行执行完毕后汇报结果。节点自身不保存任务状态挂了可以被其他节点顶替。消息队列的消费者处理消息的业务逻辑。消息内容本身就携带了足够的信息处理完成后的结果写入外部存储消费者实例可以随时增加或减少。注意并不是说以上场景一定不能有状态。比如消息消费者如果依赖内存中的去重表来保证幂等那它就有了隐式状态重启后内存去重表丢失可能造成重复消费。正确的做法是把这个去重状态也外置到存储层。5.3 选型时的两个关键判断标准在实际工作中我判断一个服务该不该有状态主要看两个问题第一个问题状态丢失是否会造成不可接受的后果如果状态丢了用户数据会丢、服务会错乱、钱会算错那这个状态就必须外置到可靠的存储中服务的有状态程度要尽可能低。如果状态丢了最多重新计算一下或者对业务影响很小那留在本地也问题不大。第二个问题状态是否能跟随请求进行传递如果一个状态数据可以简单地在请求之间传递比如通过请求头、URL 参数、请求体传入那就没有理由把它存在服务端内部。只有那些必须由服务端维护、跨多个请求长期存在的数据才需要认真考虑它的存储位置。这两个问题的答案会直接引导你决定状态是留在内存、放进 Redis、还是下沉到 MySQL以及整个服务的架构形态。6. 从有状态到无状态的完整改造路径我踩过的坑都在这最后我把一次完整的改造路径整理出来。这套流程我在多个项目中验证过已经相当稳定你可以直接照着走一遍。6.1 第一步全面盘点服务里的隐式状态改造的第一步不是写代码而是做审计。你要彻底搞清楚当前服务内部到底存了哪些状态具体包括成员变量和静态变量private MapString, Session、private static ListConfig这类以及其生命周期。本地文件服务写入本地磁盘的临时文件、业务数据文件。本地缓存Caffeine、Guava Cache 等本地缓存组件。进程内单例对象比如某些配置了单例模式的组件内部可能有随时间变化的状态。这一步需要翻源码逐个类看每一处写入了本地存储的代码都要记录下来。注意内嵌的 H2、SQLite 数据库也属于本地存储同样会导致有状态问题。6.2 第二步按状态类型制定外置方案审计完成之后把这几种状态分门别类采取不同的外置策略原状态位置外置目标改造要点本地 SessionRedisTTL 设 30-60 分钟配置序列化器注意超时隔离本地缓存Redis 或 Caffeine 多级缓存设置合理的过期时间做好缓存穿透保护本地文件对象存储如 S3、MinIO或数据库避免单点磁盘依赖内存队列消息队列如 Kafka、RocketMQ注意消息顺序性和消费幂等内存任务状态数据库存入任务表和进度表用乐观锁保证并发更新安全6.3 第三步接口幂等性设计状态外置之后会引入一个新的问题网络异常时消息或请求可能被重复发送。无状态服务天然不记得这个请求我是否处理过所以需要在存储层做幂等控制。最常见的做法是引入幂等键。比如订单回调接口要求调用方传入一个requestId服务端处理前先查数据库如果这个requestId已经存在说明是重复请求直接返回之前的结果不再重复处理如果不存在则先插入requestId记录再执行业务逻辑。6.4 第四步灰度验证与回滚预案这个步骤看着不起眼但往往决定改造的成败。我的建议是不要一次把所有状态全部迁移到新方案至少要分两批灰度。第一批放 10% 的流量观察 Session 接口的 P99 延迟、错误率、Redis 的内存增长情况确认稳定后扩大到 50%最后全量。回滚预案同样要提前准备好。由于代码层面读 Redis和读本地 Session是互斥的如果 Redis 出现了不可用最稳妥的回滚方式是快速切回旧版本代码。保留旧版本镜像配置开关做好回滚就能做到分钟级。6.5 第五步踩坑提醒——这些地方容易翻车我在多个项目中反复经历的坑一共四个提前说出来供大家参考本地缓存与 Redis 缓存双写不一致。很多改造会把本地缓存和Redis 缓存混在一起用一旦数据更新了两边没有同步就会出现部分实例读到旧值、部分实例读到新值。如果坚持用多级缓存一定设计好缓存的版本号或使用消息通知主动失效本地缓存。Redis 大 Key 和热 Key 问题。Session 数据如果存了大量信息或者某些用户的 Session 被频繁访问可能形成大 Key 和热 Key。大 Key 会导致 Redis 单次操作耗时增加热 Key 会导致单个 Redis 分片负载过高。建议控制 Session 大小必要时对 Session 数据做拆分。序列化框架升级导致的兼容性问题。如果你的 Session 对象经历了 Fastjson → Jackson 这样的序列化器切换旧数据可能反序列化失败。切换前一定要在测试环境用生产数据做完整的验证。网络分区下的雪崩。Redis 一旦故障所有依赖 Session 的服务都会超时如果超时时间设置得太长连接池会被占满雪崩就来了。建议设置短超时200ms-500ms并且做好降级逻辑——比如 Redis 挂了时允许用户重新登录而不是直接 5xx。7. 面试和团队协作中这个话题的真正考点有状态服务和无状态服务的区别是面试中出现频率极高的问题但很多候选人的回答都停留在背诵层面。我在面试别人时一般会顺着这个话题往前追问三个层次能完整答出来的人很少这里也分享给你作为自查标准。第一层能说出定义。这是最基础的大多数人都过关了。第二层能结合场景给出方案。比如我会问如果 Session 存在本地怎么解决分布式环境下 Session 共享的问题这时候能说出会话保持 Session 外置 Redis 改造无状态三种方案并分析各自适用场景的候选人已经算很不错的了。第三层能讲清楚取舍。我还会追问Session 放 Redis 是万能的吗如果 Redis 也挂了怎么办这个问题的核心目的是考察候选人能不能意识到没有完美方案只有权衡。能想到 Redis 高可用部署、降级为无登录态状态、尽量压缩 Session 大小这些措施的人说明他真正理解了状态管理的本质。如果你在准备面试我建议你不要只背结论而是用这篇文章里的思路把状态存储位置 → 可扩展性 → 故障影响面 → 存储选型 → 兜底方案这条链整个走一遍。真正理解这个链路比记住十个区别点有用得多。8. 升级补充不可变基础设施理念带来的新思考无状态服务的理念再往前走一步就是现在云原生领域常说的不可变基础设施Immutable Infrastructure。这个理念和有状态/无状态有很强的关联值得在这里补一笔。在传统运维模式下服务器被看作宠物需要细心呵护手动升级补丁、手动安装软件、手动修改配置文件这些操作会逐步改变服务器内部的状态也让服务器的状态变得越来越不可复现。而不可变基础设施理念提倡把服务器当作牛羊一旦部署上去就不要再修改它需要变更时直接销毁重来重新拉一个基于新镜像的实例。这个理念背后的前提恰恰是无状态服务。因为服务不保存本地状态所以实例销毁重建不会造成数据丢失因为所有状态都在外部存储层所以新实例拉起来之后可以立即处理请求不需要初始化加载状态的过程。可以说没有无状态化作为基础不可变基础设施就是空中楼阁。从这个角度回头看你会发现有状态和无状态不只是两个技术名词而是一套影响整个系统生命周期的方法论。从代码层面的局部变量到部署层面的弹性伸缩再到基础设施层面的不可变重建一脉相承。我自己经历了从有状态服务的隐痛到无状态化的脱胎换骨最大的感受是状态管理是架构设计的试金石。每当你遇到扩展性、稳定性、部署效率的问题都可以回到这个原点问一句这个状态真的需要放在这里吗把状态放在该放的地方——通常是最可靠的存储层而不是业务实例的内存里——很多问题会在根源上消失。这是我在这条路上踩了很多坑之后最想分享给同行的经验。

相关新闻