Skip to content

KV 与缓存键索引

L4(Valkey,持久瞬态)与 L5(Redis,派生缓存)中的每一个键:键名、类型、TTL、写入方、丢失后果。分层模型与准入判据见分层与存储角色,实测水位(命中率 99.8%、evicted_keys=0、Valkey 用量 1.1%)见性能画像

两层的界线只有一条:该数据是否为唯一副本。Valkey 中的键丢失后依靠功能自愈(重新登录、一格断档);Redis 中的每一条都能从 PostgreSQL 重算,丢失仅增加一次数据库查询。

封装在 app/services/cache.pyCache 类同时服务两个实例,由 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_keypairhash签名密钥对
key:<id>hash某个 sitekey 的定义
keyssetsitekey 注册表
settings:cors / settings:headers / settings:filteringstring站点配置
metrics:*:<id>hash验证次数、失败数、平台、国家等统计
sessionsset管理会话索引
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 sapi/v1/agent_ws.py,每次心跳(约 30 s)该节点显示为 offline,直至下一次心跳(不超过 30 s)
agent:push:<node_id>:<target>string24 h版本更新推送去重可能对同一节点重复推送一次更新(agent 侧幂等)
throttle:login:<sha256>string(计数)900 sservices/login_throttle.py,每次登录失败 INCR计数退回本进程内存副本,跨 worker 视角丢失
ws:sub:<node_id>:<instance>:<conn>string90 s(30 s 续期)core/events.py,WS 连接建立时订阅者计数少报,可能误判 agent 不在线而拒绝下发
probe:job:<probe_id>string(JSON)未完成 1 h / 完成后 120 score/probe_hub.py,创建拨测或日志作业时该次拨测断链,需重新发起
probe:buf:<probe_id>list(不超过 4000 条)同上agent 每回传一条输出执行 RPUSH刷新页面后无法看到已产生的输出
portal:login:<state>string(JSON)30 minservices/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:fleetstring(JSON)10 s健康聚合上报入库后主动 DELETE
routing:summary:<node_id> / routing:fleetstring(JSON)30 s路由聚合路由快照入库后主动 DELETE
traffic:window:<node_id>list(不超过 240 项)2 h30 s 流量采样滑动窗口LPUSH + LTRIM 自然滚动
regname:<asn>string1 hASN 至 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 秒内仍可能被接受。revokerotate 会主动删除键,因此该窗口仅在绕过控制面直接修改数据库时出现。若要求零窗口,将 TTL 调整为 0 即等同于关闭该缓存。

db 3 — registry-server

类型TTL内容
reg:verstring(计数)缓存版本号
reg:v<N>:asn:<asn>string(JSON)30 minASN 记录
reg:v<N>:mntner:<name>string(JSON)30 minmntner 记录
reg:v<N>:routes:<asn>string(JSON)30 min该 ASN 起源的前缀
reg:v<N>:identity:<asn>/<mntner>string(JSON)30 minOAuth 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 后再修改配置文件。


  1. AOF(Append Only File):Redis / Valkey 的增量式持久化,记录每条写命令。与快照式的 RDB 相比数据丢失窗口更小,但启动时加载优先级高于 RDB——这正是上述事故的成因。 ↩︎