外观
KV 与缓存键索引
L4(Valkey,持久瞬态)与 L5(Redis,派生缓存)中的每一个键:键名、类型、TTL、写入方、丢失后果。分层模型与准入判据见分层与存储角色,实测水位(命中率 99.8%、evicted_keys=0、Valkey 用量 1.1%)见性能画像。
两层的界线只有一条:该数据是否为唯一副本。Valkey 中的键丢失后依靠功能自愈(重新登录、一格断档);Redis 中的每一条都能从 PostgreSQL 重算,丢失仅增加一次数据库查询。
封装在 app/services/cache.py(Cache 类同时服务两个实例,由 main.py 分别注入 cache= 与 kv=)与 authserver/services/kv.py。
L4 · Valkey(noeviction + RDB)
注意:每个键都必须带 TTL。 该实例为 noeviction 且与 Cap 共用:无 TTL 的键将永久占用额度,额度耗尽后整个实例的写入均返回 OOM command not allowed,包括 db 0 上 Cap 的验证码签发,表现为登录中断。此项由 test_storage_discipline.py 静态约束——KV 写入方法的 ttl_seconds 参数不允许有默认值。
db 0 — Cap(事实源,非缓存)
由 Cap 自行管理,控制面不写入。这 15 个键中有 14 个无 TTL,因为它们是 Cap 的持久配置而非瞬态数据。
| 键 | 类型 | TTL | 内容 |
|---|---|---|---|
settings:rsw_keypair | hash | 无 | 签名密钥对 |
key:<id> | hash | 无 | 某个 sitekey 的定义 |
keys | set | 无 | sitekey 注册表 |
settings:cors / settings:headers / settings:filtering | string | 无 | 站点配置 |
metrics:*:<id> | hash | 无 | 验证次数、失败数、平台、国家等统计 |
sessions | set | 无 | 管理会话索引 |
session:<argon2 hash> | string | 约 14 天 | 管理台登录会话 |
丢失后果与其他各层不同:Cap 将不可用。 sitekey 需重建,auth-server.toml 与前端 widget 均需同步修改,期间登录页中断。因此 dump.rdb 必须纳入备份集——这是备份 Valkey 的唯一理由。
db 1 — control-server
| 键 | 类型 | TTL | 写入方 | 丢失后果 |
|---|---|---|---|---|
pflap:<node_id> | string(JSON blob) | 7 天 | services/prefix_flaps.py,每份上报(约 60 s)整份 SET | 唯一副本:基准丢失后该轮不计速率、不写速率桶,下一轮重建 |
bgpflap:<node_id> | string(JSON blob) | 7 天 | services/flaps.py,每份 runtime snapshot(约 5 min)整份 SET | 唯一副本:会话打分从 0 重新累积(半衰期 30 min,影响窗口有限) |
agent:live:<node_id> | string(JSON) | 600 s | api/v1/agent_ws.py,每次心跳(约 30 s) | 该节点显示为 offline,直至下一次心跳(不超过 30 s) |
agent:push:<node_id>:<target> | string | 24 h | 版本更新推送去重 | 可能对同一节点重复推送一次更新(agent 侧幂等) |
throttle:login:<sha256> | string(计数) | 900 s | services/login_throttle.py,每次登录失败 INCR | 计数退回本进程内存副本,跨 worker 视角丢失 |
ws:sub:<node_id>:<instance>:<conn> | string | 90 s(30 s 续期) | core/events.py,WS 连接建立时 | 订阅者计数少报,可能误判 agent 不在线而拒绝下发 |
probe:job:<probe_id> | string(JSON) | 未完成 1 h / 完成后 120 s | core/probe_hub.py,创建拨测或日志作业时 | 该次拨测断链,需重新发起 |
probe:buf:<probe_id> | list(不超过 4000 条) | 同上 | agent 每回传一条输出执行 RPUSH | 刷新页面后无法看到已产生的输出 |
portal:login:<state> | string(JSON) | 30 min | services/portal_sessions.py,发起 OIDC 登录时 | 该次登录往返作废,用户需重新发起 |
另有两条 pub/sub 通道(非键,不占存储):evt:node:<node_id> 投递控制面至 agent 的事件门铃;probe:msg:<probe_id> 将 agent 回传的拨测输出 fan-out 至各进程的 SSE 订阅者。
拨测相关键的存在理由是多 worker:一次拨测横跨三个请求——POST 创建作业、agent 经 WS 回传输出、GET .../stream 订阅。单进程时三者必然落在同一进程,启用 --workers 后会分散到不同进程,不跨进程共享即断链。
去重依据 RPUSH 的返回序号:订阅时先注册本地队列,再读取 buffer 快照(长度 L),此后从 pub/sub 收到的消息中凡 seq <= L 一律丢弃。
前缀级热态 blob 的实测尺寸为 115–164 KB/节点(10 节点合计约 1.4 MB);会话级(bgpflap:*)每节点仅 9–26 个会话,量级显著更小。
两级 flap 热态遵循同一模式:打分状态属于当下视图(每份上报整份重算、读时续衰),转移历史与告警事件属于事实、始终落 SQL。会话级迁入 KV 之前,前缀级在 KV 而会话级在 PostgreSQL,同一概念存在两种存法,构成易被误改的不对称。
db 2 — auth-server
| 键 | 类型 | TTL | 写入方 | 丢失后果 |
|---|---|---|---|---|
oauth:code:<sha256> | string(JSON) | 120 s | 授权页同意时签发 | 该次授权作废,用户需重新走一次 |
oauth:code:used:<sha256> | string(JSON) | 600 s | 兑换授权码时留下的墓碑 | 见下方说明 |
auth:ceremony:<uuid> | string(JSON) | 分钟级 | WebAuthn 注册或登录开始时 | 用户需重新完成一次生物识别 |
墓碑键不可省略。 授权码被二次使用是泄露信号,此时应吊销由该码换出的全部 refresh token(RFC 6749 §4.1.2)。若消费时将键彻底删除,重放将退化为「未知授权码」,该吊销路径永远不会触发——功能静默降级,且降级方向为安全性。因此 GETDEL 取出内容后立即写入一个墓碑,第二次兑换命中墓碑即进入吊销路径。
三类键均有数据库回落:KV 未配置或写入失败时落回 oauth_codes / webauthn_ceremonies 表,两条路径的对外语义逐条相同(authserver/tests/test_kv_transients.py 以同一套端到端用例覆盖)。
L5 · Redis(allkeys-lru,不落盘)
注意:此处的任何一条都可能随时消失——LRU 淘汰、容器重建、主机重启均会清空(--save "")。因此只存放能从 PostgreSQL 重算的结果。判据为:删除整个实例后,功能仅变慢、不出错。
db 1 — control-server
| 键 | 类型 | TTL | 来源 | 失效方式 |
|---|---|---|---|---|
ds:<node_id>:<generation> | string(JSON) | 1 h | 物化后的 DesiredState 快照 | 键含世代号,天然不可变,无失效竞态 |
health:node:<node_id> / health:fleet | string(JSON) | 10 s | 健康聚合 | 上报入库后主动 DELETE |
routing:summary:<node_id> / routing:fleet | string(JSON) | 30 s | 路由聚合 | 路由快照入库后主动 DELETE |
traffic:window:<node_id> | list(不超过 240 项) | 2 h | 30 s 流量采样滑动窗口 | LPUSH + LTRIM 自然滚动 |
regname:<asn> | string | 1 h | ASN 至 as-name 映射(含空串负缓存) | 依赖 TTL |
agtok:<sha256> | string(JSON) | 60 s(负缓存 5 s) | agent token 解析结果 | 撤销或轮换时主动 DELETE |
traffic:window 是本层唯一带状态属性的键——它是差分基准,但具备持久回退:Redis 不可用时 TrafficStore 改用 node_traffic_last_sample 表中的上一条样本,5 分钟存档照常累加。正因存在该回退,它才得以留在 L5。
agtok: 的 TTL 是一项显式取舍:token 撤销后最长 60 秒内仍可能被接受。revoke 与 rotate 会主动删除键,因此该窗口仅在绕过控制面直接修改数据库时出现。若要求零窗口,将 TTL 调整为 0 即等同于关闭该缓存。
db 3 — registry-server
| 键 | 类型 | TTL | 内容 |
|---|---|---|---|
reg:ver | string(计数) | 无 | 缓存版本号 |
reg:v<N>:asn:<asn> | string(JSON) | 30 min | ASN 记录 |
reg:v<N>:mntner:<name> | string(JSON) | 30 min | mntner 记录 |
reg:v<N>:routes:<asn> | string(JSON) | 30 min | 该 ASN 起源的前缀 |
reg:v<N>:identity:<asn>/<mntner> | string(JSON) | 30 min | OAuth claims 富化物料 |
失效依据版本号而非删除键:每次 registry 同步成功后执行 INCR reg:ver,键名中的版本号随之变化,旧键无人再访问、随 TTL 自然消亡。该设计使手动 POST /sync 之后新数据立即可见,无需等待 30 分钟,也无需按前缀扫描删除(后者在大 keyspace 上代价高昂)。
reg:ver 是本层唯一无 TTL 的键:它是单调计数器,占用恒定 8 字节,且丢失后仅使缓存版本从 0 重新开始,旧键会被 LRU 自然回收。
降级行为
每一层都有明确的降级路径,不存在因 KV 或缓存不可用而返回错误的情况:
| 组件 | KV / 缓存不可用时 |
|---|---|
| flap 热态 | 回落 SQL 整表路径(node_prefix_flaps + node_prefix_flap_paths),行为与改造前一致 |
| agent 心跳 | 回落进程内存注册表(等同旧实现,重启即清零) |
| 登录限速 | 回落进程内存计数(判定取两路较大值,KV 恢复后自动回到全局视角) |
| 事件总线 | PUBLISH 失败时退回本进程投递,落在本实例的 agent 仍能收到事件 |
| 门户登录事务 | 回落 portal_login_transactions 表 |
| OAuth 授权码 / ceremony | 回落 oauth_codes / webauthn_ceremonies 表 |
| 拨测与日志流 | 回落进程内存中转(等同旧实现)。注意:此时多 worker 下会断链,启用 --workers > 1 必须配置 KV |
| 各类读缓存 | 直接查询数据库 |
/healthz 对两层的处置不同,这正是角色差异的体现:
- Valkey 不可达返回 503。 L4 为唯一副本,缺失会导致静默丢数据与告警误判,应由编排层摘除流量。
- Redis 不可达返回 200 并标记
degraded。 L5 可丢弃,为其摘除流量属于过度反应。 - Valkey 内存用量超过 70% 标记
degraded并记日志。 触顶会连带导致 Cap 无法写入,需在额度耗尽前暴露键泄漏。
运维
bash
# 各 db 的键数(对照 db 编号分配表)
sudo docker exec docker-compose-valkey-1 valkey-cli info keyspace
sudo docker exec docker-compose-redis-1 redis-cli info keyspace
# 某个应用的键(-n 选择 db)
sudo docker exec docker-compose-valkey-1 valkey-cli -n 1 --scan --pattern 'pflap:*'
# 内存与淘汰
sudo docker exec docker-compose-valkey-1 valkey-cli info memory | grep -E 'used_memory_human|maxmemory'
sudo docker exec docker-compose-redis-1 redis-cli info stats | grep evicted_keys
# 仅清除单个应用的缓存(分库的实际价值)
sudo docker exec docker-compose-redis-1 redis-cli -n 1 FLUSHDB注意:FLUSHDB 仅可对 Redis 使用。 对 Valkey 执行会清除唯一副本,其中 db 0 上的一次执行将直接破坏 Cap 的签名密钥对。
注意:不得为 Valkey 添加 --appendonly yes。 数据卷中只有 dump.rdb,而 AOF[1] 的加载优先级高于 RDB:直接添加参数并重启会使实例因找不到 AOF 而以空数据集启动,随后第一次 SAVE 即将 dump.rdb 覆盖为空,回滚也无法恢复(已用同版本镜像实测复现)。若确需启用 AOF,唯一安全路径是先在线执行 CONFIG SET appendonly yes 生成 appendonlydir,确认 aof_last_bgrewrite_status:ok 后再修改配置文件。
AOF(Append Only File):Redis / Valkey 的增量式持久化,记录每条写命令。与快照式的 RDB 相比数据丢失窗口更小,但启动时加载优先级高于 RDB——这正是上述事故的成因。 ↩︎