diff --git a/docs/adr/005-redis-activity-pool.md b/docs/adr/005-redis-activity-pool.md new file mode 100644 index 0000000..0999e99 --- /dev/null +++ b/docs/adr/005-redis-activity-pool.md @@ -0,0 +1,278 @@ +# 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 实现还存在以下缺口: + +- `ownership.Repository` 缺少 `context.Context` 和存储错误返回值。 +- Provider 写入 `FETCHED` 后没有公用健康状态更新端口。 +- 本地 `FetchBudget` 不能作为多进程环境的最终库存权威。 +- 提取结果需要真实代理凭据,不能在 Redis 已提交消费后再执行可能失败的解析。 +- 短 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) +``` + +Provider、Checker、Distribution 和 Ownership 只依赖各自需要的端口,不直接 +依赖 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}:epoch STRING ownership 全局递增代次 +pp:{activity}:inventory HASH upstreamID -> 当前未提取库存 +pp:{activity}:idem: STRING 带 TTL 的提取幂等结果 +pp:{activity}:op: STRING 带 TTL 的内部操作结果 +``` + +Proxy 记录包含地址、状态、健康信息、硬过期时间、`usableUntil`、供应商、标签、 +所有权引用及 Distribution 返回所需的短期凭据。Redis 键、日志、指标和错误不得 +包含密码或原始 `SecretRef`。 + +### 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 幂等。 +- AcknowledgeDrain 仅在 Active 和 Reserved 都为零时释放所有权。 +- Assign 与 Extract 并发竞争同一 Proxy 时,只允许一个操作成功。 +- Expire 使用 `limit` 分批回收过期 assignment,禁止无界返回。 + +### 短 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 继续只携带凭据引用;Gateway 通过控制面下发到节点内存的 +凭据材料解析引用,不在请求热路径查询 Redis。凭据分发与轮换属于独立后续 +实现,不改变本 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 持续写入下的有界清理与库存一致性。 + +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 的约束。 diff --git a/docs/adr/README.md b/docs/adr/README.md index e01f9d6..2173889 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -35,3 +35,15 @@ Provider 重新获取并重建,不从 PostgreSQL 恢复原 Proxy。 PostgreSQL 只持久化配置版本、Upstream/Routing 管理状态、Admin 审计与 Outbox, 以及可选的无 Proxy 明细聚合指标。两类存储不双写 Proxy,也不建立跨存储事务。 + +## ADR-005:Redis 活动池采用单实例原子深模块 + +**状态:** 接受。 + +首版 Redis 活动池部署在单实例或 Sentinel 主节点,通过一个深 Adapter 统一实现 +Provider 入池、健康状态、Distribution 独占提取、Worker 所有权、库存读取和 +有界过期清理。所有键使用固定 hash tag,为未来 Redis Cluster 单槽迁移保留 +兼容性,但首版不引入跨分片事务。 + +完整决策、键空间、原子操作和测试门禁见 +[ADR-005](005-redis-activity-pool.md)。