X 轴扩展:水平复制

AKF 扩展立方体的 X 轴,是扩展的起点和基石。当业务流量增长,第一反应通常是「多加几台机器」。这个直觉背后的原理,就是水平复制。

什么是水平复制

水平复制(Horizontal Replication)是在 X 轴维度上的扩展——部署多个完全相同的服务实例,通过负载均衡器分发请求,每个实例处理总请求量的 1/N。

flowchart LR
    Client["客户端"] --> LB["负载均衡器"]
    LB -->|分发请求| Instance1["实例 1"]
    LB -->|分发请求| Instance2["实例 2"]
    LB -->|分发请求| Instance3["实例 N"]

    subgraph Shared["共享存储"]
        DB["数据库"]
        Cache["缓存"]
    end

    Instance1 --> Shared
    Instance2 --> Shared
    Instance3 --> Shared

关键特征是「完全相同」——每个实例运行相同的代码,访问相同的数据源。它们之间没有任何区别,可以随时增减,不影响服务可用性。

克隆服务实例

水平复制的第一步,是让服务能够运行多个实例。

容器化部署

容器化是现代水平复制的事实标准。通过 Docker 镜像打包应用,Kubernetes 或 Docker Swarm 自动管理副本数。

Dockerfile
FROM openjdk:17-slim
WORKDIR /app
COPY target/app.jar /app/
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "/app/app.jar"]
Kubernetes
apiVersion: apps/v1
kind: Deployment
metadata:
  name: api-server
spec:
  replicas: 3
  selector:
    matchLabels:
      app: api-server
  template:
    metadata:
      labels:
        app: api-server
    spec:
      containers:
      - name: api-server
        image: registry.example.com/api-server:latest
        ports:
        - containerPort: 8080
        resources:
          requests:
            memory: "512Mi"
            cpu: "250m"
          limits:
            memory: "1Gi"
            cpu: "500m"
        livenessProbe:
          httpGet:
            path: /health
            port: 8080
          initialDelaySeconds: 30
          periodSeconds: 10
        readinessProbe:
          httpGet:
            path: /ready
            port: 8080
          initialDelaySeconds: 5
          periodSeconds: 5

健康检查

每个实例需要健康检查机制,让负载均衡器知道哪些实例是健康的、可以接收流量。

存活探针(Liveness Probe):判断容器是否存活。如果探针失败,Kubernetes 会重启容器。

就绪探针(Readiness Probe):判断容器是否准备好接收流量。如果探针失败,Kubernetes 会把实例从服务中摘除,但不重启。

Spring
@RestController
public class HealthController {

    @Autowired
    private DatabaseService databaseService;

    @GetMapping("/health")
    public ResponseEntity<String> health() {
        return ResponseEntity.ok("OK");
    }

    @GetMapping("/ready")
    public ResponseEntity<Map<String, Object>> ready() {
        Map<String, Object> status = new HashMap<>();
        status.put("database", databaseService.isHealthy() ? "up" : "down");
        status.put("timestamp", System.currentTimeMillis());

        boolean ready = databaseService.isHealthy();
        return ready
            ? ResponseEntity.ok(status)
            : ResponseEntity.status(503).body(status);
    }
}

负载均衡分发

负载均衡器是水平复制的关键组件。它负责把请求均匀(或者按策略)分发到各个实例。

负载均衡算法

轮询(Round Robin):按顺序依次分发。最简单,但如果实例性能差异大,可能导致负载不均。

加权轮询(Weighted Round Robin):按权重比例分发。适合不同规格的实例。

最少连接(Least Connections):分发到当前连接数最少的实例。适合处理时间差异大的场景。

IP 哈希(IP Hash):同一 IP 的请求始终路由到同一实例。适合需要会话粘性的场景。

Nginx 配置示例

nginx.conf
upstream backend {
    least_conn;  # 最少连接算法

    server 192.168.1.10:8080 weight=3;
    server 192.168.1.11:8080 weight=2;
    server 192.168.1.12:8080 weight=1;
}

server {
    listen 80;

    location / {
        proxy_pass http://backend;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;

        # 健康检查配置
        health_check interval=5s fails=2 passes=2;
    }
}

分布式 Session 问题

水平复制后,同一用户的请求可能被分发到不同实例。如果使用本地 Session,会出现「登录状态丢失」的问题。

解决方案是 Session 外置到 Redis:

Spring
@Configuration
@EnableRedisHttpSession(maxInactiveIntervalInSeconds = 3600)
public class SessionConfig {
    // Spring Session 自动将会话存储到 Redis
    // 所有实例通过 Redis 共享会话数据
}

缓存复制

水平复制的另一个问题是缓存。每个实例都有自己的本地缓存,这些缓存无法共享。

缓存不一致问题

假设用户更新了个人信息。实例 A 更新了数据库,但实例 B 的本地缓存还是旧数据。下一个请求被路由到实例 B,用户看到的是过期数据。

解决方案

方案一:分布式缓存

不用本地缓存,统一使用 Redis 等分布式缓存。所有实例共享一份缓存数据。

分布式缓存访问
@Service
public class UserService {

    @Autowired
    private RedisTemplate<String, User> redisTemplate;

    private static final String USER_CACHE_PREFIX = "user:";

    public User getUser(Long userId) {
        String key = USER_CACHE_PREFIX + userId;
        User user = redisTemplate.opsForValue().get(key);

        if (user == null) {
            user = userRepository.findById(userId);
            if (user != null) {
                redisTemplate.opsForValue().set(key, user, 1, TimeUnit.HOURS);
            }
        }
        return user;
    }
}

方案二:缓存失效广播

更新数据时,主动失效所有实例的本地缓存。通过 Redis Pub/Sub 或消息队列广播失效消息。

缓存失效广播
@Service
public class CacheInvalidationService {

    @Autowired
    private RedisMessageSubscriber subscriber;

    public void invalidateUserCache(Long userId) {
        String pattern = "user:" + userId + ":*";
        // 本地缓存失效
        localCache.invalidate(pattern);
        // 广播给其他实例
        redisTemplate.convertAndSend("cache:invalidate", pattern);
    }
}

方案三:设置较短的缓存过期时间

让缓存自然过期,降低不一致窗口。简单但不够优雅。

适用场景

X 轴扩展不是万能的,它只解决「请求量增长」的问题。

适合 X 轴扩展的场景

  • 无状态服务:请求之间无依赖,不依赖本地数据
  • 读多写少:读请求可以分散到多个实例,写请求通过分布式存储聚合
  • 请求处理时间均匀:不会因为某个实例处理慢导致负载不均
  • 无强一致性要求:可以接受最终一致

不适合 X 轴扩展的场景

  • 有状态服务:服务依赖本地状态,如游戏服务器、实时通信
  • 数据量巨大:数据本身无法存储在单机
  • 强一致性要求:跨实例的分布式事务复杂
  • 计算密集型:CPU 打满时,增加实例反而增加资源竞争

X 轴扩展的局限性

X 轴扩展能扛更多请求,但不能扛更大数据量。所有实例共享同一个数据库,数据库可能成为新的瓶颈。

数据库 CPU 打满:10 个 API 实例同时访问一个数据库,数据库 CPU 打满,增加实例只会让数据库更忙。解决方案是 Y 轴拆分(读写分离)或 Z 轴分片。

连接池耗尽:每个实例维护自己的数据库连接池。10 个实例 × 50 连接 = 500 连接。连接数超过数据库限制,新的请求只能等待。

网络带宽饱和:10 个实例同时访问数据库,网络带宽可能成为瓶颈。

常见误区

误区一:实例越多越好

实例增加意味着更高的运维成本、更复杂的问题定位、更大的资源消耗。应该找到「足够」的实例数,而不是「尽可能多」。

误区二:忽视数据库瓶颈

X 轴扩展的前提是后端存储不是瓶颈。如果数据库已经饱和,应该先优化数据库(索引、SQL、架构),而不是盲目增加应用实例。

误区三:不做容量规划

X 轴扩展应该有明确的目标。例如:「当前峰值 5000 QPS,单实例处理 500 QPS,需要 10 个实例」。

误区四:实例配置不同

所有实例应该是相同的。不应该让某些实例处理更复杂的请求,否则负载不均。

延伸思考

X 轴扩展是水平扩展的起点,但绝不是终点。理解 X 轴的边界,才能知道什么时候需要 Y 轴(服务拆分)或 Z 轴(数据分片)。

好的 X 轴扩展实践,应该让系统「随时可以扩展」。无状态化、配置外置、健康检查、优雅关闭——这些基础设施做好之后,扩展就是改一个数字的事。

当 X 轴扩展到达极限(通常是数据库瓶颈),就到了考虑 Y 轴和 Z 轴的时候。但那是另一个维度的问题。