集群

集群(Clustering)

目录

默认情况下,单个 Worker 实例会在内存中保存所有已连接的 WebSocket 客户端。这足以应对大多数部署。如果你需要高可用性,或希望将负载分散到多台服务器上,可以运行多个 Worker 实例,通过 Redis Pub/Sub 频道相互连接。

何时需要集群?

情况 解决方案
单台服务器、负载适中 单实例(默认)
高可用性 / 零停机重启 2 个以上实例 + Redis
操作员众多的大型比赛 2 个以上实例 + Redis + 负载均衡器

对于典型的集体台,甚至大型多人比赛,一台配置普通的 VPS 上的单个 Worker 实例即可处理数百个并发 WebSocket 连接而毫无问题。

集群模式如何工作

配置 redis_url 后,每个 Worker 实例会:

  1. 发布:将广播事件发布到共享的 Redis Pub/Sub 频道(wavelog:events)
  2. 订阅:订阅该频道,并将收到的事件转发给本地客户端
  3. 存储:将主题注册信息存储在 Redis 中(TTL 为 24 小时),使所有节点共享同一注册表
  4. 宣告:在 Redis 哈希 wavelog:nodes 中宣告自身,每 5 秒发送一次心跳(Worker 0.3.0+)。因此任何节点都能报告整个集群,Wavelog 也只需一个 worker_url,而无需列出所有节点。节点名即主机名(Kubernetes 中为 Pod 名称)。

这意味着到达任意节点的发布消息都会立即转发到所有节点上的客户端——无论这些客户端连接的是哪个实例。

graph TB
    PHP[Wavelog PHP] -->|POST /internal/publish| W1
    W1[Worker Instance 1] -->|Publish| R[(Redis)]
    W2[Worker Instance 2] -->|Subscribe| R
    W3[Worker Instance 3] -->|Subscribe| R
    W1 --> C1[Client A]
    W1 --> C2[Client B]
    W2 --> C3[Client C]
    W3 --> C4[Client D]
    R -->|Fan out| W2
    R -->|Fan out| W3

每个实例也会直接将事件投递给其自身的本地客户端,无需等待 Redis 往返。

配置

1. Redis

你需要一个所有 Worker 实例都能访问的 Redis 实例。使用 Docker 的最小 Redis 配置如下:

docker run -d \
  --name redis \
  --restart unless-stopped \
  -p 6379:6379 \
  redis:7-alpine redis-server --save ""

或者使用托管的 Redis 服务(Upstash、Redis Cloud 等)。

2. Worker 配置

将 redis_url 添加到每个 Worker 的 config.yaml 中:

redis_url: "redis://localhost:6379/2"

请使用专用的 Redis 数据库(例如 /2),以避免与共享同一 Redis 实例的其他应用发生键冲突。

3. 负载均衡器

将传入的 WebSocket 连接路由到各个 Worker 实例。关键要求是粘性会话(也称为会话亲和性)——浏览器的 WebSocket 升级请求及后续所有帧都必须到达同一个 Worker 实例。一旦连接建立,任何反向代理的标准 WebSocket 负载均衡都会自动处理这一点,但请确保你的健康检查指向 /internal/status。

示例:Nginx upstream

upstream wavelog_worker {
    least_conn;
    server worker1.internal:9000;
    server worker2.internal:9000;
    server worker3.internal:9000;
}

server {
    listen 443 ssl;
    # ... TLS config ...

    location /worker/ {
        proxy_pass http://wavelog_worker/;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection "upgrade";
        proxy_set_header Host $host;
        proxy_read_timeout 3600s;
    }
}

Docker Compose(多实例)

services:
  redis:
    image: redis:7-alpine
    command: redis-server --save ""
    restart: unless-stopped

  wavelog-worker-1:
    image: ghcr.io/wavelog/wavelog_worker:latest
    hostname: worker-1
    restart: unless-stopped
    depends_on:
      - redis
    ports:
      - "9000:9000"
    volumes:
      - ./worker/config.yaml:/app/config.yaml:ro

  wavelog-worker-2:
    image: ghcr.io/wavelog/wavelog_worker:latest
    hostname: worker-2
    restart: unless-stopped
    depends_on:
      - redis
    ports:
      - "9002:9000"
    volumes:
      - ./worker/config.yaml:/app/config.yaml:ro

将 redis_url: "redis://redis:6379/2" 写入 config.yaml 后,两个实例通过 Redis 容器共享状态。hostname: 会在 Wavelog 调试页面上为节点提供可读名称;不设置的话你看到的是容器 ID。

在 Wavelog 的 worker.php 中,一个 URL 就足够了,例如 $config['worker_url'] = 'http://wavelog-worker-1:9001';(或者在 9001 端口前放置负载均衡器)。

Kubernetes

由于节点通过 Redis 相互发现,一个任意副本数的普通 Deployment 即可工作。无需 StatefulSet,无需无头服务,也无需为每个 Pod 配置 URL。

apiVersion: apps/v1
kind: Deployment
metadata:
  name: wavelog-worker
spec:
  replicas: 3
  strategy:
    type: RollingUpdate
    rollingUpdate: { maxSurge: 1, maxUnavailable: 0 }
  selector:
    matchLabels: { app: wavelog-worker }
  template:
    metadata:
      labels: { app: wavelog-worker }
    spec:
      containers:
        - name: worker
          image: ghcr.io/wavelog/wavelog_worker:latest
          ports:
            - { name: ws, containerPort: 9000 }
            - { name: internal, containerPort: 9001 }
          readinessProbe:
            httpGet: { path: /readyz, port: internal }
          volumeMounts:
            - { name: config, mountPath: /app/config.yaml, subPath: config.yaml, readOnly: true }
      volumes:
        - name: config
          secret: { secretName: wavelog-worker }   # contains config.yaml with worker_secret
---
apiVersion: v1
kind: Service
metadata:
  name: wavelog-worker
spec:
  selector: { app: wavelog-worker }
  ports:
    - { name: ws, port: 9000, targetPort: ws }
    - { name: internal, port: 9001, targetPort: internal }

Worker 的 config.yaml 需要设置 internal_bind: "0.0.0.0",并将 redis_url 指向你的 Redis/Valkey 服务。在 Wavelog 中设置 $config['worker_url'] = 'http://wavelog-worker:9001';,并将浏览器的 WebSocket(/ws)通过你的 Ingress 路由到同一服务的 9000 端口。扩容或缩容部署后,Wavelog 调试页面会在几秒内反映变化。

验证集群模式

任意节点的 /internal/status 端点都会报告整个集群:

curl -s -H "X-Worker-Secret: your-secret" http://localhost:9001/internal/status
{
  "status": "ok",
  "cluster_nodes": 3,
  "nodes": [
    { "id": "3f9c2a1b7d4e6f80", "name": "worker-1", "version": "0.3.0", "alive": true,  "uptime": "3h22m0s", "active_topics": 2, "connected_clients": 5, "connected_sockets": 6, "...": "..." },
    { "id": "8a1d0c4e2b6f9e13", "name": "worker-2", "version": "0.3.0", "alive": true,  "uptime": "3h21m58s", "...": "..." },
    { "id": "c72e5b9a0d1f4e88", "name": "worker-3", "version": "0.3.0", "alive": false, "uptime": "1h02m11s", "...": "..." }
  ],
  ...
}
  • nodes 是来自 Redis 的集群成员列表:每个 Worker 实例一项,按名称排序。当节点的心跳时间小于 15 秒时,alive 为 true。null 表示当前无法访问 Redis。
  • cluster_nodes 是当前订阅 Redis Pub/Sub 频道的实例数(实时计数,无宽限窗口)。-1 表示单实例模式(未配置 Redis)。

节点生命周期

事件 对 nodes 的影响 Wavelog 调试页面
节点启动 5 秒内出现条目 节点数增加
正常停止(SIGTERM、docker stop、缩容、滚动更新) 条目立即移除 数量减少,绝不会显示「Degraded」
崩溃(SIGKILL、OOM、主机丢失) 条目保留为 alive: false 达 15 分钟,随后被遗忘 「2/3 Degraded」最多持续 15 分钟,之后变为「2/2」
节点以相同主机名重新出现 失效条目立即被替换 立即恢复正常

因此,调试页面上的「Degraded」意味着某个节点没有告别就消失了。如果这是有意为之(是你把它杀掉了),它会在 15 分钟后自行清除。

Redis 不可用

如果配置了 Redis,但启动时无法访问或之后连接丢失,Worker 会继续运行并服务自己的 WebSocket 客户端,以退避方式重试连接(1 秒到 30 秒),并在 Redis 恢复后自行恢复集群模式:

cluster: redis lost, retrying: dial tcp ...
cluster: redis connected

断开期间,GET :9001/readyz 返回 503,因此 Kubernetes 就绪探针或负载均衡器健康检查会让流量远离该节点,事件不会与其他实例同步,nodes 在 /internal/status 中为 null(此时 Wavelog 会退回轮询其已配置的 URL)。无需重启。

主题注册表与重启

在单实例模式下,主题注册信息保存在内存中,重启后会丢失。Wavelog 通过 404 响应(来自 /internal/publish)检测到这一点,并透明地重新注册该主题。

在集群模式下,主题注册信息存储在 Redis 中,可以经受单个实例的重启。主题会在 24 小时后自动过期,并在 Wavelog 重新注册它们时刷新(例如页面重新加载或比赛开始时)。

Wavelog 手册(中文版) / 集群 0 字 0 行 bg8lng
2026-10-21T12:42:36.562456010Z 2026-10-21T12:44:16.040956155Z