proxy-pool/docs/adr/005-redis-activity-pool.md
youfak 63bc56be27
Some checks are pending
ci / test (ubuntu-latest) (push) Waiting to run
ci / test (windows-latest) (push) Waiting to run
ci / race (push) Waiting to run
docs: finalize redis activity pool delivery
2026-07-29 18:01:37 +08:00

13 KiB
Raw Blame History

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 请求热路径:

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]

模块边界

生产实现是一个深模块,对外只暴露一个构造器和窄领域端口:

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 改为适合远程存储的上下文感知接口:

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 和测试调用方同步迁移。

键空间

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

SUSPECTUNHEALTHYEXTRACTEDEXPIREDREMOVED 必须退出所有 AVAILABLE 索引。Checker 不直接拼接 Redis 命令。

独占提取

一个 Lua 操作完成:

  1. 检查 Client ID、幂等键和请求摘要。
  2. 选择候选数量最小的可用过滤索引作为驱动索引。
  3. 有界复核状态、硬 TTL、usableUntil、健康新鲜度、所有权和全部过滤条件。
  4. 为 Gateway 保留 reserveForGateway 个符合条件的候选。
  5. partialallOrNothing 判断结果。
  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 生成 SecretRefCredentialVersion。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 的约束。