三亩地 三亩地SAN MU DI · CODE DIARY
ARTICLE DETAIL

日记详情

真实记录编程学习的某一天,欢迎挑你感兴趣的翻一翻。

为什么架构设计提倡无状态化

为什么架构设计提倡无状态化

一、先分清概念:什么是有状态 / 无状态

  1. 有状态服务 (Stateful)

实例本地存储用户会话、临时数据、连接信息;同一个用户后续请求必须落到同一台服务器。 例:服务器内存存登录 session、本地内存缓存购物车、本机文件保存临时任务。

  1. 无状态服务 (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:

  1. 旧版本实例逐个销毁;
  2. 新版本实例拉起; 因为实例不带状态,随时销毁重建,滚动升级零感知。 有状态应用升级要谨慎迁移本地数据,升级流程复杂很多。

5. 降低锁、并发、数据一致性难题

多台机器本地存状态,就会出现多实例修改同一份本地缓存,出现数据不一致。 无状态把全部状态外置到 Redis、MySQL、MQ;统一中心化管理,只需要解决中间件的并发控制。

6. 可迁移、多可用区部署、异地容灾

无状态服务是纯粹的计算逻辑,可以部署在任意机房、任意服务器; 故障时流量一键切到备用机房。 有状态服务受限于本地存储,跨机房迁移成本极高。

7. 易于测试、调试、故障排查

每一个实例环境一致;请求在哪台机器执行结果都相同。 不会出现 “在 A 服务器正常、B 服务器报错” 这种本地状态引发的诡异问题。

无状态缺点

  1. 每次请求需要携带上下文信息,请求报文偏大
  2. 高频会话数据依赖 Redis 等中间件,增加中间件压力
  3. 需要额外维护分布式会话,架构组件变多

三、通俗举例理解

有状态例子:网吧单人专属座位

你上网之后座位保存你的浏览记录;你中途走开就必须回到原来座位;座位坏了你就无法上网;加新座位也不能分担老座位客人。

无状态例子:自助取票机

所有取票机一模一样;你的身份信息(状态)存在云端数据库;任意一台机器都可取票;一台机器坏了直接换一台;节假日高峰期直接新增取票机即可扩容。


四、那业务状态放去哪里?(无状态落地方案)

服务本身不带状态,状态外置,常见存放位置:

  1. 客户端:Token、JWT、Cookie、请求 Header;登录凭证放在请求里
  2. 分布式缓存 Redis:会话 session、购物车、临时任务、热点缓存
  3. 持久化数据库 MySQL/PG:永久业务数据
  4. 消息队列 MQ:异步任务、流程状态
  5. 分布式配置中心:配置状态

五、什么时候允许使用有状态服务?

无状态是业务服务原则,并不是所有组件都要无状态,下面天生就是有状态组件:

  • 数据库主从集群
  • 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. 服务器宕机,所有绑定该实例用户直接掉线;
  2. 流量分配不均,出现热点机器;
  3. 扩容新增节点无法分担已有用户;
  4. 滚动更新时关闭实例需要迁移会话。

无状态服务

方案 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; } }

集群测试现象

  1. 登录请求落到 8080,拿到 token;
  2. 查询用户请求落到 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;无论负载均衡转发到哪一台实例,都可以读取会话。 服务实例本身不存在状态 →标准无状态服务

← 返回列表