会话保持(Sticky Session)

无状态设计是分布式系统的最佳实践,但现实中总有例外。某些场景下,同一用户的请求必须路由到同一节点——这就是会话保持(Sticky Session)的需求。本节讲解三种实现方式和它们的 trade-off。

什么时候需要会话保持

flowchart TB
    subgraph Stateless["无状态场景"]
        S1["请求 1\n用户 A"] --> N1["节点 1"]
        S2["请求 2\n用户 A"] --> N2["节点 2"]
        S3["请求 3\n用户 A"] --> N3["节点 3"]

        Note1["请求可以路由到任意节点"]
    end

    subgraph Stateful["有状态场景"]
        F1["请求 1\n用户 A"] --> M1["节点 1\n本地内存有用户 A 数据"]
        F2["请求 2\n用户 A"] --> M2["节点 2"]
        F3["请求 3\n用户 A"] --> M3["节点 3"]

        Note2["请求必须路由到同一节点"]
    end

    style Stateful fill:#ffcdd2

需要会话保持的场景

  • 本地内存存储 Session(未使用分布式 Session)
  • WebSocket 长连接
  • 服务器端缓存(每个节点独立)
  • 某些协议的连接状态

三种实现方式

方式原理优点缺点
Cookie 重写在 Cookie 中写入节点标识简单,客户端无需改造Cookie 暴露节点信息
Session 亲和性负载均衡器维护会话表可追溯增加内存开销
分布式 SessionSession 统一存储(Redis)节点无关增加网络延迟

原理

sequenceDiagram
    participant C as 客户端
    participant LB as 负载均衡器
    participant S as 后端服务

    Note over C,LB: 请求 1:首次访问
    C->>LB: GET /api/user
    LB->>S: 路由到节点 1
    S-->>LB: HTTP 200 + Set-Cookie: SERVERID=node1
    LB-->>C: HTTP 200 + Set-Cookie: SERVERID=node1

    Note over C,LB: 请求 2:携带 Cookie
    C->>LB: GET /api/user<br/>Cookie: SERVERID=node1
    LB->>S: 解析 Cookie,路由到节点 1
    S-->>LB: HTTP 200
    LB-->>C: HTTP 200

Nginx 配置

upstream backend {
    ip_hash;  # 基于 IP Hash 的会话保持

    server 10.0.1.1:8080;
    server 10.0.1.2:8080;
}

# 或者基于 Cookie
upstream backend {
    server 10.0.1.1:8080;
    server 10.0.1.2:8080;
}

server {
    listen 80;

    # Cookie 标记
    split_clients "${remote_addr}%10" $sticky_route {
        0       node1;
        1       node1;
        2       node1;
        3       node1;
        4       node2;
        5       node2;
        6       node2;
        7       node2;
        8       node3;
        9       node3;
        *       node1;
    }

    location / {
        proxy_pass http://backend;
        # 使用 $sticky_route 进行路由
    }
}

HAProxy 配置

backend api_backend
    mode http
    balance roundrobin

    # 基于 Cookie 的会话保持
    cookie SERVERID insert indirect nocache

    server api1 10.0.1.1:8080 check cookie s1
    server api2 10.0.1.2:8080 check cookie s2
    server api3 10.0.1.3:8080 check cookie s3

实际请求示例

# 请求 1:首次访问
GET /api/user HTTP/1.1
Host: api.example.com

HTTP/1.1 200 OK
Set-Cookie: SERVERID=node1; Path=/; HttpOnly

# 请求 2:携带 Cookie
GET /api/user HTTP/1.1
Host: api.example.com
Cookie: SERVERID=node1

HTTP/1.1 200 OK

Session 亲和性

原理

负载均衡器维护一张会话表,记录用户与节点的映射:

会话表:
| 用户标识 | 节点 |
|---------|------|
| 192.168.1.100 | 节点 1 |
| 192.168.1.101 | 节点 2 |
| 192.168.1.102 | 节点 1 |

实现

public class SessionAffinityLoadBalancer {

    private final ConcurrentHashMap<String, String> sessionTable = new ConcurrentHashMap<>();
    private final ConcurrentHashMap<String, Long> sessionExpiry = new ConcurrentHashMap<>();
    private final List<String> servers;
    private final Duration sessionTimeout = Duration.ofMinutes(30);

    public String select(String clientId) {
        // 检查是否有已有会话
        String server = sessionTable.get(clientId);

        if (server != null) {
            // 检查会话是否过期
            Long expiry = sessionExpiry.get(clientId);
            if (expiry != null && System.currentTimeMillis() < expiry) {
                // 刷新过期时间
                sessionExpiry.put(clientId, System.currentTimeMillis() + sessionTimeout.toMillis());
                return server;
            } else {
                // 会话过期,移除
                sessionTable.remove(clientId);
                sessionExpiry.remove(clientId);
            }
        }

        // 选择新服务器
        server = selectByRoundRobin();
        sessionTable.put(clientId, server);
        sessionExpiry.put(clientId, System.currentTimeMillis() + sessionTimeout.toMillis());

        return server;
    }
}

LVS 会话保持

# LVS 会话保持配置(持久连接)
ipvsadm -A -t 192.168.1.100:80 -s rr -p 1800
# -p 1800:1800 秒内同一 IP 路由到同一服务器

分布式 Session

原理

flowchart LR
    subgraph Client["客户端"]
        C["浏览器"]
    end

    subgraph LB["负载均衡器\n无会话保持"]
        LBa["LB"]
    end

    subgraph Servers["服务节点"]
        S1["节点 1"]
        S2["节点 2"]
        S3["节点 3"]
    end

    subgraph Session["Session 存储"]
        Redis["Redis 集群"]
    end

    C --> LBa
    LBa --> S1
    LBa --> S2
    LBa --> S3

    S1 <--> Redis
    S2 <--> Redis
    S3 <--> Redis

    Note["Session 统一存储,节点无状态"]

Spring Session + Redis

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

    @Bean
    public LettuceConnectionFactory connectionFactory() {
        RedisStandaloneConfiguration config = new RedisStandaloneConfiguration();
        config.setHostName("redis.example.com");
        config.setPort(6379);
        return new LettuceConnectionFactory(config);
    }
}
// 使用分布式 Session
@RestController
public class UserController {

    @GetMapping("/user")
    public String getUser(HttpSession session) {
        // 从 Redis 获取 Session
        String userId = (String) session.getAttribute("userId");
        String username = (String) session.getAttribute("username");

        return "User: " + username;
    }

    @PostMapping("/login")
    public String login(HttpSession session) {
        // 写入 Redis Session
        session.setAttribute("userId", "12345");
        session.setAttribute("username", "张三");

        return "Login success";
    }
}

Session 序列化配置

# application.yml
spring:
  session:
    store-type: redis
    redis:
      namespace: app:session
      flush-mode: on_save
      save-mode: always
  data:
    redis:
      host: redis.example.com
      port: 6379
      timeout: 2000ms

三种方案对比

维度Cookie 重写Session 亲和性分布式 Session
实现复杂度
内存开销中(会话表)
延迟+1~2ms
可扩展性差(节点信息在 Cookie)差(需要刷新会话表)好(节点无关)
可靠性高(Cookie 在客户端)中(LB 故障丢失)高(Redis 多副本)
适用场景简单会话保持内部服务微服务架构

会话保持的代价

会话保持虽然解决了有状态问题,但也带来了挑战:

问题一:流量不均匀

场景:某些用户请求量特别大

结果:
- 这些用户所在的节点负载特别高
- 其他节点相对空闲
- 整体利用率下降

问题二:扩展困难

场景:需要扩容一个节点

问题:
- 新节点没有会话
- 旧节点会话还在

解决:
1. 会话迁移(复杂)
2. 强制所有会话过期(用户体验差)
3. 使用分布式 Session

问题三:故障影响大

场景:某个节点故障

问题:
- 该节点上的所有会话丢失
- 用户需要重新登录

解决:
1. Session 备份
2. 快速故障切换
3. 使用分布式 Session

生产环境最佳实践

方案一:分布式 Session(推荐)

@Configuration
@EnableRedisHttpSession(maxInactiveIntervalInSeconds = 3600)
public class RedisSessionConfig {
    @Bean
    public RedisSerializer<Object> springSessionDefaultRedisSerializer() {
        return new GenericJackson2JsonRedisSerializer();
    }
}

方案二:Cookie + 分布式 Session

# Nginx 配置:优先使用 Cookie,会话数据存在 Redis
upstream backend {
    server 10.0.1.1:8080;
    server 10.0.1.2:8080;
}

server {
    listen 80;
    server_name api.example.com;

    # 简单路由
    location / {
        proxy_pass http://backend;
    }
}

方案三:Redis Session + JWT

// 使用 JWT 替代 Session
@Service
public class AuthService {

    public String createToken(User user) {
        Map<String, Object> claims = new HashMap<>();
        claims.put("userId", user.getId());
        claims.put("username", user.getUsername());

        return Jwts.builder()
            .claims(claims)
            .issuedAt(new Date())
            .expiration(new Date(System.currentTimeMillis() + 3600000))
            .signWith(key)
            .compact();
    }
}

总结

会话保持解决的是「同一用户的请求路由到同一节点」的需求:

Cookie 重写

  • 在响应 Cookie 中写入节点标识
  • 简单,但节点信息暴露
  • 适合简单场景

Session 亲和性

  • 负载均衡器维护会话表
  • 可追溯,但增加内存开销
  • 适合内部服务

分布式 Session

  • Session 统一存储到 Redis
  • 节点无关,可扩展性好
  • 推荐方案

会话保持的 trade-off:

  • 破坏负载均衡的均匀性
  • 影响扩展性
  • 优先考虑无状态设计

下一节我们将讲解全局负载均衡与灾备。