16 KiB
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 请求热路径:
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)
var _ workerruntime.SessionWriter = (*Adapter)(nil)
var _ workerruntime.ReportWriter = (*Adapter)(nil)
var _ workerruntime.RuntimeReader = (*Adapter)(nil)
var _ pool.InventoryReader = (*Adapter)(nil)
Provider、Checker、Distribution、Ownership 和 Worker Runtime 只依赖各自需要 的端口,不直接依赖 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}:worker-sessions HASH workerID -> 当前 Worker session
pp:{activity}:worker-session-expiry ZSET workerID -> session 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 原子执行以下操作:
- 有界清理已过期 incumbent。
- 校验
proxyID与唯一键映射。 - 保持当前生命周期的 incumbent upstream;其他供应商的重复项不得覆盖。
- EXTRACTED 条目不得通过刷新重新进入活动池。
- 原子维护记录、唯一键、过期索引、过滤索引和 upstream 库存。
- 使用
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 操作完成:
- 检查 Client ID、幂等键和请求摘要。
- 选择候选数量最小的可用过滤索引作为驱动索引。
- 有界复核状态、硬 TTL、
usableUntil、健康新鲜度、所有权和全部过滤条件。 - 为 Gateway 保留
reserveForGateway个符合条件的候选。 - 按
partial或allOrNothing判断结果。 - 将选中条目从
AVAILABLE原子迁移到EXTRACTED。 - 原子减少 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 和运行态报告:
- 新 Worker session 替换旧 session,并隔离旧实例后续写入。
- session 保存 Controller 已 ACK 的 snapshot version 与 ownership epoch;报告 必须与 ACK 上界完全一致,不能通过自报超前 epoch 绕过所有权校验。
report_sequence严格递增;同序号、同内容可幂等重放,冲突或倒序拒绝。- 非零计数必须匹配当前 Proxy owner、Worker ID 和 ownership epoch。
- session/report TTL 使用 Redis 服务端时间;过期、缺失或损坏时容量 fail-closed。
- 空报告清除该 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,因此使用三层有界清理:
- 幂等和内部操作结果使用 Redis 原生键 TTL。
- Upsert、Health、Extract 和 Ownership 脚本机会式清理少量过期记录。
- 公用维护循环按
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 的约束。