# 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: ZSET 协议候选索引 pp:{activity}:region: ZSET 地区候选索引 pp:{activity}:carrier: ZSET 运营商候选索引 pp:{activity}:upstream: 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: 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: ZSET 单 Upstream 已分配 AVAILABLE Proxy pp:{activity}:worker-owned: ZSET 单 Worker 可下发的已分配 Proxy pp:{activity}:idem: STRING 带 TTL 的提取幂等结果 pp:{activity}:op: 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 事务会同时验证当前 session ACK、Ticket 屏障与该 Proxy 的零计数, 仅在完整稀疏报告中 Active/Reserved 都为零时释放所有权、删除 Ticket 和 Worker Drain 索引,并在记录仍为 AVAILABLE 时恢复可分配索引。ACK 或屏障不匹配、报告中仍有计数、 或 session 已替换时都保留 Drain;这样避免拆分为读 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 的约束。