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/
上一篇:扩展性原则
下一篇:缓存架构
同主题继续阅读
把当前热点继续串成多页阅读,而不是停在单篇消费。
【系统架构设计百科】扩展性原理:水平、垂直与对角扩展
系统扩展性并非简单堆机器就能获得线性增长。本文从 Amdahl 定律和通用可扩展性定律(USL)出发,用数学模型量化串行化比例与一致性开销对吞吐量的真实约束,并结合工程案例说明如何识别瓶颈、选择扩展策略。
【系统架构设计百科】认证架构:从 Session 到 JWT 到 OIDC
用户登录这件事,从单体时代的 Session-Cookie 到微服务时代的 JWT,再到企业级 SSO 的 OIDC,每一次演进都在解决上一代方案的痛点,同时引入新的复杂性。本文从 Session 的状态管理问题出发,拆解 JWT 的无状态验证机制与吊销困境,深入分析 OAuth 2.0 授权码流程的完整攻击面,给出 SSO 架构选型的工程判据。
【系统架构设计百科】架构质量属性:不只是"高可用高性能"
需求评审时写下的'高可用、高性能、高并发',到了架构设计阶段几乎无法落地——因为它们不是可执行的需求。本文从 SEI/CMU 的质量属性理论出发,用 stimulus-response 场景模型把模糊需求变成可量化、可验证的架构约束,并拆解属性之间的冲突与联动关系。
【系统架构设计百科】告警策略:如何避免"狼来了"
大多数团队的告警系统都在制造噪声而不是传递信号。阈值告警看似直观,实则产生大量误报和漏报,值班工程师在凌晨三点被叫醒,却发现只是一次无害的毛刺。本文从告警疲劳的工业数据出发,拆解基于 SLO 的多窗口燃烧率告警算法,深入 Alertmanager 的路由、抑制与分组机制,结合 PagerDuty 的告警疲劳研究和真实工程案例,给出一套可落地的告警策略设计方法。