proxy-pool/docs/adr/005-redis-activity-pool.md

325 lines
16 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.

# ADR-005Redis 活动池采用单实例原子深模块
## 状态
接受并已实现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}: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}:idem:<digest> STRING 带 TTL 的提取幂等结果
pp:{activity}:op:<digest> 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禁止无界返回。
### Worker 运行态与容量汇总
Gateway 的 `Capacity` 使用一次打包原子读取取得同一时刻的 Active/Reserved
`snapshot.Store` 周期生成完整稀疏报告。当前 Snapshot 已移除但仍有活动连接的
Proxy 继续以 `draining=true` 上报,直到 Active/Reserved 同时归零。
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 继续只携带凭据引用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 持续写入下的有界清理与库存一致性。
- 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 的约束。