一、先分清概念:什么是有状态 / 无状态
- 有状态服务 (Stateful)
实例本地存储用户会话、临时数据、连接信息;同一个用户后续请求必须落到同一台服务器。 例:服务器内存存登录 session、本地内存缓存购物车、本机文件保存临时任务。
- 无状态服务 (Stateless)
每一台服务实例完全对等;任何一台实例都可以处理任意客户端请求;实例重启、销毁不会丢失业务数据。 请求需要的全部上下文通过请求头、Cookie‑Token、Redis、数据库携带。
核心对比:
| 对比维度 | 有状态服务(Stateful) | 无状态服务(Stateless) |
|---|---|---|
| 核心定义 | 实例本地保存会话、临时上下文,请求依赖上一次请求 | 服务实例不存储业务状态;上下文全部外置,所有实例完全对等 |
| 请求调度 | 需要会话黏连、IP‑Hash,同一用户固定访问同一节点 | 轮询、最小连接、随机、权重,任意实例均可处理请求 |
| 水平扩容 | 困难;新增节点不能承接已有用户状态 | 简单,随时新增实例实现弹性扩容 |
| 实例宕机风险 | 绑定该节点的用户会话丢失、业务中断 | 节点故障直接摘除,流量转发其他实例,用户无感知 |
| 滚动 / 灰度发布 | 复杂,需要迁移本地数据、谨慎停机 | 极其简单,容器随时销毁重建 Pod 即可更新 |
| 负载均衡缺陷 | 容易出现负载倾斜、热点节点 | 流量均匀分配,调度策略不受约束 |
| 多机房容灾 | 难度高,本地存储数据需要同步迁移 | 计算逻辑可任意部署机房,流量一键切换 |
| 并发一致性 | 多实例本地缓存极易出现数据不一致 | 状态统一存放 Redis、数据库,集中管控并发 |
| 故障排查 | 偶现问题,不同机器环境不一样 | 所有实例环境一致,问题容易复现排查 |
| 典型组件 | MySQL、Redis、ES、本地会话、本地文件缓存 | 网关、Web 接口、API 服务、微服务业务逻辑 |
二、无状态架构的全部核心优势
1. 支持水平扩容(最主要原因)
- 有状态:每台机器有专属数据,新增实例无法立刻承接流量,扩容非常麻烦。
- 无状态:所有实例一模一样,流量高了直接加机器;负载均衡随便分发请求,Nginx / 网关随机、轮询即可,不需要会话黏连。
软考架构原话:无状态服务易于横向扩展,是集群伸缩的先决条件。
2. 高可用,故障容错简单
- 某一台实例宕机、卡死、重启;因为本地没有状态,负载均衡直接把流量切向其他节点,业务不受影响。
- 有状态机器宕机,则绑定这台机器的用户会话全部丢失,用户掉线。
3. 负载均衡策略自由,不需要会话粘连
有状态必须开启ip‑hash、cookie 黏连,强制用户一直访问同一实例:
- 容易造成流量分配不均;部分服务器负载爆满、其余空闲;
- 黏连的节点故障用户直接断线。 无状态不需要粘性会话,轮询、最小连接数、权重调度全都可以随便用,负载更加均衡。
4. 方便灰度发布、滚动更新、容器编排
K8s、Docker、云原生最偏好无状态 Pod:
- 旧版本实例逐个销毁;
- 新版本实例拉起; 因为实例不带状态,随时销毁重建,滚动升级零感知。 有状态应用升级要谨慎迁移本地数据,升级流程复杂很多。
5. 降低锁、并发、数据一致性难题
多台机器本地存状态,就会出现多实例修改同一份本地缓存,出现数据不一致。 无状态把全部状态外置到 Redis、MySQL、MQ;统一中心化管理,只需要解决中间件的并发控制。
6. 可迁移、多可用区部署、异地容灾
无状态服务是纯粹的计算逻辑,可以部署在任意机房、任意服务器; 故障时流量一键切到备用机房。 有状态服务受限于本地存储,跨机房迁移成本极高。
7. 易于测试、调试、故障排查
每一个实例环境一致;请求在哪台机器执行结果都相同。 不会出现 “在 A 服务器正常、B 服务器报错” 这种本地状态引发的诡异问题。
无状态缺点
- 每次请求需要携带上下文信息,请求报文偏大
- 高频会话数据依赖 Redis 等中间件,增加中间件压力
- 需要额外维护分布式会话,架构组件变多
三、通俗举例理解
有状态例子:网吧单人专属座位
你上网之后座位保存你的浏览记录;你中途走开就必须回到原来座位;座位坏了你就无法上网;加新座位也不能分担老座位客人。
无状态例子:自助取票机
所有取票机一模一样;你的身份信息(状态)存在云端数据库;任意一台机器都可取票;一台机器坏了直接换一台;节假日高峰期直接新增取票机即可扩容。
四、那业务状态放去哪里?(无状态落地方案)
服务本身不带状态,状态外置,常见存放位置:
- 客户端:Token、JWT、Cookie、请求 Header;登录凭证放在请求里
- 分布式缓存 Redis:会话 session、购物车、临时任务、热点缓存
- 持久化数据库 MySQL/PG:永久业务数据
- 消息队列 MQ:异步任务、流程状态
- 分布式配置中心:配置状态
五、什么时候允许使用有状态服务?
无状态是业务服务原则,并不是所有组件都要无状态,下面天生就是有状态组件:
- 数据库主从集群
- Redis 集群
- Elasticsearch
- 文件存储、本地临时队列 这类存储型组件本身就要保存数据,需要专门做有状态集群方案(StatefulSet)。
具体示例:
【有状态服务】完整示例(本机内存存储 Session)
控制器代码
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; import java.util.HashMap; import java.util.Map; import java.util.UUID; @RestController public class StatefulController { // ========== 致命问题:会话存储在实例本地内存当中 ========== private final Map<String, String> localSessionMap = new HashMap<>(); /** * 用户登录,返回sessionId */ @GetMapping("/stateful/login") public String login(@RequestParam String username) { String sessionId = UUID.randomUUID().toString(); // 将用户会话保存到当前进程内存 localSessionMap.put(sessionId, username); return sessionId; } /** * 获取当前登录用户 */ @GetMapping("/stateful/getUser") public String getUser(@RequestParam String sessionId) { String username = localSessionMap.get(sessionId); if (username == null) { return "未登录!"; } return "当前登录用户:" + username; } /** * 退出登录,清除本地内存状态 */ @GetMapping("/stateful/logout") public String logout(@RequestParam String sessionId) { localSessionMap.remove(sessionId); return "退出成功"; } }复现问题(重点,考试常考场景)
调用登录,Nginx 轮询分配至8080 (实例 A)
GET http://127.0.0.1:8080/stateful/login?username=张三 返回:uuid‑001
session 存入实例 A 的 HashMap。
接下来调用获取用户,轮询命中8081 (实例 B)
GET http://127.0.0.1:8081/stateful/getUser?sessionId=uuid‑001 返回:未登录!
原因
实例 A、实例 B 拥有两块独立内存,HashMap 互不共享。
唯一妥协方案:开启会话黏连 ip‑hash
Nginx 根据客户端 IP 固定转发到同一台实例。 随之带来一堆缺点:
- 服务器宕机,所有绑定该实例用户直接掉线;
- 流量分配不均,出现热点机器;
- 扩容新增节点无法分担已有用户;
- 滚动更新时关闭实例需要迁移会话。
无状态服务
方案 1:状态存放在客户端 — JWT 完全无状态(最经典)
服务端内存完全不保存会话;用户信息加密放在 Token,每次请求携带。
Jwt 工具类(简易版)
import io.jsonwebtoken.Jwts; import io.jsonwebtoken.SignatureAlgorithm; import java.util.Date; public class JwtUtil { // 加密密钥 private static final String SECRET_KEY = "my‑secret‑key‑2026"; // 有效期 2小时 private static final long EXPIRATION = 2 * 60 * 60 * 1000; // 创建token,存放用户名 public static String generateToken(String username){ return Jwts.builder() .setSubject(username) .setExpiration(new Date(System.currentTimeMillis()+EXPIRATION)) .signWith(SignatureAlgorithm.HS256,SECRET_KEY) .compact(); } // 解析token拿到用户名 public static String parseUser(String token){ return Jwts.parser() .setSigningKey(SECRET_KEY) .parseClaimsJws(token) .getBody() .getSubject(); } }无状态控制器‑JWT
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestHeader; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; @RestController public class StatelessJwtController { @GetMapping("/stateless/jwt/login") public String login(@RequestParam String username){ // 只生成令牌,服务本地内存不存储任何会话 String token = JwtUtil.generateToken(username); return token; } @GetMapping("/stateless/jwt/getUser") public String getUser(@RequestHeader("token") String token){ String username = JwtUtil.parseUser(token); return "当前登录用户:" + username; } }集群测试现象
- 登录请求落到 8080,拿到 token;
- 查询用户请求落到 8081; 8081 只需要解析 token 即可,不需要共享内存。
JWT 优缺点
- 优点:服务彻底无状态、不需要缓存中间件;适合网关、集群、多机房;
- 缺点:token 无法服务端主动作废、token 体积大、不能存放大量会话信息
方案 2:状态外置到共享中间件 Redis(企业最常用分布式 Session)
所有服务实例读取同一个 Redis,状态脱离服务进程。
Controller 代码
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; import java.util.UUID; import java.util.concurrent.TimeUnit; @RestController public class StatelessRedisController { @Autowired private StringRedisTemplate redisTemplate; // 登录,session存放Redis,而不是本机HashMap @GetMapping("/stateless/redis/login") public String login(@RequestParam String username){ String sessionId = UUID.randomUUID().toString(); // 设置过期时间 2小时 redisTemplate.opsForValue().set(sessionId, username,2, TimeUnit.HOURS); return sessionId; } @GetMapping("/stateless/redis/getUser") public String getUser(@RequestParam String sessionId){ String username = redisTemplate.opsForValue().get(sessionId); if(username == null){ return "未登录"; } return "当前登录用户:"+username; } @GetMapping("/stateless/redis/logout") public String logout(@RequestParam String sessionId){ redisTemplate.delete(sessionId); return "退出登录成功"; } }集群现象
8080、8081 都访问公共 Redis;无论负载均衡转发到哪一台实例,都可以读取会话。 服务实例本身不存在状态 →标准无状态服务。