docs: define redis activity pool architecture

This commit is contained in:
youfak 2026-07-29 13:38:55 +08:00
parent 4de3ffb85f
commit 2fdc37e8d7
2 changed files with 290 additions and 0 deletions

View File

@ -0,0 +1,278 @@
# 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 实现还存在以下缺口:
- `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:<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}: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禁止无界返回。
### 短 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 的约束。

View File

@ -35,3 +35,15 @@ Provider 重新获取并重建,不从 PostgreSQL 恢复原 Proxy。
PostgreSQL 只持久化配置版本、Upstream/Routing 管理状态、Admin 审计与 Outbox
以及可选的无 Proxy 明细聚合指标。两类存储不双写 Proxy也不建立跨存储事务。
## ADR-005Redis 活动池采用单实例原子深模块
**状态:** 接受。
首版 Redis 活动池部署在单实例或 Sentinel 主节点,通过一个深 Adapter 统一实现
Provider 入池、健康状态、Distribution 独占提取、Worker 所有权、库存读取和
有界过期清理。所有键使用固定 hash tag为未来 Redis Cluster 单槽迁移保留
兼容性,但首版不引入跨分片事务。
完整决策、键空间、原子操作和测试门禁见
[ADR-005](005-redis-activity-pool.md)。