339 lines
17 KiB
Markdown
339 lines
17 KiB
Markdown
# ADR-005:Redis 活动池采用单实例原子深模块
|
||
|
||
## 状态
|
||
|
||
接受并已实现,2026-07-29。
|
||
|
||
## 背景
|
||
|
||
Proxy Pool 的 Gateway 峰值目标是每秒 100,000 个请求。Gateway 请求热路径必须
|
||
只读取 Worker 本地不可变 Snapshot 和本地容量计数,不得同步查询 Redis、
|
||
PostgreSQL 或 Provider。
|
||
|
||
Redis 只承载控制面中的可重建短效状态:Proxy 活动池、健康状态、Worker
|
||
所有权、独占提取、短期幂等结果和库存计数。供应商 Proxy 的有效期可能只有
|
||
30 秒,因此数据结构必须支持高频刷新、有界清理和硬过期,不能把逐个 Proxy
|
||
或逐次提取记录写入 PostgreSQL。
|
||
|
||
`activitypool.MemoryPool` 定义 Provider Upsert、Distribution Extract 和 Worker
|
||
Ownership 的参考语义,生产 Redis Adapter 已按同一套公用契约实现:
|
||
|
||
- 所有远程存储端口接收 `context.Context` 并返回存储错误。
|
||
- Provider Upsert、健康更新、提取、所有权和维护能力通过窄接口复用。
|
||
- 原子 Lua 在提交前完成记录解码和候选校验,提取结果不依赖提交后的凭据解析。
|
||
- 全局 ownership epoch 使用 Redis `INCR`,库存读取与过期清理均采用有界扫描。
|
||
- 真实 Redis 8.2 fixture 覆盖并发提取、所有权竞争、幂等硬过期和键 TTL。
|
||
|
||
## 决策
|
||
|
||
### 部署边界
|
||
|
||
首版支持 Redis 单实例或 Sentinel,不实现 Redis Cluster 多分片。所有活动池键
|
||
仍使用固定 `{activity}` hash tag,使未来迁移到 Cluster 单槽时不需要改变业务
|
||
键名和原子边界。
|
||
|
||
Redis 不进入 Gateway 请求热路径:
|
||
|
||
```mermaid
|
||
flowchart LR
|
||
Provider[Provider Reconciler] -->|UpsertFetched| Adapter[Redis Activity Adapter]
|
||
Checker[Checker] -->|ApplyHealth| Adapter
|
||
Distribution[Distribution Service] -->|Extract| Adapter
|
||
Ownership[Ownership Manager] -->|Assign / Drain / Expire| Adapter
|
||
Adapter --> Redis[(Redis Activity Pool)]
|
||
Adapter --> Snapshot[Snapshot Publisher]
|
||
Snapshot --> Worker[Gateway Worker]
|
||
Client[Gateway Client] --> Worker
|
||
Worker -->|本地快照与本地计数| Upstream[Upstream Proxy]
|
||
```
|
||
|
||
### 模块边界
|
||
|
||
生产实现是一个深模块,对外只暴露一个构造器和窄领域端口:
|
||
|
||
```go
|
||
type Adapter struct {
|
||
// Redis client、键构造、脚本、编解码和指标均为私有实现。
|
||
}
|
||
|
||
func New(client RedisClient, options Options) (*Adapter, error)
|
||
|
||
var _ activitypool.Upserter = (*Adapter)(nil)
|
||
var _ activitypool.HealthStore = (*Adapter)(nil)
|
||
var _ activitypool.InventoryReader = (*Adapter)(nil)
|
||
var _ extraction.Store = (*Adapter)(nil)
|
||
var _ ownership.Repository = (*Adapter)(nil)
|
||
var _ workerruntime.SessionWriter = (*Adapter)(nil)
|
||
var _ workerruntime.ReportWriter = (*Adapter)(nil)
|
||
var _ workerruntime.RuntimeReader = (*Adapter)(nil)
|
||
var _ workerruntime.ControlStore = (*Adapter)(nil)
|
||
var _ pool.InventoryReader = (*Adapter)(nil)
|
||
```
|
||
|
||
Provider、Checker、Distribution、Ownership 和 Worker Runtime 只依赖各自需要
|
||
的端口,不直接依赖 Redis 客户端、键名、Lua 返回格式或清理策略。
|
||
|
||
`ownership.Repository` 改为适合远程存储的上下文感知接口:
|
||
|
||
```go
|
||
Assign(context.Context, time.Time, string, string, time.Duration) (Assignment, error)
|
||
Renew(context.Context, time.Time, string, string, uint64, time.Duration) (Assignment, error)
|
||
BeginDrain(context.Context, string, string, uint64) (Assignment, error)
|
||
AcknowledgeDrain(context.Context, string, string, uint64, int64, int64) error
|
||
Get(context.Context, string) (Assignment, bool, error)
|
||
Expire(context.Context, time.Time, int) ([]Assignment, error)
|
||
```
|
||
|
||
不保留旧签名。内存参考实现、Ownership Manager 和测试调用方同步迁移。
|
||
|
||
### 键空间
|
||
|
||
```text
|
||
pp:{activity}:records HASH proxyID -> 短期 Proxy 记录
|
||
pp:{activity}:unique HASH uniqueKey digest -> proxyID
|
||
pp:{activity}:idkeys HASH proxyID -> uniqueKey digest
|
||
pp:{activity}:expiry ZSET proxyID -> hard expiry milliseconds
|
||
pp:{activity}:available ZSET proxyID -> usableUntil milliseconds
|
||
pp:{activity}:protocol:<value> ZSET 协议候选索引
|
||
pp:{activity}:region:<value> ZSET 地区候选索引
|
||
pp:{activity}:carrier:<value> ZSET 运营商候选索引
|
||
pp:{activity}:upstream:<value> ZSET 供应商候选索引
|
||
pp:{activity}:owners HASH proxyID -> ownership assignment
|
||
pp:{activity}:owner-expiry ZSET proxyID -> ownership expiry milliseconds
|
||
pp:{activity}:drain-tickets HASH proxyID -> pending drain ticket
|
||
pp:{activity}:worker-draining:<digest> ZSET 单 Worker 待绑定 Drain Ticket
|
||
pp:{activity}:epoch STRING ownership 全局递增代次
|
||
pp:{activity}:inventory HASH upstreamID -> 当前未提取库存
|
||
pp:{activity}:worker-sessions HASH workerID -> 当前 Worker session
|
||
pp:{activity}:worker-session-expiry ZSET workerID -> session expiry milliseconds
|
||
pp:{activity}:worker-snapshots HASH workerID -> 最近签发且待确认的 Snapshot 引用
|
||
pp:{activity}:worker-snapshot-expiry ZSET workerID -> Snapshot 引用 expiry milliseconds
|
||
pp:{activity}:worker-runtime HASH workerID -> 完整稀疏运行态报告
|
||
pp:{activity}:worker-runtime-expiry ZSET workerID -> report expiry milliseconds
|
||
pp:{activity}:owned:<digest> ZSET 单 Upstream 已分配 AVAILABLE Proxy
|
||
pp:{activity}:worker-owned:<digest> ZSET 单 Worker 可下发的已分配 Proxy
|
||
pp:{activity}:idem:<digest> STRING 带 TTL 的提取幂等结果
|
||
pp:{activity}:op:<digest> STRING 带 TTL 的内部操作结果
|
||
```
|
||
|
||
Proxy 记录包含地址、状态、健康信息、硬过期时间、`usableUntil`、供应商、标签、
|
||
所有权引用及 Distribution 返回所需的短期凭据。Redis 键、日志、指标和错误不得
|
||
包含密码或原始 `SecretRef`。
|
||
|
||
`worker-owned` 的 score 是 ownership 租约到期时间,仅包含未进入 Drain 的 Proxy;
|
||
分配、续租、Drain、所有权过期与 Proxy 硬过期均在相同 Redis 原子脚本内维护该索引。
|
||
它用于 Controller 构建 Worker Snapshot,不被 Gateway 请求热路径读取。
|
||
|
||
### Provider Upsert
|
||
|
||
Go 层先完成上下文检查、批次校验、唯一键摘要、TTL/安全余量计算、凭据解析和
|
||
批内 ID 冲突检查。无效候选计入 `Dropped`,批级非法输入在写 Redis 前失败。
|
||
|
||
Lua 原子执行以下操作:
|
||
|
||
1. 有界清理已过期 incumbent。
|
||
2. 校验 `proxyID` 与唯一键映射。
|
||
3. 保持当前生命周期的 incumbent upstream;其他供应商的重复项不得覆盖。
|
||
4. EXTRACTED 条目不得通过刷新重新进入活动池。
|
||
5. 原子维护记录、唯一键、过期索引、过滤索引和 upstream 库存。
|
||
6. 使用 `FetchedBatch.MaxSize` 对当前未提取库存执行最终硬限制。
|
||
|
||
单次脚本批量有固定上限。超大 Provider 响应在 Go 层分块,Redis 库存计数始终
|
||
作为 `pool.maxSize` 的最终保护;本地 FetchBudget 只负责调用前的成本控制。
|
||
分块写入使用内部 `operationID`,连接中断后的底层重试不会改变结果计数。
|
||
|
||
### 健康状态
|
||
|
||
`HealthStore.ApplyHealth` 原子更新 Proxy 状态、检查时间、成功时间、延迟和失败
|
||
信息。只有满足下列条件的 Proxy 才进入 AVAILABLE 索引:
|
||
|
||
- 状态是 `AVAILABLE`。
|
||
- 没有 Worker 所有权。
|
||
- 当前时间早于 `usableUntil`。
|
||
|
||
`SUSPECT`、`UNHEALTHY`、`EXTRACTED`、`EXPIRED` 和 `REMOVED` 必须退出所有
|
||
AVAILABLE 索引。Checker 不直接拼接 Redis 命令。
|
||
|
||
### 独占提取
|
||
|
||
一个 Lua 操作完成:
|
||
|
||
1. 检查 Client ID、幂等键和请求摘要。
|
||
2. 选择候选数量最小的可用过滤索引作为驱动索引。
|
||
3. 有界复核状态、硬 TTL、`usableUntil`、健康新鲜度、所有权和全部过滤条件。
|
||
4. 为 Gateway 保留 `reserveForGateway` 个符合条件的候选。
|
||
5. 按 `partial` 或 `allOrNothing` 判断结果。
|
||
6. 将选中条目从 `AVAILABLE` 原子迁移到 `EXTRACTED`。
|
||
7. 原子减少 upstream 库存并写入短期幂等响应。
|
||
|
||
脚本扫描达到内部上限但仍不能确认结果时返回临时不可用,不得把未完成扫描
|
||
错误报告为库存不足。`allOrNothing` 在库存不足、扫描未完成或脚本异常时均为
|
||
零状态变更。
|
||
|
||
幂等结果过期时间为配置 `idempotencyTTL` 和本次结果最早 Proxy 硬过期时间中的
|
||
较早者。没有客户端幂等键时,Request ID 仍作为单次底层重试的内部操作 ID,
|
||
但不承诺不同 HTTP 请求之间的业务幂等。
|
||
|
||
### Worker 所有权
|
||
|
||
Assign、Renew、BeginDrain 和 AcknowledgeDrain 分别使用有界小脚本,与 Extract
|
||
共享 Proxy 记录和 AVAILABLE 索引。
|
||
|
||
- Assign 只接受 AVAILABLE、无 owner 且未到 `usableUntil` 的 Proxy。
|
||
- Renew 必须匹配 worker、epoch,并把 lease 截断到 `usableUntil`。
|
||
- BeginDrain 对同一 assignment 幂等。
|
||
- 首次 BeginDrain 原子写入按 Worker 索引的待绑定 Drain Ticket,并推进全局 ownership
|
||
epoch;`worker-owned` 已移除该 Proxy,下一份完整快照可据此撤销新预留。
|
||
- AcknowledgeDrain 仅在 Active 和 Reserved 都为零时释放所有权。
|
||
- Assign 与 Extract 并发竞争同一 Proxy 时,只允许一个操作成功。
|
||
- Expire 使用 `limit` 分批回收过期 assignment,禁止无界返回。
|
||
|
||
### Worker 运行态与容量汇总
|
||
|
||
Gateway 的 `Capacity` 使用一次打包原子读取取得同一时刻的 Active/Reserved,
|
||
`snapshot.Store` 周期生成完整稀疏报告。当前 Snapshot 已移除但仍有活动连接的
|
||
Proxy 继续以 `draining=true` 上报,直到 Active/Reserved 同时归零。
|
||
|
||
Drain Ticket 会绑定“已签发给当前 session、且不早于所需 epoch 的完整排除快照”引用,
|
||
包括 version、epoch 和 checksum。
|
||
后续会在 Runtime 替换 Lua 事务中同时验证 Snapshot ACK、Ticket 屏障和零计数,避免
|
||
拆分为读 Runtime 再释放所有权产生竞态。
|
||
|
||
Redis Adapter 在单个 `{activity}` 原子边界内维护 Worker session 和运行态报告:
|
||
|
||
1. 新 Worker session 替换旧 session,并隔离旧实例后续写入。
|
||
2. session 保存 Controller 已 ACK 的 snapshot version 与 ownership epoch;报告
|
||
必须与 ACK 上界完全一致,不能通过自报超前 epoch 绕过所有权校验。
|
||
3. `report_sequence` 严格递增;同序号、同内容可幂等重放,冲突或倒序拒绝。
|
||
4. 非零计数必须匹配当前 Proxy owner、Worker ID 和 ownership epoch。
|
||
5. session/report TTL 使用 Redis 服务端时间;过期、缺失或损坏时容量 fail-closed。
|
||
6. 空报告清除该 Worker 的全部旧计数,稀疏报告中缺失的 Proxy 计数视为零。
|
||
|
||
`pool.InventoryReader` 低频返回单个 Upstream 的 `Managed` 和
|
||
`AvailableSlots`。Managed 统计 FETCHED/CHECKING/AVAILABLE/SUSPECT/DRAINING;
|
||
Available Slots 只统计超过 safety margin、状态为 AVAILABLE 且所有权与新鲜
|
||
Worker 运行态一致的 `max - active - reserved`。未分配 Proxy 可直接贡献 Max;
|
||
已分配但运行态未知的 Proxy 贡献零槽位。PostgreSQL 不保存这些短效报告或容量
|
||
明细,Gateway 每次请求也不访问 Redis。
|
||
|
||
Managed 直接读取现有 Upstream 权威计数;Available Slots 只扫描目标 Upstream
|
||
的未分配可用索引与已分配可用索引,不扫描全局 Proxy,也不受其他供应商活记录
|
||
数量影响。索引成员数超过单次扫描预算时直接返回不可用,不降级为近似容量。
|
||
生产规模验收仍需验证脚本 p95/p99、CPU、内存和过期风暴下的有界行为。
|
||
|
||
Gateway `snapshot.Store` 扫描有界的当前 Snapshot,并使用分片索引补充已移除但
|
||
仍非零的 runtime,不扫描全部历史 Proxy;历史 Capacity 注册表设硬上限,达到
|
||
上限时拒绝新 Snapshot 并保持旧视图,避免长期轮换造成无界内存增长。
|
||
|
||
### 短 TTL 清理
|
||
|
||
Redis Hash 字段没有独立 TTL,因此使用三层有界清理:
|
||
|
||
1. 幂等和内部操作结果使用 Redis 原生键 TTL。
|
||
2. Upsert、Health、Extract 和 Ownership 脚本机会式清理少量过期记录。
|
||
3. 公用维护循环按 `limit` 从 expiry ZSET 分批清理记录、唯一键、过滤索引、
|
||
ownership 和库存计数。
|
||
|
||
主活动键的过期时间始终延伸到当前最晚 Proxy 硬过期时间。活动池停止写入后,
|
||
整个命名空间最终自动释放;持续写入时由有界维护循环阻止旧字段累积。
|
||
|
||
### 凭据
|
||
|
||
Provider Parser 继续通过 `credentials.Store` 生成 `SecretRef` 和
|
||
`CredentialVersion`。Redis Adapter 在 Upsert 写入前解析凭据,使解析失败发生
|
||
在活动池状态提交之前。Distribution 所需凭据只保存在 Proxy 硬 TTL 和幂等 TTL
|
||
约束内,Extract 脚本可原子保存完整重放响应。
|
||
|
||
Gateway Snapshot 携带凭据引用及按引用去重的材料;材料只经 mTLS 控制面进入
|
||
Gateway 当前内存 View,不在请求热路径查询 Redis,也不进入 PostgreSQL。凭据轮换的
|
||
主动推送仍属于后续实现,不改变本 ADR 的活动池边界。
|
||
|
||
### 库存真值
|
||
|
||
Redis `inventory` 是当前未提取 Proxy 数量的运行时真值:
|
||
|
||
- 插入新的当前生命周期时增加。
|
||
- EXTRACTED、EXPIRED 或 REMOVED 时减少。
|
||
- 重复刷新和其他供应商重复上报不改变。
|
||
- Redis 丢失后归零,由 Provider 重新获取并重建。
|
||
|
||
`InventoryReader` 为控制面提供低频校准。PostgreSQL 不保存 Proxy 明细,也不
|
||
参与每秒库存读取;可选长期指标只能保存无 Proxy 明细的聚合值。
|
||
|
||
## 故障语义
|
||
|
||
- Redis 不可用时停止 Provider 入池、Extract 和所有权变更。
|
||
- Distribution 将存储不可用和扫描预算耗尽映射为 503。
|
||
- PostgreSQL 不可用不阻断 Redis 中能够完成的 Extract。
|
||
- Worker 在控制面故障时继续使用未过期本地 Snapshot,超过最大陈旧时间后
|
||
停止接收新流量。
|
||
- 写脚本通过内部 operation ID 抵御连接中断后的重复执行。
|
||
- Redis 整体丢失代表活动池代次终止;Provider 重建是新代次,不从 PostgreSQL
|
||
恢复旧 Proxy,也不延续已丢失代次的排他状态。
|
||
|
||
## 测试与验收
|
||
|
||
实现必须先建立可复用行为契约,并让 MemoryPool 与 Redis Adapter 运行相同
|
||
测试向量:
|
||
|
||
- 供应商 TTL、安全余量、MaxSize、重复刷新和跨供应商 incumbent。
|
||
- FETCHED 到 AVAILABLE 及不健康状态退出索引。
|
||
- partial、allOrNothing、过滤、健康新鲜度和 Gateway 预留。
|
||
- 幂等重放、摘要冲突和最早 Proxy 过期时间上限。
|
||
- 100 轮并发 Extract 的返回集合无交集。
|
||
- Assign 与 Extract 并发互斥,以及 renew/drain/ACK/expire。
|
||
- 提交后连接断开、脚本缓存丢失、上下文取消和 Redis 不可用。
|
||
- 30 秒 TTL 持续写入下的有界清理与库存一致性。
|
||
- Worker session 替换、运行态序号幂等/冲突、报告过期和 ownership epoch 隔离。
|
||
- 权威 Managed/Available Slots 聚合及扫描预算耗尽时的 fail-closed 行为。
|
||
|
||
Lua 语义必须使用真实 Redis 8.2 集成测试验证。单元测试最长 60 秒,并执行
|
||
gofmt、go vet、全量测试、构建和 diff whitespace 检查。100,000 QPS 只能由
|
||
后续代表性集群压测证明,本 ADR 不把设计目标表述为已验证吞吐。
|
||
|
||
## 数据持久化
|
||
|
||
本地 Compose 的 Redis 关闭 AOF 和 RDB,因为活动池是可重建短效状态,避免
|
||
将代理地址、凭据和幂等响应持续写入开发机磁盘。生产 Redis 是否启用受保护的
|
||
磁盘持久化由部署策略决定,但不得把 Redis 备份当作 Proxy 恢复来源。
|
||
|
||
## 备选方案
|
||
|
||
### Redis Cluster 单槽
|
||
|
||
可以提供 Cluster 故障转移,但活动池仍集中在一个 slot,不能获得水平吞吐
|
||
扩展。首版使用 Sentinel 已满足当前部署边界,因此暂不承担 Cluster 运维成本。
|
||
|
||
### Redis Cluster 多分片
|
||
|
||
可以分摊控制面吞吐,但会破坏全局唯一键、Gateway 预留和跨分片
|
||
`allOrNothing` 原子性,需要 reservation/commit/rollback 两阶段协议。当前
|
||
Distribution 频率远低于 Gateway 流量,不采用该复杂度。
|
||
|
||
### 每个 Proxy 一个带 TTL 的 Redis Key
|
||
|
||
硬 TTL 直观,但原子 Extract 需要先发现候选再访问动态 key,键声明、索引清理
|
||
和批量脚本复杂度更高。固定 Hash 与 ZSET 组合更适合当前单实例原子边界,并用
|
||
有界清理保证内存回收。
|
||
|
||
### 在 PostgreSQL 保存 Proxy 或提取记录
|
||
|
||
会引入高频写入、过期清理和不必要存储,并让 PostgreSQL 进入运行时数据路径,
|
||
与已确认的数据最小化边界冲突,因此不采用。
|
||
|
||
## 后果
|
||
|
||
收益:
|
||
|
||
- Redis 复杂性集中在一个深模块,业务调用方只依赖窄端口。
|
||
- 独占提取、所有权、库存和幂等共享明确原子边界。
|
||
- 30 秒短 TTL、过期风暴和扫描工作量具有明确上限。
|
||
- 10 万 QPS Gateway 路径继续完全本地化。
|
||
|
||
代价:
|
||
|
||
- 单个活动池主节点是控制面吞吐上限,需要监控脚本 p95/p99 和 CPU。
|
||
- Hash 字段 TTL 需要 ZSET 和维护循环配合。
|
||
- Redis Adapter 需要真实 Redis 集成测试,纯内存替身不足以证明 Lua 原子性。
|
||
- Gateway 凭据安全下发仍需单独实现,但不得改变热路径无 Redis 的约束。
|