土法炼钢兴趣小组的算法知识备份

【系统架构设计百科】无状态设计:扩展的第一步也是最难的一步

文章导航

分类入口
architecture
标签入口
#stateless#session-management#JWT#state-externalization#scalability

目录

2024 年某电商平台大促前夜,运维团队把应用服务器从 8 台扩到 32 台。负载均衡器配置完毕,压测开始——QPS 从 1.2 万涨到 1.8 万,远没有达到预期的 4.8 万。排查发现,负载均衡器开启了会话保持(Sticky Session),32 台机器的流量分布极度不均:最忙的 4 台 CPU 跑满,其余 20 多台负载不到 30%。更糟糕的是,当那 4 台中有一台因 Full GC 卡顿 15 秒,绑定在上面的 12000 个用户全部超时,重新登录后又被随机打散到其他机器——但这些用户的 Session 数据留在了那台卡顿的机器上,登录态全部丢失。

这不是个例。有状态服务是水平扩展的最大障碍。你可以买更多的机器,但如果服务实例之间彼此不可替换,加机器只是加成本,不是加能力。

上一篇中,我们讨论了扩展性原则和阿姆达尔定律(Amdahl’s Law)——系统中不可并行化的部分决定了扩展的上限。有状态就是那个”不可并行化”的瓶颈:它把请求绑定到特定实例,让负载均衡退化为流量路由。

这篇文章拆解三件事:状态到底藏在哪里、怎么把它移出去、移出去之后各种场景怎么处理。


一、无状态为什么是水平扩展的前提

什么是无状态服务

一个服务是无状态的,当且仅当:任意一个请求可以被任意一个实例处理,且结果完全一致

这个定义看起来简单,但它隐含了一个苛刻的要求——服务实例不能持有任何跨请求的上下文。不能有本地 Session,不能有本地缓存(如果缓存不一致会导致业务错误),不能有本地文件依赖。

无状态带来的架构收益

第一,自由伸缩。 负载均衡器可以用最简单的轮询(Round Robin)策略分发请求。新实例上线即可接流量,旧实例下线不影响任何用户。自动扩缩容(Auto Scaling)的前提就是无状态——云平台不关心你的实例里有没有 Session,它只关心 CPU 和内存的阈值。

第二,简化发布。 滚动更新(Rolling Update)时,旧实例被逐个替换。如果实例有状态,你需要”排空”(Drain)上面的连接和数据,等所有绑定的用户断开才能下线。无状态实例可以直接杀掉,新版本立刻接管。

第三,故障隔离。 一台实例宕机,请求自动切到其他实例,用户无感知。不需要故障转移(Failover)协议,不需要主备同步。

第四,测试简化。 无状态服务的行为完全由请求输入决定,不依赖前序请求的副作用。单元测试和集成测试都更容易编写和维护。

与阿姆达尔定律的关系

阿姆达尔定律告诉我们,如果系统中有 5% 的工作必须串行执行,那么无论加多少处理器,加速比上限是 20 倍。Sticky Session 本质上是一种”串行化约束”——它把特定用户的请求强制绑定到单台机器,让那台机器成为该用户的单点瓶颈。当热点用户集中在少数实例上时,整个集群的有效吞吐量远低于理论值。

无状态设计消除的就是这个串行化约束。它让每一个请求都成为独立的、可并行的工作单元。


二、状态外置模式

服务要做到无状态,核心思路只有一个:把状态从进程内存移到进程外部的共享存储。不同类型的状态适合不同的外部存储。

状态分类

在改造之前,先盘点服务里到底有哪些状态:

状态类型 典型例子 特征
会话状态 用户登录态、购物车 生命周期短(分钟到小时),丢失可恢复
缓存状态 热点商品数据、用户画像 可重建,丢失不影响正确性
文件状态 上传的临时文件、生成的报表 体积大,访问模式不同于结构化数据
连接状态 WebSocket 连接、TCP 长连接 绑定到特定进程,无法简单迁移
计算状态 任务进度、工作流步骤 需要持久化,丢失意味着重复计算

外置架构全景

graph TB
    LB["负载均衡器<br/>Round Robin"]
    
    LB --> S1["服务实例 1"]
    LB --> S2["服务实例 2"]
    LB --> S3["服务实例 N"]
    
    S1 --> Redis["Redis 集群<br/>会话 + 缓存"]
    S2 --> Redis
    S3 --> Redis
    
    S1 --> DB["数据库集群<br/>持久状态"]
    S2 --> DB
    S3 --> DB
    
    S1 --> OSS["对象存储<br/>S3 / MinIO"]
    S2 --> OSS
    S3 --> OSS
    
    S1 --> MQ["消息队列<br/>任务状态"]
    S2 --> MQ
    S3 --> MQ
    
    style LB fill:#388bfd,color:#fff
    style Redis fill:#f0883e,color:#fff
    style DB fill:#3fb950,color:#fff
    style OSS fill:#a371f7,color:#fff
    style MQ fill:#f85149,color:#fff

每个服务实例本身不保存任何跨请求数据。所有状态都通过网络访问外部存储。这意味着任何实例都可以处理任何请求。

会话状态外置:Redis / Memcached

最常见的场景是 Session 外置。以 Spring Boot 为例,把 Session 从 Tomcat 内存迁移到 Redis 只需要两步:

第一步,添加依赖:

<!-- pom.xml -->
<dependency>
    <groupId>org.springframework.session</groupId>
    <artifactId>spring-session-data-redis</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>

第二步,配置 Redis 连接和 Session 存储:

# application.yml
spring:
  session:
    store-type: redis
    timeout: 1800
  redis:
    host: redis-cluster.internal
    port: 6379
    password: ${REDIS_PASSWORD}
    lettuce:
      pool:
        max-active: 50
        max-idle: 10
        min-idle: 5

改造前后代码层面几乎无感知。HttpSession 的 API 不变,但底层存储从 JVM 堆内存切换到了 Redis。任何一个服务实例都可以通过 Session ID 从 Redis 读取同一份数据。

Node.js 的 Express 框架也类似:

// app.js
const session = require('express-session');
const RedisStore = require('connect-redis').default;
const { createClient } = require('redis');

const redisClient = createClient({
  url: 'redis://redis-cluster.internal:6379',
  password: process.env.REDIS_PASSWORD,
});
redisClient.connect();

app.use(session({
  store: new RedisStore({ client: redisClient }),
  secret: process.env.SESSION_SECRET,
  resave: false,
  saveUninitialized: false,
  cookie: {
    secure: true,
    httpOnly: true,
    maxAge: 1800000, // 30 分钟
  },
}));

缓存状态外置

本地缓存(如 Guava Cache、Caffeine)的问题是数据不一致。实例 A 更新了缓存,实例 B 还在用旧数据。两种解决方案:

方案一:集中式缓存。 所有实例共享 Redis 缓存。优点是一致性好,缺点是每次读取都有网络开销。

方案二:本地缓存 + 失效广播。 本地缓存用于读取,数据变更时通过 Redis Pub/Sub 或消息队列广播失效通知。优点是读取性能好,缺点是有短暂的不一致窗口。

// 方案二示例:本地缓存 + Redis 失效广播
@Component
public class ProductCacheManager {

    private final Cache<String, Product> localCache = Caffeine.newBuilder()
        .maximumSize(10000)
        .expireAfterWrite(Duration.ofMinutes(5))
        .build();

    @Autowired
    private RedisTemplate<String, Product> redisTemplate;

    @Autowired
    private RedisMessageListenerContainer listenerContainer;

    @PostConstruct
    public void init() {
        // 监听缓存失效通知
        listenerContainer.addMessageListener(
            (message, pattern) -> {
                String productId = new String(message.getBody());
                localCache.invalidate(productId);
            },
            new ChannelTopic("cache:product:invalidate")
        );
    }

    public Product getProduct(String productId) {
        // 先查本地缓存
        Product product = localCache.getIfPresent(productId);
        if (product != null) {
            return product;
        }
        // 再查 Redis
        product = redisTemplate.opsForValue().get("product:" + productId);
        if (product != null) {
            localCache.put(productId, product);
            return product;
        }
        // 最后查数据库
        product = productRepository.findById(productId);
        if (product != null) {
            redisTemplate.opsForValue().set("product:" + productId, product,
                Duration.ofMinutes(30));
            localCache.put(productId, product);
        }
        return product;
    }

    public void updateProduct(Product product) {
        productRepository.save(product);
        redisTemplate.opsForValue().set("product:" + product.getId(), product,
            Duration.ofMinutes(30));
        // 广播失效通知,所有实例的本地缓存都会失效
        redisTemplate.convertAndSend("cache:product:invalidate", product.getId());
    }
}

文件状态外置

服务实例不应该依赖本地磁盘存储文件。所有文件都应该存放在对象存储(S3、MinIO、阿里云 OSS)或分布式文件系统中。这个话题在后面”文件上传”章节展开。

计算状态外置

长时间运行的任务(报表生成、视频转码、数据导入)的进度信息不能存在内存里。如果实例重启,进度丢失,任务需要从头开始。

解决方案是把任务状态存到数据库或 Redis:

# task_manager.py
import redis
import json
import time

class TaskManager:
    def __init__(self):
        self.redis = redis.Redis(host='redis-cluster.internal', port=6379)

    def create_task(self, task_id, total_steps):
        task_state = {
            'task_id': task_id,
            'status': 'running',
            'current_step': 0,
            'total_steps': total_steps,
            'created_at': time.time(),
            'updated_at': time.time(),
        }
        self.redis.set(f'task:{task_id}', json.dumps(task_state))
        self.redis.expire(f'task:{task_id}', 86400)  # 24 小时过期

    def update_progress(self, task_id, current_step):
        raw = self.redis.get(f'task:{task_id}')
        if not raw:
            raise ValueError(f'Task {task_id} not found')
        task_state = json.loads(raw)
        task_state['current_step'] = current_step
        task_state['updated_at'] = time.time()
        if current_step >= task_state['total_steps']:
            task_state['status'] = 'completed'
        self.redis.set(f'task:{task_id}', json.dumps(task_state))

    def resume_task(self, task_id):
        """实例重启后,从上次的进度继续"""
        raw = self.redis.get(f'task:{task_id}')
        if not raw:
            return None
        state = json.loads(raw)
        return state['current_step'] if state['status'] == 'running' else None

三、Sticky Session 的代价

Sticky Session(会话保持)是一种”不改造应用就能让有状态服务在多实例环境工作”的方案。它通过负载均衡器把同一个用户的请求始终路由到同一台服务器。

实现方式

Sticky Session 有两种常见实现:

基于 Cookie。 负载均衡器在第一次响应中插入一个特殊 Cookie(如 SERVERID=server-03),后续请求带上这个 Cookie,负载均衡器据此路由。

Nginx 配置示例:

# nginx.conf
upstream backend {
    ip_hash;  # 基于 IP 的 Sticky Session

    server 10.0.1.1:8080;
    server 10.0.1.2:8080;
    server 10.0.1.3:8080;
}

# 或者使用 Cookie 方式(需要 nginx-sticky-module)
upstream backend_cookie {
    sticky cookie srv_id expires=1h domain=.example.com path=/;

    server 10.0.1.1:8080;
    server 10.0.1.2:8080;
    server 10.0.1.3:8080;
}

server {
    listen 80;
    location / {
        proxy_pass http://backend;
    }
}

AWS ALB 也提供类似配置,通过目标组属性 stickiness.enabled 开启,支持基于应用 Cookie(如 JSESSIONID)或 ALB 自生成 Cookie 的会话保持。

基于 IP。 对客户端 IP 做哈希,映射到固定服务器。问题更多——NAT 网关后面的大量用户会被映射到同一台服务器,企业出口 IP 集中导致严重的负载倾斜。

Sticky Session 的问题

问题一:负载不均衡。 不同用户的请求量差异巨大。一个重度用户可能产生普通用户 100 倍的请求。Sticky Session 让负载均衡器无法按请求量分配流量,只能按”用户数”分配——但用户的活跃度分布是长尾的。

问题二:故障恢复困难。 当绑定的实例宕机,该实例上的所有 Session 数据丢失。用户被重新分配到其他实例后需要重新登录、重新操作。如果业务流程是多步骤的(如填写表单、支付流程),中间状态全部丢失。

问题三:扩缩容失效。 新增实例时,已有的 Sticky 关系不会重新分配,新实例只能接收新用户。缩容时,被下线实例上的用户必须全部迁移——但迁移意味着丢失状态。

问题四:部署复杂化。 滚动更新时,旧实例上的 Sticky 用户需要排空(Drain)。排空等待时间不可控——如果用户一直在操作,实例就一直不能下线。生产环境中经常出现”等了 30 分钟还没排空完”的情况。

什么时候 Sticky Session 可以接受

Sticky Session 不是绝对的禁忌。以下场景中它是合理的折中:

  1. 遗留系统迁移过渡期。 有状态应用正在改造中,Sticky Session 作为短期过渡方案。
  2. 特定协议要求。 某些 RPC 协议(如 gRPC 的双向流)需要连接级别的亲和性。
  3. 本地缓存优化。 当缓存命中率对性能至关重要,且缓存数据量太大无法全量放入每个实例时,Sticky Session 可以提高缓存命中率。但这必须建立在”缓存丢失不影响正确性”的前提上。

四、JWT 与服务端 Session 的架构级对比

Session 管理是无状态改造中最核心的问题。两种主流方案——服务端 Session(Server-side Session)和 JWT(JSON Web Token)——不只是”认证方式”的区别,而是架构选型。

服务端 Session 的工作方式

sequenceDiagram
    participant C as 客户端
    participant LB as 负载均衡器
    participant S1 as 服务实例 1
    participant S2 as 服务实例 2
    participant R as Redis Session 存储

    C->>LB: POST /login (用户名 + 密码)
    LB->>S1: 转发请求
    S1->>R: 创建 Session(sid=abc123)
    R-->>S1: OK
    S1-->>C: Set-Cookie: sid=abc123

    C->>LB: GET /profile(Cookie: sid=abc123)
    LB->>S2: 转发到另一个实例
    S2->>R: 查询 Session(sid=abc123)
    R-->>S2: {userId: 42, role: "admin"}
    S2-->>C: 200 OK(用户数据)

服务端 Session 的核心特征:客户端只持有一个不透明的 Session ID,所有会话数据存储在服务端的共享存储中。

JWT 的工作方式

JWT 的思路相反:把会话数据编码到令牌(Token)本身,服务端不需要存储任何东西——只需要验证令牌的签名。

一个 JWT 令牌的结构:

eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.    ← Header(算法 + 类型)
eyJzdWIiOiI0MiIsInJvbGUiOiJhZG1pbiIsIm    ← Payload(用户数据)
V4cCI6MTcxNDU2MDAwMH0.
SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQ   ← Signature(签名)

签发和验证的代码(Node.js):

// auth.js
const jwt = require('jsonwebtoken');
const fs = require('fs');

// 使用 RSA 非对称密钥
const privateKey = fs.readFileSync('/secrets/jwt-private.pem');
const publicKey = fs.readFileSync('/secrets/jwt-public.pem');

function issueToken(user) {
  const payload = {
    sub: user.id,
    role: user.role,
    permissions: user.permissions,
  };
  return jwt.sign(payload, privateKey, {
    algorithm: 'RS256',
    expiresIn: '15m',    // Access Token 短有效期
    issuer: 'auth.example.com',
  });
}

function issueRefreshToken(user) {
  return jwt.sign(
    { sub: user.id, type: 'refresh' },
    privateKey,
    { algorithm: 'RS256', expiresIn: '7d' }
  );
}

function verifyToken(token) {
  try {
    return jwt.verify(token, publicKey, {
      algorithms: ['RS256'],
      issuer: 'auth.example.com',
    });
  } catch (err) {
    if (err.name === 'TokenExpiredError') {
      throw new Error('Token expired');
    }
    throw new Error('Invalid token');
  }
}

Spring Boot 中的 JWT 验证过滤器:

// JwtAuthenticationFilter.java — 以下代码经删减,仅保留关键路径
@Component
public class JwtAuthenticationFilter extends OncePerRequestFilter {

    private final RSAPublicKey publicKey;

    @Override
    protected void doFilterInternal(HttpServletRequest request,
                                     HttpServletResponse response,
                                     FilterChain chain)
            throws ServletException, IOException {

        String header = request.getHeader("Authorization");
        if (header == null || !header.startsWith("Bearer ")) {
            chain.doFilter(request, response);
            return;
        }

        try {
            SignedJWT signedJWT = SignedJWT.parse(header.substring(7));
            if (!signedJWT.verify(new RSASSAVerifier(publicKey))) {
                response.setStatus(401);
                return;
            }
            JWTClaimsSet claims = signedJWT.getJWTClaimsSet();
            if (claims.getExpirationTime().before(new Date())) {
                response.setStatus(401);
                return;
            }
            SecurityContext ctx = SecurityContextHolder.createEmptyContext();
            ctx.setAuthentication(new JwtAuthentication(
                claims.getSubject(), claims.getStringClaim("role")));
            SecurityContextHolder.setContext(ctx);
        } catch (Exception e) {
            response.setStatus(401);
            return;
        }
        chain.doFilter(request, response);
    }
}

JWT 的撤销难题

JWT 最大的架构问题是撤销(Revocation)。令牌一旦签发,在过期之前始终有效——因为验证只需要公钥,不需要查询任何外部存储。如果用户被禁用、密码被修改、权限被回收,已签发的 JWT 仍然有效。

常见的撤销方案:

方案一:短有效期 + Refresh Token。 Access Token 有效期 5-15 分钟,过期后通过 Refresh Token 换取新的。撤销时只需让 Refresh Token 失效。缺点是 Access Token 有效期内无法即时撤销。

方案二:黑名单。 维护被撤销的 JWT ID(jti)列表,每次验证时检查。黑名单存在 Redis 中,只需保留到 JWT 过期为止。

# jwt_blacklist.py
import redis
import time

class JWTBlacklist:
    def __init__(self):
        self.redis = redis.Redis(host='redis-cluster.internal', port=6379)

    def revoke(self, jti, exp):
        """将 JWT 加入黑名单,过期后自动清除"""
        ttl = exp - int(time.time())
        if ttl > 0:
            self.redis.setex(f'jwt:blacklist:{jti}', ttl, '1')

    def is_revoked(self, jti):
        return self.redis.exists(f'jwt:blacklist:{jti}') > 0

但这破坏了 JWT 的”无状态”承诺——验证时又需要查询外部存储了。如果你需要即时撤销,JWT 在这个维度上退化为服务端 Session。

方案三:版本号。 在用户表中维护一个 token_version 字段,JWT Payload 里包含签发时的版本号。验证时对比当前版本号和令牌中的版本号——不一致则拒绝。这仍然需要查询数据库,但可以利用缓存减少开销。

详细对比

维度 服务端 Session(Redis) JWT
状态存储位置 服务端(Redis / Memcached) 客户端(Token 自身)
验证方式 查询 Session 存储 验证签名(不需要外部查询)
令牌大小 极小(通常 32 字节 Session ID) 较大(500-2000 字节,取决于 Payload)
服务端存储开销 每个活跃会话占用存储空间 无(除非使用黑名单)
即时撤销 支持,删除 Session 即可 不支持,需要额外机制
跨域 / 跨服务 需要共享 Session 存储 天然支持,公钥分发即可
带宽开销 低(只传 Session ID) 高(每次请求传完整 Token)
水平扩展 依赖共享存储的可用性 完全无状态(无黑名单时)
信息泄露风险 Payload 在服务端,客户端不可见 Payload 仅 Base64 编码,可被解码
适用场景 单体应用、需要即时撤销 微服务、跨域、第三方集成
实现复杂度 中等(需处理刷新、撤销)

混合方案

实践中很多系统采用混合架构:

# 混合认证架构配置示例
# gateway-config.yml
routes:
  - path: /api/v1/**
    auth:
      type: jwt
      public-key-url: https://auth.internal/.well-known/jwks.json
      required-claims:
        - sub
        - role

  - path: /admin/**
    auth:
      type: session
      session-store: redis
      session-timeout: 1800
      require-mfa: true

  - path: /internal/**
    auth:
      type: mtls
      ca-cert: /certs/internal-ca.pem

五、无状态服务中的文件上传

文件上传是无状态改造中最容易被忽略的问题。传统架构中,文件先上传到应用服务器的本地磁盘,处理完再存到最终位置。在多实例环境下,这个模式会出两个问题:

  1. 分块上传(Chunked Upload)的多个块可能被路由到不同实例,而每个实例只有自己那部分数据。
  2. 本地临时文件在实例重启或缩容后丢失。

方案一:直传对象存储(Pre-signed URL)

最干净的方案是让客户端直接上传到对象存储,应用服务器只负责签发上传凭证。

sequenceDiagram
    participant C as 客户端
    participant API as 应用服务器
    participant S3 as 对象存储

    C->>API: 请求上传凭证(文件名、大小、类型)
    API->>API: 生成 Pre-signed URL
    API-->>C: 返回 Pre-signed URL + 文件 Key

    C->>S3: PUT 文件(使用 Pre-signed URL)
    S3-->>C: 200 OK

    C->>API: 通知上传完成(文件 Key)
    API->>S3: HEAD 验证文件存在
    S3-->>API: 200 OK(文件元数据)
    API->>API: 更新数据库记录
    API-->>C: 201 Created

后端代码:

# upload_service.py
import boto3
import uuid
from datetime import datetime

class UploadService:
    def __init__(self):
        self.s3 = boto3.client('s3',
            endpoint_url='https://s3.example.com',
            aws_access_key_id='...',
            aws_secret_access_key='...',
        )
        self.bucket = 'user-uploads'

    def generate_presigned_url(self, user_id, filename, content_type, file_size):
        # 校验文件类型和大小
        allowed_types = ['image/jpeg', 'image/png', 'application/pdf']
        if content_type not in allowed_types:
            raise ValueError(f'Unsupported content type: {content_type}')
        if file_size > 100 * 1024 * 1024:  # 100MB 上限
            raise ValueError('File too large')

        # 生成唯一的存储路径
        file_key = f'uploads/{user_id}/{datetime.now():%Y/%m}/{uuid.uuid4()}/{filename}'

        # 生成 Pre-signed URL,有效期 15 分钟
        presigned_url = self.s3.generate_presigned_url(
            'put_object',
            Params={
                'Bucket': self.bucket,
                'Key': file_key,
                'ContentType': content_type,
                'ContentLength': file_size,
            },
            ExpiresIn=900,
        )

        return {
            'upload_url': presigned_url,
            'file_key': file_key,
            'expires_in': 900,
        }

    def confirm_upload(self, file_key):
        """客户端上传完成后,验证文件确实存在"""
        try:
            response = self.s3.head_object(Bucket=self.bucket, Key=file_key)
            return {
                'file_key': file_key,
                'size': response['ContentLength'],
                'content_type': response['ContentType'],
                'etag': response['ETag'],
            }
        except self.s3.exceptions.ClientError:
            raise ValueError('File not found')

前端对接只需三步——获取凭证、直传文件、通知后端:

// upload.js
async function uploadFile(file) {
  const { upload_url, file_key } = await fetch('/api/upload/presign', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      filename: file.name, content_type: file.type, file_size: file.size,
    }),
  }).then(r => r.json());

  await fetch(upload_url, {
    method: 'PUT',
    headers: { 'Content-Type': file.type },
    body: file,
  });

  await fetch('/api/upload/confirm', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ file_key }),
  });
}

这个方案的核心优势:应用服务器完全不接触文件数据。文件从客户端直传到对象存储,应用服务器只处理元数据。上传带宽不经过应用服务器,不占用应用服务器的内存和磁盘。

方案二:流式代理

某些场景下客户端无法直连对象存储(安全策略限制、需要实时处理文件内容),可以让应用服务器做流式代理——数据流经应用服务器但不落盘:

// upload_proxy.go
package main

import (
    "context"
    "fmt"
    "net/http"

    "github.com/aws/aws-sdk-go-v2/service/s3"
)

func streamUploadHandler(s3Client *s3.Client) http.HandlerFunc {
    return func(w http.ResponseWriter, r *http.Request) {
        contentType := r.Header.Get("Content-Type")
        contentLength := r.ContentLength
        fileKey := fmt.Sprintf("uploads/%s", r.URL.Query().Get("key"))

        // 直接把 request body 流式传到 S3,不在本地缓存
        _, err := s3Client.PutObject(context.Background(), &s3.PutObjectInput{
            Bucket:        stringPtr("user-uploads"),
            Key:           stringPtr(fileKey),
            Body:          r.Body, // 流式传输,不缓存到内存或磁盘
            ContentType:   stringPtr(contentType),
            ContentLength: &contentLength,
        })
        if err != nil {
            http.Error(w, "Upload failed", http.StatusInternalServerError)
            return
        }

        w.WriteHeader(http.StatusCreated)
    }
}

func stringPtr(s string) *string { return &s }

关键点是 r.Body 直接作为 S3 的输入流,数据流过内存但不持久化到磁盘。每个请求处理完毕后,实例上不残留任何文件状态。


六、WebSocket 的状态管理

WebSocket 连接天然是有状态的——一条 TCP 连接绑定在特定的服务实例上。如果用户 A 连接到实例 1,用户 B 连接到实例 2,A 给 B 发消息时,实例 1 需要知道 B 在实例 2 上。

连接注册表 + Redis Pub/Sub

核心思路:每个实例只管理自己的 WebSocket 连接,跨实例的消息通过 Redis Pub/Sub 广播。

// ws-server.js
const WebSocket = require('ws');
const Redis = require('ioredis');

const INSTANCE_ID = process.env.INSTANCE_ID || 'instance-1';
const redisPub = new Redis('redis://redis-cluster.internal:6379');
const redisSub = new Redis('redis://redis-cluster.internal:6379');

// 本地连接注册表:userId -> WebSocket
const localConnections = new Map();

const wss = new WebSocket.Server({ port: 8080 });

wss.on('connection', (ws, req) => {
  const userId = authenticateFromRequest(req);
  if (!userId) {
    ws.close(4001, 'Unauthorized');
    return;
  }

  // 注册到本地连接表
  localConnections.set(userId, ws);

  // 在 Redis 中注册连接位置
  redisPub.hset('ws:connections', userId, INSTANCE_ID);

  ws.on('message', async (data) => {
    const message = JSON.parse(data);
    if (message.type === 'direct') {
      await sendToUser(message.targetUserId, message.payload);
    } else if (message.type === 'broadcast') {
      await broadcastToChannel(message.channel, message.payload);
    }
  });

  ws.on('close', () => {
    localConnections.delete(userId);
    redisPub.hdel('ws:connections', userId);
  });
});

async function sendToUser(targetUserId, payload) {
  const localWs = localConnections.get(targetUserId);
  if (localWs && localWs.readyState === WebSocket.OPEN) {
    localWs.send(JSON.stringify(payload));
    return;
  }
  // 不在本地,通过 Redis Pub/Sub 转发到目标实例
  const targetInstance = await redisPub.hget('ws:connections', targetUserId);
  if (targetInstance) {
    redisPub.publish(`ws:instance:${targetInstance}`,
      JSON.stringify({ targetUserId, payload }));
  }
}

// 监听发给本实例的定向消息
redisSub.subscribe(`ws:instance:${INSTANCE_ID}`);
redisSub.on('message', (channel, message) => {
  const { targetUserId, payload } = JSON.parse(message);
  const localWs = localConnections.get(targetUserId);
  if (localWs && localWs.readyState === WebSocket.OPEN) {
    localWs.send(JSON.stringify(payload));
  }
});

自动扩缩容下的 WebSocket 处理

WebSocket 实例的扩缩容比 HTTP 服务更复杂。缩容时不能直接杀掉实例——上面的连接会全部断开。

正确的做法是优雅排空(Graceful Drain)

// graceful-drain.js
const DRAIN_TIMEOUT = 30000; // 30 秒排空超时
let isDraining = false;

process.on('SIGTERM', () => {
  isDraining = true;

  // 通知所有已连接的客户端迁移
  for (const [userId, ws] of localConnections) {
    ws.send(JSON.stringify({
      type: 'system',
      action: 'reconnect',
      message: 'Server is shutting down, please reconnect',
    }));
  }

  // 等待客户端断开或超时强制关闭
  const drainTimer = setTimeout(() => {
    for (const [userId, ws] of localConnections) {
      ws.close(1001, 'Server shutting down');
    }
    process.exit(0);
  }, DRAIN_TIMEOUT);

  const checkInterval = setInterval(() => {
    if (localConnections.size === 0) {
      clearTimeout(drainTimer);
      clearInterval(checkInterval);
      process.exit(0);
    }
  }, 1000);
});

// 健康检查端点:排空期间返回 503,负载均衡器不再分配新连接
app.get('/health', (req, res) => {
  res.status(isDraining ? 503 : 200).json({
    status: isDraining ? 'draining' : 'healthy',
  });
});

Kubernetes 的 Pod 配置需要配合:

# deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: ws-server
spec:
  replicas: 3
  template:
    spec:
      terminationGracePeriodSeconds: 60  # 给足排空时间
      containers:
        - name: ws-server
          image: ws-server:latest
          ports:
            - containerPort: 8080
          readinessProbe:
            httpGet:
              path: /health
              port: 8080
            periodSeconds: 5
          lifecycle:
            preStop:
              exec:
                command: ["sleep", "5"]  # 等待负载均衡器感知

七、工程案例:从有状态到无状态的完整迁移

以下案例来自一个真实的电商订单服务的改造过程(技术栈 Spring Boot + MySQL)。改造前,这个服务有三种典型的有状态问题。

改造前的架构

改造前架构:

                    ┌─────────────────────┐
                    │   Nginx(Sticky)    │
                    │   ip_hash 策略       │
                    └──────┬──────────────┘
                           │
              ┌────────────┼────────────┐
              ▼            ▼            ▼
        ┌──────────┐ ┌──────────┐ ┌──────────┐
        │ 实例 1   │ │ 实例 2   │ │ 实例 3   │
        │          │ │          │ │          │
        │ Tomcat   │ │ Tomcat   │ │ Tomcat   │
        │ Session  │ │ Session  │ │ Session  │
        │ 内存缓存 │ │ 内存缓存 │ │ 内存缓存 │
        │ /uploads │ │ /uploads │ │ /uploads │
        └────┬─────┘ └────┬─────┘ └────┬─────┘
             │             │             │
             └─────────────┼─────────────┘
                           │
                    ┌──────┴──────┐
                    │    MySQL    │
                    └─────────────┘

问题清单:

问题 影响 表现
Session 存在 Tomcat 内存 实例宕机后用户登录态丢失 大促期间频繁投诉”被踢出登录”
商品数据缓存在本地内存 改价后不同实例显示不同价格 客服接到”价格不一致”投诉
订单发票 PDF 存在本地 /uploads 只能从上传的那台实例下载 下载失败率约 30%(ip_hash 不稳定)
ip_hash 导致负载不均 大客户 NAT 出口集中 3 台实例负载比为 60:25:15

迁移步骤

第一步:Session 外置到 Redis。

这一步最简单,改动量最小。添加 spring-session-data-redis 依赖,配置 Redis 连接,去掉 Nginx 的 ip_hash

// SessionConfig.java
@Configuration
@EnableRedisHttpSession(maxInactiveIntervalInSeconds = 1800)
public class SessionConfig {

    @Bean
    public LettuceConnectionFactory connectionFactory() {
        RedisStandaloneConfiguration config = new RedisStandaloneConfiguration();
        config.setHostName("redis-cluster.internal");
        config.setPort(6379);
        config.setPassword("...");
        return new LettuceConnectionFactory(config);
    }

    @Bean
    public CookieSerializer cookieSerializer() {
        DefaultCookieSerializer serializer = new DefaultCookieSerializer();
        serializer.setCookieName("ORDER_SID");
        serializer.setUseHttpOnlyCookie(true);
        serializer.setUseSecureCookie(true);
        serializer.setSameSite("Lax");
        return serializer;
    }
}
# nginx.conf 改造后
upstream order_service {
    # 去掉 ip_hash,改为默认的 round-robin
    server 10.0.1.1:8080;
    server 10.0.1.2:8080;
    server 10.0.1.3:8080;
}

改造结果:三台实例负载从 60:25:15 变为 34:33:33。实例宕机后用户无感知。

第二步:本地缓存改为 Redis + 本地二级缓存。

// ProductCacheService.java — 以下代码经删减,仅保留关键路径
@Service
public class ProductCacheService {

    private final Cache<Long, ProductDTO> l1Cache = Caffeine.newBuilder()
        .maximumSize(5000)
        .expireAfterWrite(Duration.ofSeconds(30))
        .build();

    @Autowired private StringRedisTemplate redisTemplate;
    @Autowired private ProductMapper productMapper;

    public ProductDTO getProduct(Long productId) {
        // L1 本地缓存 -> L2 Redis -> L3 数据库,逐级回源并回填上层
        ProductDTO product = l1Cache.getIfPresent(productId);
        if (product != null) return product;

        String json = redisTemplate.opsForValue().get("product:" + productId);
        if (json != null) {
            product = JSON.parseObject(json, ProductDTO.class);
            l1Cache.put(productId, product);
            return product;
        }

        product = productMapper.selectById(productId);
        if (product != null) {
            redisTemplate.opsForValue().set("product:" + productId,
                JSON.toJSONString(product), Duration.ofMinutes(10));
            l1Cache.put(productId, product);
        }
        return product;
    }
}

改造结果:改价后所有实例最多 30 秒内同步(本地缓存过期),价格不一致投诉清零。

第三步:文件上传改为对象存储。

// InvoiceService.java — 以下代码经删减,仅保留关键路径
@Service
public class InvoiceService {

    @Autowired private AmazonS3 s3Client;
    private static final String BUCKET = "order-invoices";

    public String generateAndUploadInvoice(Order order) {
        byte[] pdfBytes = pdfGenerator.generate(order);
        String key = String.format("invoices/%s/%s.pdf",
            order.getOrderDate().format(DateTimeFormatter.ofPattern("yyyy/MM")),
            order.getOrderId());
        ObjectMetadata meta = new ObjectMetadata();
        meta.setContentLength(pdfBytes.length);
        meta.setContentType("application/pdf");
        s3Client.putObject(new PutObjectRequest(BUCKET, key,
            new ByteArrayInputStream(pdfBytes), meta));
        return key;
    }

    public String getDownloadUrl(String fileKey) {
        Date expiration = new Date(System.currentTimeMillis() + 600_000);
        return s3Client.generatePresignedUrl(BUCKET, fileKey,
            expiration, HttpMethod.GET).toString();
    }
}

同时需要做数据迁移,把已有的本地文件批量上传到 S3:

#!/bin/bash
# migrate_uploads.sh - 将三台实例上的历史文件迁移到 S3
for host in 10.0.1.1 10.0.1.2 10.0.1.3; do
    echo "Migrating files from ${host}..."
    ssh "${host}" "find /data/uploads -type f" | while read filepath; do
        relative_path="${filepath#/data/uploads/}"
        aws s3 cp "ssh://${host}${filepath}" \
            "s3://order-invoices/migrated/${relative_path}" --quiet
    done
done
echo "Migration complete."
aws s3 ls s3://order-invoices/migrated/ --recursive --summarize | tail -2

改造后的架构

改造后架构:

                    ┌─────────────────────┐
                    │  Nginx(Round Robin)│
                    │  无 Sticky Session   │
                    └──────┬──────────────┘
                           │
              ┌────────────┼────────────┐
              ▼            ▼            ▼
        ┌──────────┐ ┌──────────┐ ┌──────────┐
        │ 实例 1   │ │ 实例 2   │ │ 实例 N   │
        │          │ │          │ │          │
        │ 无本地   │ │ 无本地   │ │ 无本地   │
        │ 状态     │ │ 状态     │ │ 状态     │
        └─┬──┬──┬──┘ └─┬──┬──┬──┘ └─┬──┬──┬──┘
          │  │  │       │  │  │       │  │  │
          │  │  └───────┼──┼──┼───────┘  │  │
          │  └──────────┼──┼──┼──────────┘  │
          └─────────────┼──┼──┼─────────────┘
                        │  │  │
              ┌─────────┘  │  └────────┐
              ▼            ▼           ▼
        ┌──────────┐ ┌──────────┐ ┌────────┐
        │  Redis   │ │  MySQL   │ │ MinIO  │
        │ Session  │ │ 持久数据 │ │ 文件   │
        │ + 缓存   │ │          │ │        │
        └──────────┘ └──────────┘ └────────┘

改造效果量化

指标 改造前 改造后 改善
3 实例 QPS 上限 12000 18000 +50%
扩到 9 实例后 QPS 18000(瓶颈在 Sticky) 52000 +189%
实例宕机后用户影响 该实例所有用户丢失登录态 无感知 消除
滚动更新耗时 15-30 分钟(等排空) 2 分钟 -90%
发票下载失败率 30% <0.1% 消除
扩缩容生效时间 需要重新分配 Sticky 关系 即时 消除

八、状态外置方案对比

不同的状态外置方案在各维度上有显著差异。选择时需要根据具体的状态类型和业务需求做权衡。

维度 Redis(内存) 关系型数据库 对象存储(S3) 消息队列 本地缓存 + 广播
读取延迟 亚毫秒(<1ms) 1-10ms 10-100ms 不适用 纳秒级(本地命中)
写入延迟 亚毫秒 1-50ms 10-200ms 1-10ms 毫秒级(含广播)
数据持久性 可选(AOF/RDB) 强持久化 强持久化 可配置 无持久化
容量上限 受内存限制 TB 级 无限 取决于配置 受单机内存限制
一致性保证 最终一致(集群模式) 强一致(单主) 最终一致 至少一次 / 精确一次 最终一致(秒级)
故障影响 Session 丢失(无持久化时) 服务不可用 文件不可读 消息积压 降级为无缓存
运维复杂度 中等(集群管理) 高(备份、主从) 低(托管服务) 中等
成本 高(全内存) 中等 低(按量计费) 中等
适合的状态类型 会话、热点缓存 持久业务数据 文件、大对象 任务队列、事件 高频读取的只读数据

选型建议:


九、常见陷阱与边界

陷阱一:把所有东西都塞进 Redis

Redis 是内存数据库,单 GB 内存的年化成本远高于磁盘。大对象(文件、长文本)、冷数据(过去 30 天的日志)、需要 JOIN 和聚合查询的结构化数据——这些不属于 Redis。

陷阱二:忽略外部存储的故障

状态外置不是消除故障,而是把故障点从”随机的某台应用实例”转移到”集中的存储服务”。Redis 挂了,所有实例的 Session 都不可用。外部存储本身需要高可用——Redis Sentinel 或 Redis Cluster,数据库主从复制,S3 跨区域复制。

陷阱三:序列化兼容性

Session 数据存到 Redis 需要序列化。如果 Session 里放了自定义对象(如 UserContext),升级时修改了字段,反序列化会失败。解决方案:Session 中只存基础类型,使用 JSON 而非语言原生序列化格式,滚动更新时测试新旧版本兼容性。

// 反面示例:Session 中存放自定义对象,字段变更后旧 Session 反序列化失败
session.setAttribute("user", new UserContext(userId, role, permissions));

// 正面示例:Session 中只存基础类型
session.setAttribute("userId", userId);
session.setAttribute("role", role);
session.setAttribute("permissions", String.join(",", permissions));

陷阱四:无状态不等于无缓存

无状态是指”不依赖本地状态来保证正确性”。本地缓存作为性能优化手段完全可以存在——前提是缓存丢失后服务仍然能正确工作(只是慢一些)。判断标准:如果清空本地缓存后服务行为不变(只是延迟升高),那这个缓存是安全的。如果清空后服务出错,说明你的”缓存”实际上是”状态”。

边界:不是所有服务都应该无状态

某些服务天然需要状态,强行无状态化反而增加复杂度。数据库本身的职责就是管理状态。实时流处理引擎(Flink、Kafka Streams)的窗口聚合需要进程内状态来保证性能,通过 Checkpoint 机制管理。机器学习推理服务的模型权重需要加载到 GPU 内存,通过镜像预加载解决。


十、实施清单

如果你正在规划无状态改造,以下是检查清单:

[ ] 1. 盘点所有状态:Session、缓存、文件、连接、计算进度
[ ] 2. 分类:哪些必须外置,哪些可以容忍丢失
[ ] 3. 选择外部存储:Redis、数据库、对象存储
[ ] 4. 改造 Session 管理(通常是第一步,改动最小)
[ ] 5. 改造文件存储(迁移历史数据)
[ ] 6. 改造缓存策略(集中式或广播式)
[ ] 7. 处理 WebSocket 等长连接(如果有)
[ ] 8. 去掉 Sticky Session 配置
[ ] 9. 压测验证:轮询策略下负载是否均衡
[ ] 10. 故障演练:随机杀实例,验证用户无感知
[ ] 11. 扩缩容测试:加减实例,验证流量自动均衡

关键原则:分步迁移,逐项验证,保留回退能力。 先改 Session(风险最低、收益最高),再改文件,最后处理长连接。每一步都要有回退方案。


参考资料

  1. Twelve-Factor App - Processes. https://12factor.net/processes —— 无状态设计的核心纲领。
  2. Martin Fowler. Patterns of Enterprise Application Architecture. —— Session State 模式的经典分类。
  3. RFC 7519 - JSON Web Token (JWT). https://datatracker.ietf.org/doc/html/rfc7519
  4. Spring Session Documentation. https://docs.spring.io/spring-session/reference/
  5. AWS Well-Architected Framework. https://docs.aws.amazon.com/wellarchitected/latest/framework/design-principles.html
  6. Kubernetes Pod Lifecycle. https://kubernetes.io/docs/concepts/workloads/pods/pod-lifecycle/#pod-termination
  7. Redis Cluster Specification. https://redis.io/docs/reference/cluster-spec/

上一篇:扩展性原则

下一篇:缓存架构

同主题继续阅读

把当前热点继续串成多页阅读,而不是停在单篇消费。

2026-04-13 · architecture

【系统架构设计百科】扩展性原理:水平、垂直与对角扩展

系统扩展性并非简单堆机器就能获得线性增长。本文从 Amdahl 定律和通用可扩展性定律(USL)出发,用数学模型量化串行化比例与一致性开销对吞吐量的真实约束,并结合工程案例说明如何识别瓶颈、选择扩展策略。

2026-04-13 · architecture

【系统架构设计百科】认证架构:从 Session 到 JWT 到 OIDC

用户登录这件事,从单体时代的 Session-Cookie 到微服务时代的 JWT,再到企业级 SSO 的 OIDC,每一次演进都在解决上一代方案的痛点,同时引入新的复杂性。本文从 Session 的状态管理问题出发,拆解 JWT 的无状态验证机制与吊销困境,深入分析 OAuth 2.0 授权码流程的完整攻击面,给出 SSO 架构选型的工程判据。

2026-04-13 · architecture

【系统架构设计百科】架构质量属性:不只是"高可用高性能"

需求评审时写下的'高可用、高性能、高并发',到了架构设计阶段几乎无法落地——因为它们不是可执行的需求。本文从 SEI/CMU 的质量属性理论出发,用 stimulus-response 场景模型把模糊需求变成可量化、可验证的架构约束,并拆解属性之间的冲突与联动关系。

2026-04-13 · architecture

【系统架构设计百科】告警策略:如何避免"狼来了"

大多数团队的告警系统都在制造噪声而不是传递信号。阈值告警看似直观,实则产生大量误报和漏报,值班工程师在凌晨三点被叫醒,却发现只是一次无害的毛刺。本文从告警疲劳的工业数据出发,拆解基于 SLO 的多窗口燃烧率告警算法,深入 Alertmanager 的路由、抑制与分组机制,结合 PagerDuty 的告警疲劳研究和真实工程案例,给出一套可落地的告警策略设计方法。


By .