chore(push-gateway): ai02 module updates - config, hub, ws, kafka, health, docs

This commit is contained in:
SpecialX
2026-07-10 17:36:53 +08:00
parent dc0a6feec4
commit 3fac472a57
16 changed files with 2017 additions and 289 deletions

View File

@@ -1,11 +1,10 @@
# 模块架构设计文档 — push-gateway
> AIai01Go 网关层
> 阶段:阶段 2 交付物
> 日期2026-07-09
> 关联:[01 理解确认书](./01-understanding.md)、[004 架构影响地图](../../../docs/architecture/004_architecture_impact_map.md)
> 阶段:阶段 2 交付物v1.0 仲裁回写于 2026-07-10
> 日期2026-07-09v0.1/ 2026-07-10v1.0
> 关联:[01 理解确认书](./01-understanding.md)、[004 架构影响地图](../../../docs/architecture/004_architecture_impact_map.md)、[president-final-rulings](../../../docs/architecture/president-final-rulings.md)
>
> 本文档覆盖 ai-allocation §5 设计重点WebSocket 连接生命周期、与 msg 的 gRPC 推送通道协议、用户 session 映射、水平扩展方案Redis Pub/Sub 跨实例广播)。
> 本文档覆盖WebSocket 连接生命周期、与 msg 的 HTTP 推送通道协议、用户 session 映射、水平扩展方案Redis Pub/Sub 跨实例广播)。仲裁结果见 §14 ADR。
---
@@ -53,7 +52,7 @@ graph TB
end
subgraph Kafka["Kafka 消费者(可选)"]
K[Consumer Group<br/>topic: edu.notification.events]
K[Consumer Group<br/>topic: edu.notification.requested]
end
subgraph Msg["msg 服务"]
@@ -189,10 +188,10 @@ type Hub struct {
### 4.3 健康检查
| 端点 | 检查逻辑 |
| ---------- | ------------------------------------------------------------------------ |
| `/healthz` | 进程存活,返回 200 |
| `/readyz` | 检查 Redis 连接(PING+ 在线连接数 > 0 时认为就绪Redis 不可达返回 503 |
| 端点 | 检查逻辑 |
| ---------- | -------------------------------------------------------------------------------------------------------------- |
| `/healthz` | 进程存活,返回 200 |
| `/readyz` | Redis PING + Kafka 元数据探活;**软失败**ISSUE-058/006失败返 200 + `degraded: true`,仅 Hub 关闭时返 503 |
### 4.4 指标端点
@@ -204,9 +203,11 @@ type Hub struct {
### 5.1 我消费的 Kafka 事件
| Topic | Message | 触发场景 | 消费动作 |
| ------------------------- | ----------------------- | -------------------------- | --------------------------------------------------------------------- |
| `edu.notification.events` | `NotificationRequested` | msg 服务收到通知请求后发布 | 消费 → 调用 Hub.SendToUser 投递 → 若离线则 ACK 不重投msg 服务落库) |
| 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`。旧名作废。
**消费语义**
@@ -298,13 +299,14 @@ func InitLogger(level string) {
| 指标名 | 类型 | 标签 | 描述 |
| ------------------------------------------- | --------- | ---------------- | ------------------------------------------------- |
| `push_gateway_active_connections` | Gauge | — | 当前在线连接数 |
| `push_gateway_connections_per_user` | 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 |
| `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
@@ -329,19 +331,31 @@ func Healthz(c *gin.Context) {
}
```
### 6.7 /readyz 检查逻辑
### 6.7 /readyz 检查逻辑(软失败 — ISSUE-058/006
```go
func Readyz(redisClient *redis.Client) gin.HandlerFunc {
return func(c *gin.Context) {
ctx, cancel := context.WithTimeout(c.Request.Context(), 1*time.Second)
defer cancel()
if err := redisClient.Ping(ctx).Err(); err != nil {
c.JSON(503, gin.H{"status":"error","error":"redis unreachable"})
return
}
c.JSON(200, gin.H{"status":"ok"})
// 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, ...})
}
```
@@ -502,17 +516,17 @@ graph TB
## 9. 与其他模块的交互点(契约清单)
| 方向 | 对方服务 | 协议 | 接口/事件 | 用途 |
| ------ | ---------- | --------- | --------------------------------------------------- | --------------------- |
| 被调用 | msg | HTTP | `POST /internal/push` | 定向推送 |
| 被调用 | msg | HTTP | `POST /internal/broadcast` | 广播 |
| 被调用 | msg | HTTP | `GET /internal/online/<userID>` | 查在线状态 |
| 消费 | msg | Kafka | `edu.notification.events` / `NotificationRequested` | 异步广播通知 |
| 调用 | 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` | 指标采集 |
| 方向 | 对方服务 | 协议 | 接口/事件 | 用途 |
| ------ | ---------- | --------- | ------------------------------------------------------ | -------------------------------- |
| 被调用 | 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. 风险与假设
@@ -571,3 +585,223 @@ graph TB
| `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:false`Redis 不可达时的保守响应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 client`packages/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-go`replace 到 `../../packages/shared-go``internal/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-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 JSONprod/ textdev字段 `service/user_id/conn_id/trace_id` |
| 指标 | 9 个 Prometheus 指标 + Go runtime 默认指标,`/metrics` 端点 |
| 追踪 | OTel SDK via shared-go/tracerotelgin 中间件自动埋点 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 数 | 每连接 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 资源配置建议
```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:<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 |