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

  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;无论负载均衡转发到哪一台实例,都可以读取会话。 服务实例本身不存在状态 → 标准无状态服务

更多推荐