Files
Edu/services/push-gateway/docs/02-architecture-design.md

40 KiB
Raw Blame History

模块架构设计文档 — push-gateway

阶段:阶段 2 交付物v1.0 仲裁回写于 2026-07-10 日期2026-07-09v0.1/ 2026-07-10v1.0 关联:01 理解确认书004 架构影响地图president-final-rulings

本文档覆盖WebSocket 连接生命周期、与 msg 的 HTTP 推送通道协议、用户 session 映射、水平扩展方案Redis Pub/Sub 跨实例广播)。仲裁结果见 §14 ADR。


1. 模块内部分层图

graph TB
    subgraph Client["客户端"]
        B[浏览器/移动端]
    end

    subgraph External["外部入口层"]
        WS[WebSocket Upgrader<br/>/ws?token=JWT]
    end

    subgraph MW["中间件链"]
        M1[Recovery]
        M2[OTelgin]
        M3[RequestID]
        M4[OriginGuard<br/>CheckOrigin 白名单]
        M5[InternalAuth<br/>/internal/* 校验]
    end

    subgraph Hub["Hub 连接池(核心)"]
        H[Hub<br/>map userID -> map connID -> Connection]
        Conn[Connection<br/>userID + ws + send chan + heartbeat]
    end

    subgraph Writer["写协程池"]
        W[每 Connection 一个写协程<br/>消费 send chan 写 ws]
    end

    subgraph Reader["读协程"]
        R[每 Connection 一个读协程<br/>处理 Ping 控制帧 + 消息]
    end

    subgraph PushAPI["内部推送 API"]
        P1[POST /internal/push]
        P2[POST /internal/broadcast]
    end

    subgraph Redis["Redis Pub/Sub横向扩展"]
        Sub[Subscriber<br/>订阅 edu.push.user.* + edu.push.broadcast]
        Pub[Publisher<br/>本实例无此用户时 PUBLISH]
    end

    subgraph Kafka["Kafka 消费者(可选)"]
        K[Consumer Group<br/>topic: edu.notification.requested]
    end

    subgraph Msg["msg 服务"]
        MSG[msg :3007]
    end

    B -->|WebSocket| WS
    WS --> M1 --> M2 --> M3 --> M4 --> H
    H --> Conn
    Conn --> W
    Conn --> R
    R -->|Ping| Conn
    MSG -->|HTTP /internal/push| P1
    MSG -->|HTTP /internal/broadcast| P2
    P1 --> H
    P2 --> H
    P1 -.本地无此用户.-> Pub --> Sub
    Sub -.广播到本实例.-> H
    MSG -.NotificationRequested 事件.-> K
    K --> H

    style Redis fill:#fff4e6
    style Kafka fill:#fff4e6

关键设计决策

  • Hub 是单例,进程内唯一,持有所有连接
  • 每 Connection 两个 goroutine:读协程(消费 ws+ 写协程(消费 send chan
  • Redis Pub/Sub 解决跨实例广播:本实例无目标用户时 PUBLISH 到 edu.push.user.<userID> channel所有实例订阅此 channel持有该用户的实例收到后投递
  • Kafka 消费可选:广播类通知走 Kafka 解耦;定向推送走 HTTP /internal/push(同步返结果)

2. 领域模型

无业务聚合。仅连接管理值对象:

// Connection 包装单个 WebSocket 连接
type Connection struct {
    UserID    string
    ConnID    string                 // 连接唯一 IDuuid
    conn      *websocket.Conn
    send      chan []byte             // 异步发送通道cap 64
    closeOnce sync.Once
    closed    bool
    mu        sync.Mutex
    lastPing  time.Time              // 最后心跳时间
}

// Hub 管理所有在线连接
type Hub struct {
    mu       sync.RWMutex
    clients  map[string]map[string]*Connection  // userID -> connID -> Connection
    counters map[string]int                       // userID -> 连接计数(快速判断超限)
}

不变式

  • Hub.clients[userID][connID] 一一对应 Connection
  • Connection.send channel 关闭后不可再写
  • Connection.closed 为 true 后所有操作 no-op

3. 数据模型

无数据库。所有状态在内存 + Redis。

3.1 Redis 数据结构

Key 模式 类型 TTL 用途
edu:push:online:<userID> SET成员是 instanceID 60s心跳续期 在线用户所在实例集合(用于判断用户是否在线)
edu:push:session:<userID>:<connID> HASH 60s 单连接元数据instanceID, device, lastPing
edu:push:channel:user:<userID> Pub/Sub channel 跨实例定向推送 channel
edu:push:channel:broadcast Pub/Sub channel 跨实例广播 channel

3.2 在线状态判定

用户在线 := Redis SET edu:push:online:<userID> 非空
用户离线 := SET 为空或不存在

续期机制:每 30s 客户端心跳时,本实例 SADD 自己 + EXPIRE 60s。

4. API 设计

4.1 WebSocket 端点

方法 路径 鉴权 说明
GET /ws JWTquery ?token=Authorization: Bearer WebSocket 升级

WebSocket 子协议(应用层消息格式)

// 服务端 → 客户端:推送消息
{
  "type": "message",
  "event": "notification.created",
  "data": { ... },
  "seq": 12345,                  // 全局递增序列号(用于 reconnect
  "timestamp": "2026-07-09T..."
}

// 服务端 → 客户端:确认
{ "type": "ack", "seq": 12345 }

// 客户端 → 服务端心跳WebSocket Ping 控制帧,非文本消息)
// 客户端 → 服务端:消息确认
{ "type": "ack", "seq": 12345 }

4.2 内部推送 API

方法 路径 鉴权 请求体 响应
POST /internal/push X-Internal-Token {user_id, event, data, ttl?} {success, delivered, online}
POST /internal/broadcast X-Internal-Token {event, data, filter?} {success, reached}
GET /internal/online/<userID> X-Internal-Token {online: bool, instances: []}

X-Internal-Token 校验

  • 启动时从 INTERNAL_API_TOKEN 环境变量加载
  • 与 msg 服务共享同一密钥K8s Secret 注入)
  • 缺失/不匹配 → 401

响应语义

  • delivered: true:本实例或跨实例成功投递到至少一个连接
  • online: false用户离线msg 服务应走离线推送SMS/邮件)
  • delivered: false, online: true:投递失败(连接满/异常msg 服务应重试或落库

4.3 健康检查

端点 检查逻辑
/healthz 进程存活,返回 200
/readyz Redis PING + Kafka 元数据探活;软失败ISSUE-058/006失败返 200 + degraded: true,仅 Hub 关闭时返 503

4.4 指标端点

端点 暴露指标
/metrics 见 §6.4

5. 事件设计

5.1 我消费的 Kafka 事件

Topic Message 触发场景 消费动作
edu.notification.requested NotificationRequested msg 服务收到通知请求后发布 消费 → 调用 Hub.SendToUser 投递 → 若离线则 ACK 不重投msg 服务落库)

Topic 命名变更ISSUE-053 / president §1.5:原名 edu.notification.events 不符合 G16 规则(edu.<domain>.<aggregate>.<action>),已改为 edu.notification.requested。旧名作废。

消费语义

  • Consumer Grouppush-gateway
  • 至少一次at-least-once消费失败重试 3 次后入死信队列
  • 幂等性:基于 event_id 去重Redis SETNXTTL 24h

5.2 我发布的 Kafka 事件

。推送结果通过 HTTP /internal/push 同步响应返 msg 服务。

5.3 与 msg 服务的双通道协议

sequenceDiagram
    participant Msg as msg 服务
    participant GW as push-gateway
    participant Hub as Hub
    participant Redis as Redis Pub/Sub
    participant Client as 客户端

    rect rgb(240, 248, 255)
        Note over Msg,Client: 通道 1HTTP 同步推送(定向)
        Msg->>GW: POST /internal/push {user_id, event, data}
        GW->>Hub: SendToUser(user_id, msg)
        alt 本实例有此用户
            Hub->>Client: WebSocket 推送
            Client-->>Hub: ack
            Hub-->>GW: delivered=true
        else 本实例无此用户
            GW->>Redis: PUBLISH edu.push.channel.user.<userID> msg
            Note over Redis: 持有该用户的实例订阅后投递
            Redis-->>GW: 投递结果(异步,通过另一个 ack channel
            GW-->>Msg: delivered=true, online=true
        end
    end

    rect rgb(240, 255, 240)
        Note over Msg,Client: 通道 2Kafka 异步消费(广播)
        Msg->>Msg: 发布 NotificationRequested 事件到 Kafka
        Note over GW: Consumer Group 异步消费
        GW->>Hub: Broadcast(msg)
        Hub->>Client: WebSocket 推送(所有在线连接)
    end

双通道选择规则msg 服务决策):

  • 定向推送(单用户/少数用户)→ HTTP /internal/push(需立即知道在线/离线)
  • 广播(全校/全年级)→ Kafka 事件(解耦 + 削峰)
  • 紧急通知(考试开始)→ HTTP /internal/push(实时性优先)

6. 横切关注点对齐清单

6.1 权限校验

端点 鉴权方式
/ws JWT RS256P2 起,与 api-gateway 共用 JWKS 拉取逻辑)
/internal/* X-Internal-Token 头校验
/healthz /readyz /metrics

6.2 错误码清单

错误码 HTTP 触发条件
PUSH_UNAUTHORIZED 401 缺失/无效 token
PUSH_INVALID_REQUEST 400 JSON 解析失败
PUSH_INVALID_PAYLOAD 400 event/data 字段缺失
PUSH_TOO_MANY_CONNECTIONS 429 单用户连接数超限(>5
PUSH_INTERNAL_ERROR 500 panic / Redis 不可达

6.3 Logger

// internal/observability/logger.go待新增
var Logger *slog.Logger

func InitLogger(level string) {
    Logger = slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: parseLevel(level)}))
    slog.SetDefault(Logger)
}

日志字段timestamp level service=push-gateway request_id trace_id user_id conn_id event

6.4 Metrics 指标清单

指标名 类型 标签 描述
push_gateway_active_connections Gauge 当前在线连接数
push_gateway_connections_per_user_max Gauge 单用户最大连接数(运行期峰值)
push_gateway_messages_pushed_total Counter event, result 推送消息总数result=delivered/dropped/offline
push_gateway_messages_dropped_total Counter reason 丢弃消息数reason=channel_full/buffer_overflow
push_gateway_heartbeat_total Counter 心跳接收总数
push_gateway_disconnect_total Counter reason 断开连接数reason=idle/error/closed/too_many
push_gateway_redis_pubsub_latency_seconds Histogram direction Pub/Sub 延迟publish/subscribe
push_gateway_kafka_consumed_total Counter topic, partition Kafka 消费数
push_gateway_redis_set_rebuild_total Counter Redis online SET 重建次数ISSUE-058 启动重建)
go_* prom-client 默认 Go runtime 指标

6.5 Tracer

已实现 OTel tracer。待补

  • WebSocket 升级 spanws.upgrade
  • 消息推送 spanpush.message,标签 user_id/event
  • Redis Pub/Sub spanredis.publish / redis.subscribe

6.6 /healthz 检查逻辑

func Healthz(c *gin.Context) {
    c.JSON(200, gin.H{
        "status":    "ok",
        "service":   "push-gateway",
        "version":   Version,
        "timestamp": time.Now().UTC().Format(time.RFC3339),
        "connections": hub.ActiveConnections(),
    })
}

6.7 /readyz 检查逻辑(软失败 — ISSUE-058/006

// internal/health/readyz.go
// Redis/Kafka 不可达时不返 503而是 200 + degraded:true
// 避免 K8s 在依赖短暂抖动时驱逐 PodARB-015 §17.4)。
// 仅 Hub.CloseAll 触发的 closing 状态返 503优雅关闭期间
func (rz *Readyzer) Handler(c *gin.Context) {
    if rz.hub.IsClosing() {
        c.JSON(503, readyzResponse{Status: "shutting_down", Degraded: true, ...})
        return
    }
    deps := map[string]*dependencyStatus{}
    degraded := false
    // Redis 软探活
    if err := rz.redis.Ping(ctx); err != nil {
        deps["redis"] = &dependencyStatus{Ok: false, Error: err.Error()}
        degraded = true
    }
    // Kafka 软探活
    if err := rz.kafka.HealthCheck(ctx); err != nil {
        deps["kafka"] = &dependencyStatus{Ok: false, Error: err.Error()}
        degraded = true
    }
    c.JSON(200, readyzResponse{Status: statusText(degraded), Degraded: degraded, ...})
}

6.8 优雅关闭顺序

// 1. 收到 SIGTERM
// 2. 标记 Hub 为 closing拒绝新连接
// 3. 向所有在线连接发送 close 帧code=1001 going away
// 4. 等待所有连接关闭10s 超时)
// 5. srv.Shutdown(ctx) 停止 HTTP 服务
// 6. 关闭 Kafka consumer提交 offset
// 7. 关闭 Redis subscriber
// 8. tracerShutdown() flush span
// 9. os.Exit(0)

7. WebSocket 连接生命周期(核心设计)

stateDiagram-v2
    [*] --> Connecting: 客户端 GET /ws?token=
    Connecting --> Authenticating: Upgrade 成功
    Authenticating --> Rejected: JWT 无效
    Rejected --> [*]: 返回 401
    Authenticating --> Connected: JWT 有效
    Connected --> Heartbeating: 注册到 Hub
    Heartbeating --> Heartbeating: 收到 Ping → 回 Pong
    Heartbeating --> Reconnecting: 客户端断线
    Heartbeating --> Closing: 服务端 SIGTERM
    Heartbeating --> IdleTimeout: 60s 无心跳
    IdleTimeout --> Closing: 服务端主动关闭
    Reconnecting --> Connected: 客户端重连(带 session_id + last_seq
    Closing --> [*]: 发送 close 帧 + 注销 Hub

7.1 心跳协议RFC 6455 控制帧)

规则

  • 客户端每 30s 发送 WebSocket Ping 控制帧
  • 服务端自动回 Ponggorilla/websocket 默认行为)
  • 服务端 conn.SetReadDeadline(60s)60s 未收到任何消息(含 Ping则关闭连接
  • 服务端每 30s 检查 lastPing,超过 60s 主动关闭

不使用文本消息 ping/pong(当前骨架的实现,需重构)。

7.2 单用户连接数限制

const MaxConnectionsPerUser = 5

func (h *Hub) Register(userID string, conn *websocket.Conn) (*Connection, error) {
    h.mu.Lock()
    defer h.mu.Unlock()
    if h.counters[userID] >= MaxConnectionsPerUser {
        return nil, ErrTooManyConnections
    }
    // ... 注册逻辑
}

超限策略:拒绝新连接,返回 close 帧code=1008 policy violation客户端应处理为"已达到最大连接数"。

7.3 Reconnect 协议P6 硬化项,本期仅预留)

协议

  • 客户端首次连接时,服务端返回 {type:"hello", session_id:"<uuid>", seq:0}
  • 后续每条推送消息带 seq 递增
  • 客户端断线重连时,/ws?token=&session_id=<id>&last_seq=<n>
  • 服务端从 msg 服务拉取 last_seq+1 到当前的所有未送达消息补推

本期不实现,仅协议预留。

7.4 Origin 校验

var upgrader = websocket.Upgrader{
    CheckOrigin: func(r *http.Request) bool {
        origin := r.Header.Get("Origin")
        if origin == "" {
            return false // 无 Origin 拒绝
        }
        for _, allowed := range allowedOrigins {
            if origin == allowed {
                return true
            }
        }
        return false
    },
}

白名单来源:环境变量 WS_ALLOWED_ORIGINS(逗号分隔)。

8. 横向扩展方案Redis Pub/Sub

8.1 多实例部署架构

graph TB
    subgraph LB["负载均衡器"]
        L[Sticky Session 或 IP Hash<br/>保证同一用户路由到同一实例]
    end

    subgraph Instances["push-gateway 实例集群"]
        I1[Instance 1<br/>持有用户 A,B,C]
        I2[Instance 2<br/>持有用户 D,E,F]
        I3[Instance 3<br/>持有用户 G,H,I]
    end

    subgraph Redis["Redis Pub/Sub"]
        R[(Redis<br/>edu.push.channel.user.*<br/>edu.push.channel.broadcast)]
    end

    subgraph Msg["msg 服务"]
        M[msg]
    end

    M -->|HTTP /internal/push user=A| I1
    M -->|HTTP /internal/push user=D| I2
    M -->|HTTP /internal/push user=Z 不知在哪| R
    R --> I1
    R --> I2
    R --> I3
    M -->|Kafka broadcast| K[Kafka]
    K --> I1
    K --> I2
    K --> I3

8.2 跨实例推送流程

  1. msg 服务调本实例 /internal/push {user_id: A}
  2. 本实例 Hub 查 clients[A]
    • 命中 → 直接投递
    • 未命中 → 查 Redis edu:push:online:A
      • 不存在 → 用户离线,返 delivered:false, online:false
      • 存在 → PUBLISH 到 edu.push.channel.user.A,所有实例订阅此 channel
  3. 持有用户 A 的实例收到 Pub/Sub 消息 → 投递到本地连接 → 通过另一 ack channel 返结果
  4. 本实例收到 ack → 返 msg 服务 delivered:true

8.3 广播优化

  • 不遍历所有用户:直接 PUBLISH 到 edu.push.channel.broadcast
  • 所有实例订阅此 channel收到后遍历本地连接投递
  • 单实例广播成本 O(local_connections),集群总成本 O(sum_connections)

8.4 在线状态一致性

  • Redis SET edu:push:online:<userID> 存储用户所在实例集合
  • 实例启动时生成唯一 instanceIDuuid
  • 连接建立时 SADD 自己到 SETEXPIRE 60s
  • 心跳续期时 EXPIRE 60s
  • 连接断开时 SREM 自己;若 SET 空DEL key
  • 实例崩溃时 SET 自然过期60s

9. 与其他模块的交互点(契约清单)

方向 对方服务 协议 接口/事件 用途
被调用 msg HTTP POST /internal/push 定向推送
被调用 msg HTTP POST /internal/broadcast 广播
被调用 msg HTTP GET /internal/online/<userID> 查在线状态
消费 msg Kafka edu.notification.requested / NotificationRequested 异步广播通知ISSUE-053 重命名)
调用 iam HTTP GET /.well-known/jwks.json RS256 公钥P2
调用 Redis Redis Pub/Sub + SET 跨实例广播 + 在线状态
被调用 微前端 WebSocket /ws 长连接
被调用 K8s/Docker HTTP /healthz /readyz 探针
被调用 Prometheus HTTP GET /metrics 指标采集

10. 风险与假设

  • 假设Redis 在 P5 部署时可用P1 已就绪)
  • 假设msg 服务 P5 实现 NotificationRequested 事件发布
  • 假设iam P2 暴露 JWKS 端点
  • 风险:单实例内存连接池上限受 Go runtime 内存限制10w 连接约 1-2GB
  • 风险Redis Pub/Sub 是 fire-and-forget实例崩溃时未投递的消息丢失msg 服务落库兜底)
  • 风险:跨实例 ack 链路复杂,可能引入延迟(建议 P6 用 Redis Stream 替代 Pub/Sub 实现持久化)
  • 未决reconnect 协议是否本期实现建议本期仅协议预留P6 实现
  • 未决:是否在 push-gateway 做 SSE 支持建议不支持WebSocket 已够用,避免协议膨胀

11. 容量与性能目标

指标 目标 实现方式
单实例最大连接数 10w+ goroutine per connection + 内存优化
推送延迟(本实例) < 50ms 异步 send chan
推送延迟(跨实例) < 200ms Redis Pub/Sub
心跳间隔 30s 客户端 Ping
空闲超时 60s SetReadDeadline
单用户最大连接数 5 Hub.counters 限制
消息体上限 64KB WebSocket 消息大小限制

12. 实施优先级

优先级 任务 阶段
P0 Origin 校验CheckOrigin 白名单) P5 立即
P0 /internal/* 鉴权X-Internal-Token P5 立即
P0 心跳改用 WebSocket 控制帧 + SetReadDeadline P5 立即
P0 单用户连接数限制 P5 立即
P0 Send 通道满时记录指标+日志 P5 立即
P0 Dockerfile 重构(多阶段 + 非 root + healthcheck P5 立即
P0 引入 log/slog 替换标准 log P5 立即
P1 Redis Pub/Sub 跨实例广播 P5
P1 /metrics 端点 + prom-client P5
P1 /readyz 真实健康检查Redis PING P5
P1 优雅关闭所有 WebSocket 连接 P5
P2 Kafka 消费 edu.notification.events P5
P2 JWT RS256 升级(与 api-gateway 共用 JWKS P5依赖 iam
P2 在线状态 Redis SET 维护 P5
P3 Reconnect 协议session_id + last_seq P6
P3 Redis Stream 替代 Pub/Sub持久化 P6
P3 测试覆盖率 ≥ 80% P6

13. 与 api-gateway 共用的横切代码

为避免重复实现,建议以下模块提取到 packages/shared-go/(由 coord 维护ai01 提交需求):

模块 用途 当前位置
observability/tracer.go OTel tracer 初始化 两服务各有一份,完全相同
observability/logger.go slog 结构化日志 待新增(两服务共用)
auth/jwks.go JWKS 公钥拉取与缓存 待新增两服务共用P2
config/env.go 环境变量加载工具 两服务各有 getEnv可统一

提交方式:通过 # proto-change 渠道向 coord 声明需求coord 在 shared-go 中建立ai01 在本服务中改为 import。


14. 架构决策记录ADR

本节沉淀所有 ISSUE 仲裁结果与设计决策,作为未来变更的决策源。每条 ADR 引用对应的 ISSUE / president ruling / ARB 编号。

ADR-001 仅 WebSocket不支持 SSEISSUE-001

  • 决策push-gateway 仅实现 WebSocket 长连接通道,不提供 SSEServer-Sent Events端点。
  • 理由WebSocket 双向通信能力更强(服务端可主动推送 + 客户端可发 ack/heartbeat单协议栈降低运维成本SSE 单向推送且浏览器连接数受限HTTP/1.1 下 6 个/域名)。微前端已统一使用 WebSocket 客户端。
  • 影响:不支持仅支持 SSE 的旧客户端;客户端必须实现 WebSocket Ping 心跳。
  • 状态acceptedpresident-final-rulings §1.1

ADR-002 X-Internal-Token 命名ISSUE-002 / ARB-015 §17.3

  • 决策/internal/* API 鉴权头从 X-Internal-Key 改名为 X-Internal-Token,环境变量从 INTERNAL_API_KEY 改为 INTERNAL_API_TOKEN
  • 理由与项目其他服务api-gateway、msg的内部鉴权头命名保持一致"Token" 更准确表达 bearer 语义。
  • 影响msg 服务调用方需同步修改请求头DevMode 仍跳过校验。
  • 状态acceptedpresident §7.2

ADR-003 单实例容量 10w+ 连接ISSUE-003

  • 决策:单实例目标连接数从 50k 提升到 10w+,通过 goroutine-per-connection + send chan(cap 64) + 内存优化实现。
  • 理由10w 连接估算内存 ~1.5GB(每连接 ~15KBgoroutine 栈 8KB + chan 64×1.5KB + websocket buffer 4KBGo runtime 可承载;实际峰值按 80% 水位8w规划副本数。
  • 影响:单 Pod resources.limits.memory 建议 2GB超限时水平扩容而非单实例加内存。
  • 状态acceptedpresident §3.1

ADR-004 无 DB审计由 msg 服务负责ISSUE-005

  • 决策push-gateway 不持久化任何业务数据(无 DB消息审计、离线落库、重试队列均由 msg 服务负责。
  • 理由push-gateway 职责单一(连接管理 + 消息转发),引入 DB 会增加状态一致性复杂度msg 服务已有 Outbox + DB天然承担审计职责。
  • 影响push-gateway 崩溃时在途消息丢失msg 通过 Outbox 重投兜底);/internal/push 同步响应返 delivered/online 供 msg 决策是否离线推送。
  • 状态acceptedpresident §5.1

ADR-005 Kafka topic 命名ISSUE-053

  • 决策:消费的 Kafka topic 从 edu.notification.events 改为 edu.notification.requested
  • 理由:符合 G16 命名规则 edu.<domain>.<aggregate>.<action>requested 表达"通知请求"语义,events 过于宽泛。
  • 影响msg 服务 Outbox TOPIC_MAP 需同步修改CI 检测 breaking change。
  • 状态acceptedpresident-final-rulings §1.5

ADR-006 /readyz 软失败ISSUE-058 / ISSUE-006 / ARB-015 §17.4

  • 决策/readyz 探针采用软失败语义Redis 或 Kafka 不可达时返回 HTTP 200 + degraded: true,而非 503。
  • 理由K8s readinessProbe 返 503 会立即从 Service endpoints 驱逐 Pod导致 Redis 短暂抖动时所有 push-gateway 实例被驱逐、WebSocket 连接全断。软失败让 Pod 留在 endpoints 内,仅降级(无跨实例 fanout / 无 Kafka 消费),抖动恢复后自动恢复。
  • 例外Hub 处于 closing 状态时返 503优雅关闭期间确实不应接收新连接
  • 影响msg 服务调用 /internal/push 时需容忍 delivered:false, online:falseRedis 不可达时的保守响应K8s readinessProbe 配置 failureThreshold: 5 给恢复留窗口。
  • 状态acceptedpresident-final-rulings §1.6 / ARB-015 §17.4

ADR-007 Redis online SET 启动重建ISSUE-058

  • 决策push-gateway 实例启动时主动扫描 edu:push:online:* 并 SREM 自己的 instanceID然后基于本地 Hub 内存状态重新 SADD。
  • 理由:实例崩溃重启后,旧 instanceID 在 Redis SET 中可能尚未过期60s TTL 窗口),直接 SADD 同名 instanceID 是 no-op无法区分"旧连接已失效"与"新连接已建立"。先清后建确保 SET 与内存一致。
  • 不一致窗口:崩溃到 SET 过期之间(最长 60s/internal/online 可能误报 online:truemsg 通过 delivered:false 重试或离线推送兜底。
  • 指标push_gateway_redis_set_rebuild_total 记录重建次数。
  • 状态acceptedpresident-final-rulings §1.6

ADR-008 Redis 作为必需依赖但软失败ISSUE-055 vs ISSUE-058 仲裁)

  • 决策Redis 是 push-gateway 的必需依赖(部署前置条件),但运行期 Redis 故障采用软失败ADR-006不阻断 WebSocket 本地投递。
  • 理由ISSUE-055 主张 Redis 不可达时 push-gateway 应 503 停服ISSUE-058 主张软失败。ARB-015 §17.4 裁定 ISSUE-058 优先Redis 故障时本地 WebSocket 连接仍可服务,仅跨实例 fanout 与在线状态查询降级。
  • 部署约束:启动时 Redis 不可达记为 degraded 并继续启动(不 Fatal生产环境应通过 K8s deployment initContainer 等待 Redis 就绪。
  • 状态acceptedARB-015 §17.4

ADR-009 纯 HTTP + WebSocket无 gRPCISSUE-007

  • 决策push-gateway 对外仅暴露 HTTP/internal/* API+ WebSocket/ws),不实现 gRPC 服务。
  • 理由msg 服务调用 push-gateway 用 HTTP 已足够(同步请求-响应语义);引入 gRPC 会增加 proto 契约维护成本与 buf 生成链路。gRPC 的流式推送能力由 WebSocket 承担。
  • 影响msg 服务无需生成 push-gateway 的 gRPC clientpackages/shared-proto/proto/ 无 push-gateway.proto。
  • 状态acceptedARB-015 §17.6

ADR-010 shared-go 复用ARB-015 §17.5

  • 决策tracer / logger / env / jwks 四个横切模块统一从 packages/shared-go/ 导入,不在 push-gateway 内重复实现。
  • 理由:与 api-gateway 共用同一套 OTel SDK 配置、slog 日志格式、JWKS 缓存逻辑,避免代码漂移。
  • 影响go.mod 依赖 github.com/edu-cloud/shared-goreplace 到 ../../packages/shared-gointernal/observability/tracer.go 仅包装 shared-go/tracer.Init
  • 状态acceptedARB-015 §17.5

15. 非功能性需求NFR

15.1 性能

指标 目标 测量方式 验收标准
单实例最大连接数 10w+ push_gateway_active_connections 持续 30min 稳定 ≥ 10w 不崩
本实例推送延迟 P99 < 50ms push.message span 压测 10w 连接下 P99 < 50ms
跨实例推送延迟 P99 < 200ms Redis Pub/Sub round-trip 3 实例 10w 连接下 P99 < 200ms
广播推送延迟 P99 < 100ms push_gateway_messages_pushed_total 10w 连接广播 P99 < 100ms
心跳间隔 30s 客户端配置 客户端 Ping 间隔 30s ± 5s
空闲超时 60s SetReadDeadline 60s 无帧断开
消息体上限 64KB SetReadLimit 超限返回 1009

15.2 可用性

指标 目标 实现方式
服务可用性 99.9% 多副本部署 + K8s 自愈 + /readyz 软失败
Redis 故障降级 不断连 软失败保留本地 WebSocket 服务ADR-006/008
Kafka 故障降级 不断连 软失败保留 HTTP /internal/push 通道
滚动升级零中断 Hub.CloseAll + srv.Shutdown + 10s drain
单实例崩溃恢复 < 60s K8s 重启 + Redis SET TTL 过期 + 启动重建ADR-007

15.3 安全

需求 实现
WebSocket 鉴权 JWT RS256iam JWKS5min 缓存DevMode 接受 dev-token
内部 API 鉴权 X-Internal-TokenK8s Secret 注入,与 msg 共享)
Origin 白名单 WS_ALLOWED_ORIGINS 环境变量DevMode 空白名单允许所有
非 root 运行 Dockerfile USER app (uid 1001)
最小镜像 alpine:3.20 + CGO_ENABLED=0 静态编译
无密钥泄露 INTERNAL_API_TOKEN / JWT_SECRET 通过 K8s Secret 注入,不入镜像

15.4 可观测性

支柱 实现
日志 slog JSONprod/ textdev字段 service/user_id/conn_id/trace_id
指标 9 个 Prometheus 指标 + Go runtime 默认指标,/metrics 端点
追踪 OTel SDK via shared-go/tracerotelgin 中间件自动埋点 HTTP span
健康检查 /healthzliveness+ /readyzreadiness软失败

16. 失败模式与恢复

16.1 失败模式矩阵

失败场景 检测方式 系统行为 恢复方式
Redis 不可达 /readyz 软探活 200 + degraded:true;跨实例 fanout 停止;本地 WebSocket 继续 Redis 恢复后自动重连
Kafka 不可达 /readyz 软探活 200 + degraded:trueHTTP /internal/push 通道继续 Kafka 恢复后 consumer 自动重连
iam JWKS 不可达 jwks.Fetcher 返错 新 WebSocket 连接被拒401已建连接不受影响 JWKS 缓存 5min恢复后新连接可用
单实例崩溃 K8s livenessProbe 失败 K8s 重启 Pod该实例连接断开客户端重连到其他实例 Redis SET 60s 过期 + 启动重建
客户端网络抖动 SetReadDeadline(60s) 60s 无帧断开连接;客户端重连 客户端指数退避重连
单用户连接超限 Hub.counters 检查 拒绝新连接,返 close 帧 1008 客户端关闭旧连接后重连
send channel 满 Send() 返 false 丢弃消息,messages_dropped_total{reason=channel_full} +1 消费端 drain 后恢复
Kafka 消费失败 3 次重试后 发送 DLQedu.notification.requested.dlqcommit 跳过 人工排查 DLQ
Redis Pub/Sub 消息丢失 fire-and-forget 特性 msg 服务通过 delivered:false 响应触发重试或离线推送 msg Outbox 重投
内存 OOM K8s OOMKilled Pod 重启,连接断开,客户端重连 调整 resources.limits.memory

16.2 降级矩阵

依赖故障 本地 WebSocket HTTP /internal/push Kafka 消费 跨实例 fanout /readyz 状态
Redis 故障 正常 本地投递 正常 停止 degraded
Kafka 故障 正常 正常 停止 正常 degraded
iam JWKS 故障 ⚠️ 新连接被拒 正常 正常 正常 ok缓存可用
Hub 关闭中 拒绝新连接 503 ⏸️ drain 停止 shutting_down

17. 容量估算

17.1 单实例资源估算

资源 估算公式 10w 连接目标值 备注
内存 每连接 ~15KB × 10w + Go runtime 200MB ~1.7GB goroutine 栈 + chan + buffer
goroutine 数 每连接 2reader+writer+ 10后台 ~20w Go runtime 调度开销可接受
文件描述符 每连接 1WebSocket fd+ 50其他 ~10w ulimit -n 需调到 200000
CPU 心跳处理 10w/30s = 3.3k QPS 1-2 core 心跳轻量,主要 CPU 在消息推送

17.2 K8s Pod 资源配置建议

resources:
  requests:
    cpu: 500m
    memory: 2Gi
  limits:
    cpu: 2000m
    memory: 3Gi # 留 1.3GB 余量应对突发

17.3 副本数规划

场景 在线用户数 单实例容量 副本数 总内存
开发环境 100 10w 1 3GB
测试环境 1k 10w 1 3GB
预发环境 1w 10w 2 6GB
生产(小规模) 5w 10w 2 6GB
生产(大规模) 20w 10w 3 9GB
生产(峰值) 50w 10w 6 18GB

副本数公式ceil(在线用户数 × 0.8 / 10w) + 180% 水位 + 1 冗余)

17.4 Redis 资源估算

数据结构 单条大小 10w 在线用户总量 备注
edu:push:online:<userID> ~50B ~5MB SET + TTL
edu:push:session:<userID>:<connID> ~100B ~10MB HASH每连接一条
Pub/Sub channel 消息 ~200B 瞬时 不持久化
edu:push:idempotent:<event_id> ~30B ~3MB/h 24h TTL按 10w 事件/h 估

Redis 总内存需求 < 50MB10w 在线用户),现有 edu-redis 容器 128m 限制足够。

17.5 Kafka 容量估算

指标 估算 备注
消息速率 1k 通知/s峰值 考试开始、成绩发布等批量场景
消息大小 ~500B NotificationRequested JSON
吞吐量 ~500KB/s 单 partition 足够
Partition 数 3 容错 + 并行消费
保留时间 24h 失败重试窗口

18. 变更日志

日期 版本 变更 来源
2026-07-09 0.1 初始架构设计 阶段 2 交付
2026-07-10 1.0 仲裁回写ISSUE-001/002/003/005/053/055/056/058/007新增 §14 ADR / §15 NFR / §16 失败模式 / §17 容量估算 ARB-015 + president-final-rulings