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

808 lines
40 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 模块架构设计文档 — push-gateway
> 阶段:阶段 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)
>
> 本文档覆盖WebSocket 连接生命周期、与 msg 的 HTTP 推送通道协议、用户 session 映射、水平扩展方案Redis Pub/Sub 跨实例广播)。仲裁结果见 §14 ADR。
---
## 1. 模块内部分层图
```mermaid
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. 领域模型
无业务聚合。仅连接管理值对象:
```go
// 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 子协议(应用层消息格式)**
```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/<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 Group`push-gateway`
- 至少一次at-least-once消费失败重试 3 次后入死信队列
- 幂等性:基于 `event_id` 去重Redis SETNXTTL 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: 通道 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
```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 在依赖短暂抖动时驱逐 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 优雅关闭顺序
```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 控制帧
- 服务端自动回 Ponggorilla/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:"<uuid>", seq:0}`
- 后续每条推送消息带 `seq` 递增
- 客户端断线重连时,`/ws?token=&session_id=<id>&last_seq=<n>`
- 服务端从 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<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>` 存储用户所在实例集合
- 实例启动时生成唯一 `instanceID`uuid
- 连接建立时 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: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 |