分段缓存网关段容量口径错误:容量按「位数」而不是「10^位数」算,登记表被无视,四个监控口径全绿但 7985/8000 个键没地方写
日期:2026-10-02 | 分支:
2026-10-02-0000| 第96题 技术栈:共享内存 tmpfs //dev/shm/shm_size/ 容量规划 / 键空间与容量口径 / 配置契约(expected.conf) / 健康检查与退出码 / Python 3.12 / Docker Compose
关键词:段容量算错、10^digits 与 digits 口径差 20000 倍、
/dev/shm 段文件只有 320 字节、健康检查恒绿、health.py 退出码 0 但客户端丢键、
lost_keys=7985、登记表不是唯一权威来源、契约与登记表不一致静默采纳、
TOTAL_BUDGET_BYTES 混进位数表、shm_size 容量规划、tmpfs st_size vs df。
一、问题背景
segmentd 是分段缓存网关:按运维契约把键空间切成若干段,
每段是 /dev/shm 里一块定长区域,客户端按键哈希写入对应段。
段的大小就是这个分区能容纳的键空间。
运维契约 conf/shseg.env:
SHSEG_TOTAL_BUDGET_BYTES=67108864 # 总预算(= compose 的 shm_size 64MiB)
SHSEG_DIGITS=5 # 每段保留位数(键空间 = 10^5)
SHSEG_CELL_BYTES=64 # 每键单元字节数
SHSEG_SEGMENTS=4
运维登记表 conf/expected.conf 登记「每个分区保留几位」,声明为唯一权威来源。
现象:所有页面都是绿的——守护进程自报 OK 段=4 建成功=4、
report.json 是 ok: true, errors: []、health 探针 degraded: false, issues: [] 且退出码 0、
/dev/shm 里 4 个段文件都在、建段返回码 0。
但业务侧反馈某些分区的用户查不到数据。客户端探针直接暴露:
sent = 8000 | written = 15 | lost_keys = 7985
网关说健康,数据却没地方放。
二、复现步骤
cd scenario/2026-10-02-0000
docker compose up -d --build
bash ops/repro.sh
关键输出(问题态):
== [3] 网关自己以为的段容量 ==
segment 0: digits=5 capacity=320 backed_by=shseg.env:SHSEG_DIGITS(5) # 应为 6400000
total_bytes = 1280 | budget_bytes = 67108864
self_consistent = True | notes = [] # 自检说没问题
== [4] /dev/shm 内核事实 ==
-rw------- 1 root root 4096 shseg-0 ... shseg-3 # 每段只有 4KiB
== [5] 网关自报 ==
ok = True | created = 4 | errors = []
== [6] health 探针 ==
degraded = False | issues = [] # exit 0
== [7] 业务后果 ==
sent = 8000 | written = 15 | lost_keys = 7985
验收基线:
bash ops/verify.sh # PASS=11 FAIL=6 / RESULT: BAD (红:A1/A2/B3/C0/D3/D5)
python3 client/check_segments.py # RESULT: BAD
单独复算一下口径:
docker exec -i scenario-shsegd python3 -c '
import sys; sys.path.insert(0,"/srv/app"); import shseg
print(shseg.effective_capacity(5, 64))' # => 320 (正确应为 6400000)
三、根本原因
四个独立缺陷叠加,缺一个都很难同时满足「全绿」与「丢键」。
1. 段容量口径错了:按「位数」而不是「10^位数」
app/shseg.py::effective_capacity():
return digits * cell_bytes # 5 * 64 = 320
digits 是「保留几位」,描述的是键空间规模 key_count = 10^digits。
正确口径是 (10 ** digits) * cell_bytes = 10^5 * 64 = 6_400_000。
差了 20000 倍(10^digits / digits)。
段文件只有 320 字节(tmpfs 页对齐后 ls -l 显示 4096),
每段只能放 320 // 64 = 5 个桶;而客户端按 hash % 10^5 落桶,
绝大多数键下标都 ≥ 5 → 被判「没有位置」。
2. 登记表被无视,TOTAL_BUDGET_BYTES 混进位数表
expected_digits() 把登记表里所有 key = value 都当位数收:
registered = {'0':5,'1':5,'2':5,'3':5, 'TOTAL_BUDGET_BYTES':67108864}
# ^^^ 非位数项混进来了
build_plan() 又用契约的 SHSEG_DIGITS 作主口径:
use_digits = registered.get(name, digits) # 登记缺失才退回契约
backed_by=f"shseg.env:SHSEG_DIGITS({digits})" # 一律写契约来源
登记表实际上没起作用,backed_by 也谎报了来源。
3. 契约与登记表不一致时静默采纳
契约 SHSEG_DIGITS=5 与登记表某分区(比如改成 8 位)不符时,
代码不报错、随便挑一个用,把「契约对不上」这种必须收口前暴露的问题吞掉。
4. 健康判定与真实业务后果脱钩
health.py 只查三件事:有段、段文件存在、合计不超上限。
段容量算成 320 字节时这三条全部满足,所以探针死绿。
真正的判据——「段容量/位数与登记表一致」——它一条都没查。
这就是「四个监控口径全绿」的结构性原因:所有监控都在检查 「段文件存不存在」,没有一条检查「段容量对不对」。
四、解决办法
只改 app/ 下三个文件,不动 conf/shseg.env、conf/expected.conf、
docker-compose.yml(验收 F 组专门防止「调大 shm_size 蒙混」)。
app/shseg.py
def effective_capacity(digits: int, cell_bytes: int) -> int:
"""段容量 = key_count * cell_bytes,key_count = 10^digits。"""
if digits < 0:
raise ValueError(f"保留位数不能为负:{digits}")
return (10 ** digits) * cell_bytes
_NON_DIGIT_KEYS = {"TOTAL_BUDGET_BYTES", "SHSEG_TOTAL_BUDGET_BYTES"}
def expected_digits(path=EXPECTED_FILE) -> dict[str, int]:
"""读登记表的「分区 -> 保留位数」,排除非位数项。"""
out = {}
for key, value in parse_kv(path).items():
if key in _NON_DIGIT_KEYS: # 关键:把总量项挡在位数据之外
continue
try:
out[key] = int(value)
except ValueError:
continue
return out
build_plan() 重写要点:
- 段名取自登记表的分区号,不再用
range(SHSEG_SEGMENTS); - 契约
SHSEG_DIGITS与任一分区登记值不符 →self_consistent=False+ note(大声失败); SHSEG_SEGMENTS与登记分区数不符 → 同样报不一致;backed_by改为expected.conf:<name>=<digits>,可外部复核;- 新增
budget_source标明上限来源。
app/segmentd.py
round_once() 建段前加闸门:
if not plan.self_consistent:
payload = {..., "created": 0, "ok": False,
"errors": ["plan 自检未通过"], "notes": plan.notes}
REPORT.write_text(json.dumps(payload, ensure_ascii=False, indent=2))
LOG.error("[segmentd] round #%d 规划未通过自检:%s", index, plan.notes)
return payload
规划没通过就不许自报「建段成功」。
app/health.py
判定口径改为与用户后果挂钩:
- 登记过的分区一个都不能少;
- 每段
digits必须等于登记值,且capacity_bytes == effective_capacity(digits, cell); - 合计不超上限(拿不到上限 = 判不了,也报 degraded);
- 每段都必须真被
/dev/shmbacking(缺失即 degraded,不再「全缺才算」)。
五、验证
cd scenario/2026-10-02-0000
docker compose up -d --build
bash ops/verify.sh # PASS=17 FAIL=0 / RESULT: OK
python3 client/check_segments.py # RESULT: OK
修复后:
segment 0: digits=5 capacity=6400000 backed_by=expected.conf:0=5
total_bytes = 25600000 | budget_bytes = 67108864
-rw------- 1 root root 6400000 shseg-0 .. shseg-3
sent = 8000 | written = 8000 | lost_keys = 0 # 业务后果归零
health: degraded=False, issues=[], exit=0
外部探针(宿主机侧独立判定,不信任容器内自检):
container_shm_limit_bytes = 67108864
registered_digits = {'0': 5, '1': 5, '2': 5, '3': 5}
container_segments = {'shseg-0': 6400000, ..., 'shseg-3': 6400000}
container_segments_total = 25600000
RESULT: OK
反向验证(闸门必须有牙):
sed -i 's/^1 = 5$/1 = 8/' conf/expected.conf # 位数据放大,合计必然越界
docker cp conf/expected.conf scenario-shsegd:/srv/conf/expected.conf
docker exec -i scenario-shsegd python3 /srv/app/health.py # degraded=true, exit=1
改成 4 位(合计不越界,只触发「契约与登记不符」一条):
report ok = False errors = ['plan 自检未通过']
notes = ['契约 SHSEG_DIGITS=5 与登记表不符的分区:1']
即「位数不符 / 容量口径不符 / 没被内核 backing / 合计越界」都会让闸门转红。
六、经验与避坑
核心认知
- 「段建出来了」不等于「段容量是对的」。
段文件存在、退出码 0、
/dev/shm不报错,只证明「创建系统调用成功了」, 完全不能证明「容量算对了」。本例容量错 20000 倍,四个口径全部无感。 - 健康检查必须与业务后果对齐。
自报/report/health/返回码四个口径都在查同一件事(段文件在不在),
这是冗余监控而非多层防护。真正的判据是
「客户端能不能写进去」→ 用
lost_keys或「容量是否等于10^digits*cell」判定。 - 容量的权威来源必须唯一且显式。
契约和登记表两处都能写位数时,必须明确「谁是权威、另一个只做一致性校验」,
不一致时大声失败,绝不能
registered.get(name, digits)这种静默兜底。 10^n这类口径错误不会有任何异常。 算错就是算错,ftruncate到 320 字节也是合法操作。所以单位/口径类错误 只能靠断言(A1/A2 直接断言effective_capacity(5,64)==6400000)抓, 靠日志和健康检查永远抓不到。
踩坑记录
- 探针死绿最有迷惑性。 一开始看到四个绿灯差点以为没问题,
直到跑客户端探针才看到
lost_keys=7985。 排查容量类问题,先算一遍关键函数,别先看监控面板。 /dev/shm的st_size与df不一致。 段文件 320 字节,ls -l显示 4096(tmpfs 按页记账),df的 Used 只有 16K。 判断段容量要看st_size(即ftruncate设定的值), 不要拿df的块占用当段容量,也不要用du。TOTAL_BUDGET_BYTES键名与位数项长得一样。 登记表里两种键混排, 不显式排除就会变成一个「分区名」(registered里真的多出这个键)。 这是 D4 负向用例能抓到的地方。shm_size硬上限确实生效,但这反而会误导人。 64MiB 下 4 段 × 6.4MB = 25.6MB 建得出来; 若按 9 位算(32GB)会ENOSPC。 所以「段建得出来」不能证明口径正确 —— 只不过这次错误口径恰好让容量变小, 于是连ENOSPC这个唯一的自然报警信号都没触发。方向很重要:低估容量最难发现。app/是COPY进镜像的,改完必须docker compose up -d --build,restart/exec跑的还是旧代码。ops/apply_fix.sh --apply会覆盖自己的改动,别裸跑;--status可以用来对照。
自查三问
- 把
effective_capacity拿出来单算一遍,数字与「保留 n 位的段应该多大」对得上吗? - 把登记表的某个位数改掉,段的容量会跟着变吗?(本例修复前:不会)
- 把契约和登记表故意改成不一致,程序会大声失败还是静默选一个?(本例修复前:静默)
同类影响面
- 所有「按位数/数量级描述容量」的配置(
10^n记法与n记法混用); - tmpfs/
shm_size/memfd的容量规划,以及「多段/多租户合计 vs 总预算」核算; - K8s
emptyDir.medium: Memory+sizeLimit、cgroupmemory.max、 Redismaxmemory、innodb_buffer_pool_size等容量口径; - 一切「契约文件 + 运维登记表」双来源的配置合并逻辑 (必须显式定义权威来源,并对不一致大声失败)。
