# 模块架构设计文档 — push-gateway > 阶段:阶段 2 交付物(v1.0 仲裁回写于 2026-07-10) > 日期:2026-07-09(v0.1)/ 2026-07-10(v1.0) > 关联:[01 理解确认书](./01-understanding.md)、[004 架构影响地图](../../../docs/architecture/004_architecture_impact_map.md)、[president-final-rulings](../../../docs/architecture/president-final-rulings.md) > > 本文档覆盖:WebSocket 连接生命周期、与 msg 的 HTTP 推送通道协议、用户 session 映射、水平扩展方案(Redis Pub/Sub 跨实例广播)。仲裁结果见 §14 ADR。 --- ## 1. 模块内部分层图 ```mermaid graph TB subgraph Client["客户端"] B[浏览器/移动端] end subgraph External["外部入口层"] WS[WebSocket Upgrader
/ws?token=JWT] end subgraph MW["中间件链"] M1[Recovery] M2[OTelgin] M3[RequestID] M4[OriginGuard
CheckOrigin 白名单] M5[InternalAuth
/internal/* 校验] end subgraph Hub["Hub 连接池(核心)"] H[Hub
map userID -> map connID -> Connection] Conn[Connection
userID + ws + send chan + heartbeat] end subgraph Writer["写协程池"] W[每 Connection 一个写协程
消费 send chan 写 ws] end subgraph Reader["读协程"] R[每 Connection 一个读协程
处理 Ping 控制帧 + 消息] end subgraph PushAPI["内部推送 API"] P1[POST /internal/push] P2[POST /internal/broadcast] end subgraph Redis["Redis Pub/Sub(横向扩展)"] Sub[Subscriber
订阅 edu.push.user.* + edu.push.broadcast] Pub[Publisher
本实例无此用户时 PUBLISH] end subgraph Kafka["Kafka 消费者(可选)"] K[Consumer Group
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.` channel,所有实例订阅此 channel,持有该用户的实例收到后投递 - **Kafka 消费可选**:广播类通知走 Kafka 解耦;定向推送走 HTTP `/internal/push`(同步返结果) ## 2. 领域模型 无业务聚合。仅连接管理值对象: ```go // Connection 包装单个 WebSocket 连接 type Connection struct { UserID string ConnID string // 连接唯一 ID(uuid) 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:` | SET(成员是 `instanceID`) | 60s(心跳续期) | 在线用户所在实例集合(用于判断用户是否在线) | | `edu:push:session::` | HASH | 60s | 单连接元数据(instanceID, device, lastPing) | | `edu:push:channel:user:` | Pub/Sub channel | — | 跨实例定向推送 channel | | `edu:push:channel:broadcast` | Pub/Sub channel | — | 跨实例广播 channel | ### 3.2 在线状态判定 ``` 用户在线 := Redis SET edu:push:online: 非空 用户离线 := SET 为空或不存在 ``` **续期机制**:每 30s 客户端心跳时,本实例 SADD 自己 + EXPIRE 60s。 ## 4. API 设计 ### 4.1 WebSocket 端点 | 方法 | 路径 | 鉴权 | 说明 | | ---- | ----- | ------------------------------------------------- | -------------- | | GET | `/ws` | JWT(query `?token=` 或 `Authorization: Bearer`) | WebSocket 升级 | **WebSocket 子协议(应用层消息格式)**: ```json // 服务端 → 客户端:推送消息 { "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/` | `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...`),已改为 `edu.notification.requested`。旧名作废。 **消费语义**: - Consumer Group:`push-gateway` - 至少一次(at-least-once),消费失败重试 3 次后入死信队列 - 幂等性:基于 `event_id` 去重(Redis SETNX,TTL 24h) ### 5.2 我发布的 Kafka 事件 **无**。推送结果通过 HTTP `/internal/push` 同步响应返 msg 服务。 ### 5.3 与 msg 服务的双通道协议 ```mermaid 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: 通道 1:HTTP 同步推送(定向) 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. 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: 通道 2:Kafka 异步消费(广播) 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 RS256(P2 起,与 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 ```go // 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 升级 span(`ws.upgrade`) - 消息推送 span(`push.message`,标签 user_id/event) - Redis Pub/Sub span(`redis.publish` / `redis.subscribe`) ### 6.6 /healthz 检查逻辑 ```go 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) ```go // internal/health/readyz.go // Redis/Kafka 不可达时不返 503,而是 200 + degraded:true, // 避免 K8s 在依赖短暂抖动时驱逐 Pod(ARB-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 优雅关闭顺序 ```go // 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 连接生命周期(核心设计) ```mermaid 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 控制帧 - 服务端自动回 Pong(gorilla/websocket 默认行为) - 服务端 `conn.SetReadDeadline(60s)`:60s 未收到任何消息(含 Ping)则关闭连接 - 服务端每 30s 检查 `lastPing`,超过 60s 主动关闭 **不使用文本消息 `ping/pong`**(当前骨架的实现,需重构)。 ### 7.2 单用户连接数限制 ```go 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:"", seq:0}` - 后续每条推送消息带 `seq` 递增 - 客户端断线重连时,`/ws?token=&session_id=&last_seq=` - 服务端从 msg 服务拉取 `last_seq+1` 到当前的所有未送达消息补推 **本期不实现**,仅协议预留。 ### 7.4 Origin 校验 ```go 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 多实例部署架构 ```mermaid graph TB subgraph LB["负载均衡器"] L[Sticky Session 或 IP Hash
保证同一用户路由到同一实例] end subgraph Instances["push-gateway 实例集群"] I1[Instance 1
持有用户 A,B,C] I2[Instance 2
持有用户 D,E,F] I3[Instance 3
持有用户 G,H,I] end subgraph Redis["Redis Pub/Sub"] R[(Redis
edu.push.channel.user.*
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:` 存储用户所在实例集合 - 实例启动时生成唯一 `instanceID`(uuid) - 连接建立时 SADD 自己到 SET,EXPIRE 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/` | 查在线状态 | | 消费 | 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,不支持 SSE(ISSUE-001) - **决策**:push-gateway 仅实现 WebSocket 长连接通道,不提供 SSE(Server-Sent Events)端点。 - **理由**:WebSocket 双向通信能力更强(服务端可主动推送 + 客户端可发 ack/heartbeat),单协议栈降低运维成本;SSE 单向推送且浏览器连接数受限(HTTP/1.1 下 6 个/域名)。微前端已统一使用 WebSocket 客户端。 - **影响**:不支持仅支持 SSE 的旧客户端;客户端必须实现 WebSocket Ping 心跳。 - **状态**:accepted(president-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 仍跳过校验。 - **状态**:accepted(president §7.2) ### ADR-003 单实例容量 10w+ 连接(ISSUE-003) - **决策**:单实例目标连接数从 50k 提升到 10w+,通过 goroutine-per-connection + send chan(cap 64) + 内存优化实现。 - **理由**:10w 连接估算内存 ~1.5GB(每连接 ~15KB:goroutine 栈 8KB + chan 64×1.5KB + websocket buffer 4KB),Go runtime 可承载;实际峰值按 80% 水位(8w)规划副本数。 - **影响**:单 Pod resources.limits.memory 建议 2GB;超限时水平扩容而非单实例加内存。 - **状态**:accepted(president §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 决策是否离线推送。 - **状态**:accepted(president §5.1) ### ADR-005 Kafka topic 命名(ISSUE-053) - **决策**:消费的 Kafka topic 从 `edu.notification.events` 改为 `edu.notification.requested`。 - **理由**:符合 G16 命名规则 `edu...`,`requested` 表达"通知请求"语义,`events` 过于宽泛。 - **影响**:msg 服务 Outbox TOPIC_MAP 需同步修改;CI 检测 breaking change。 - **状态**:accepted(president-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:false`(Redis 不可达时的保守响应);K8s readinessProbe 配置 `failureThreshold: 5` 给恢复留窗口。 - **状态**:accepted(president-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:true;msg 通过 `delivered:false` 重试或离线推送兜底。 - **指标**:`push_gateway_redis_set_rebuild_total` 记录重建次数。 - **状态**:accepted(president-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 就绪。 - **状态**:accepted(ARB-015 §17.4) ### ADR-009 纯 HTTP + WebSocket,无 gRPC(ISSUE-007) - **决策**:push-gateway 对外仅暴露 HTTP(`/internal/*` API)+ WebSocket(`/ws`),不实现 gRPC 服务。 - **理由**:msg 服务调用 push-gateway 用 HTTP 已足够(同步请求-响应语义);引入 gRPC 会增加 proto 契约维护成本与 buf 生成链路。gRPC 的流式推送能力由 WebSocket 承担。 - **影响**:msg 服务无需生成 push-gateway 的 gRPC client;`packages/shared-proto/proto/` 无 push-gateway.proto。 - **状态**:accepted(ARB-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-go`(replace 到 `../../packages/shared-go`);`internal/observability/tracer.go` 仅包装 `shared-go/tracer.Init`。 - **状态**:accepted(ARB-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 RS256(iam JWKS,5min 缓存);DevMode 接受 `dev-token` | | 内部 API 鉴权 | `X-Internal-Token` 头(K8s 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 JSON(prod)/ text(dev),字段 `service/user_id/conn_id/trace_id` | | 指标 | 9 个 Prometheus 指标 + Go runtime 默认指标,`/metrics` 端点 | | 追踪 | OTel SDK via shared-go/tracer,otelgin 中间件自动埋点 HTTP span | | 健康检查 | `/healthz`(liveness)+ `/readyz`(readiness,软失败) | --- ## 16. 失败模式与恢复 ### 16.1 失败模式矩阵 | 失败场景 | 检测方式 | 系统行为 | 恢复方式 | | ---------------------- | ---------------------- | -------------------------------------------------------------- | -------------------------------- | | Redis 不可达 | `/readyz` 软探活 | 200 + `degraded:true`;跨实例 fanout 停止;本地 WebSocket 继续 | Redis 恢复后自动重连 | | Kafka 不可达 | `/readyz` 软探活 | 200 + `degraded:true`;HTTP `/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 次重试后 | 发送 DLQ(`edu.notification.requested.dlq`),commit 跳过 | 人工排查 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 数 | 每连接 2(reader+writer)+ 10(后台) | ~20w | Go runtime 调度开销可接受 | | 文件描述符 | 每连接 1(WebSocket fd)+ 50(其他) | ~10w | ulimit -n 需调到 200000 | | CPU | 心跳处理 10w/30s = 3.3k QPS | 1-2 core | 心跳轻量,主要 CPU 在消息推送 | ### 17.2 K8s Pod 资源配置建议 ```yaml 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) + 1`(80% 水位 + 1 冗余) ### 17.4 Redis 资源估算 | 数据结构 | 单条大小 | 10w 在线用户总量 | 备注 | | ------------------------------------ | -------- | ---------------- | ------------------------- | | `edu:push:online:` | ~50B | ~5MB | SET + TTL | | `edu:push:session::` | ~100B | ~10MB | HASH,每连接一条 | | Pub/Sub channel 消息 | ~200B | 瞬时 | 不持久化 | | `edu:push:idempotent:` | ~30B | ~3MB/h | 24h TTL,按 10w 事件/h 估 | > Redis 总内存需求 < 50MB(10w 在线用户),现有 `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 |