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 不是绝对的禁忌。以下场景中它是合理的折中:
- 遗留系统迁移过渡期。 有状态应用正在改造中,Sticky Session 作为短期过渡方案。
- 特定协议要求。 某些 RPC 协议(如 gRPC 的双向流)需要连接级别的亲和性。
- 本地缓存优化。 当缓存命中率对性能至关重要,且缓存数据量太大无法全量放入每个实例时,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 编码,可被解码 |
| 适用场景 | 单体应用、需要即时撤销 | 微服务、跨域、第三方集成 |
| 实现复杂度 | 低 | 中等(需处理刷新、撤销) |
混合方案
实践中很多系统采用混合架构:
- 对外 API 使用 JWT:移动端、第三方调用方不需要维护 Cookie,JWT 的自包含特性简化了认证流程。
- 内部管理后台 使用服务端 Session:操作敏感度高,需要即时撤销能力,用户量小,Session 存储开销可控。
- 服务间调用 使用短有效期 JWT 或 mTLS:服务之间的身份验证不依赖用户会话。
# 混合认证架构配置示例
# 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五、无状态服务中的文件上传
文件上传是无状态改造中最容易被忽略的问题。传统架构中,文件先上传到应用服务器的本地磁盘,处理完再存到最终位置。在多实例环境下,这个模式会出两个问题:
- 分块上传(Chunked Upload)的多个块可能被路由到不同实例,而每个实例只有自己那部分数据。
- 本地临时文件在实例重启或缩容后丢失。
方案一:直传对象存储(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。延迟低,Session 的生命周期短(丢失可重新登录),不需要强持久化。
- 文件状态必须用对象存储。数据库存文件是反模式,Redis 存文件浪费内存。
- 计算状态(任务进度、工作流)用数据库或 Redis,取决于是否需要持久化。短任务(分钟级)用 Redis,长任务(小时级)用数据库。
- 缓存状态根据一致性要求选择:强一致用集中式 Redis,允许短暂不一致用本地缓存 + 广播。
九、常见陷阱与边界
陷阱一:把所有东西都塞进 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(风险最低、收益最高),再改文件,最后处理长连接。每一步都要有回退方案。
参考资料
- Twelve-Factor App - Processes. https://12factor.net/processes —— 无状态设计的核心纲领。
- Martin Fowler. Patterns of Enterprise Application Architecture. —— Session State 模式的经典分类。
- RFC 7519 - JSON Web Token (JWT). https://datatracker.ietf.org/doc/html/rfc7519
- Spring Session Documentation. https://docs.spring.io/spring-session/reference/
- AWS Well-Architected Framework. https://docs.aws.amazon.com/wellarchitected/latest/framework/design-principles.html
- Kubernetes Pod Lifecycle. https://kubernetes.io/docs/concepts/workloads/pods/pod-lifecycle/#pod-termination
- Redis Cluster Specification. https://redis.io/docs/reference/cluster-spec/
上一篇:扩展性原则
下一篇:缓存架构
同主题继续阅读
把当前热点继续串成多页阅读,而不是停在单篇消费。
【身份与访问控制工程】JWT、JWS、JWE、JWKS 一次讲透
JWT 是现代身份系统事实上的令牌格式,但围绕它的 JWS、JWE、JWK、JWKS 四个 RFC 常常被混为一谈。本文从标准归属、字段细节、算法选型、攻击面、密钥轮换到生产运维,把 JWT 相关的工程问题一次讲透
【身份与访问控制工程】Session、Refresh Token 与吊销体系
JWT 的无状态签发解决了分布式认证的扩展性,但也把吊销这件事推回到了工程师面前。一个短期 access token 配长期 refresh token 的混合架构,在 Google、Auth0、Keycloak、AWS Cognito 的实现里趋同收敛,但细节差异能决定系统在被攻击时是多丢一个账号还是多丢一百万。本文把 refresh token rotation、reuse detection、family-based abort、OIDC back-channel logout、Redis 黑名单、Bloom filter 加速、批量吊销场景拆开讲清楚。
【系统架构设计百科】扩展性原理:水平、垂直与对角扩展
系统扩展性并非简单堆机器就能获得线性增长。本文从 Amdahl 定律和通用可扩展性定律(USL)出发,用数学模型量化串行化比例与一致性开销对吞吐量的真实约束,并结合工程案例说明如何识别瓶颈、选择扩展策略。
【系统架构设计百科】认证架构:从 Session 到 JWT 到 OIDC
用户登录这件事,从单体时代的 Session-Cookie 到微服务时代的 JWT,再到企业级 SSO 的 OIDC,每一次演进都在解决上一代方案的痛点,同时引入新的复杂性。本文从 Session 的状态管理问题出发,拆解 JWT 的无状态验证机制与吊销困境,深入分析 OAuth 2.0 授权码流程的完整攻击面,给出 SSO 架构选型的工程判据。