朕喜技术(ZhenxiJS)
2026.10.02
1 分钟阅读

分段缓存网关段容量口径错误:容量按「位数」而不是「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

判定口径改为与用户后果挂钩:

  1. 登记过的分区一个都不能少;
  2. 每段 digits 必须等于登记值,且 capacity_bytes == effective_capacity(digits, cell);
  3. 合计不超上限(拿不到上限 = 判不了,也报 degraded);
  4. 每段都必须真被 /dev/shm backing(缺失即 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 / 合计越界」都会让闸门转红。

六、经验与避坑

核心认知

  1. 「段建出来了」不等于「段容量是对的」。 段文件存在、退出码 0、/dev/shm 不报错,只证明「创建系统调用成功了」, 完全不能证明「容量算对了」。本例容量错 20000 倍,四个口径全部无感。
  2. 健康检查必须与业务后果对齐。 自报/report/health/返回码四个口径都在查同一件事(段文件在不在), 这是冗余监控而非多层防护。真正的判据是 「客户端能不能写进去」→ 用 lost_keys 或「容量是否等于 10^digits*cell」判定。
  3. 容量的权威来源必须唯一且显式。 契约和登记表两处都能写位数时,必须明确「谁是权威、另一个只做一致性校验」, 不一致时大声失败,绝不能 registered.get(name, digits) 这种静默兜底。
  4. 10^n 这类口径错误不会有任何异常。 算错就是算错,ftruncate 到 320 字节也是合法操作。所以单位/口径类错误 只能靠断言(A1/A2 直接断言 effective_capacity(5,64)==6400000)抓, 靠日志和健康检查永远抓不到。

踩坑记录

  1. 探针死绿最有迷惑性。 一开始看到四个绿灯差点以为没问题, 直到跑客户端探针才看到 lost_keys=7985。 排查容量类问题,先算一遍关键函数,别先看监控面板。
  2. /dev/shm 的 st_size 与 df 不一致。 段文件 320 字节, ls -l 显示 4096(tmpfs 按页记账),df 的 Used 只有 16K。 判断段容量要看 st_size(即 ftruncate 设定的值), 不要拿 df 的块占用当段容量,也不要用 du。
  3. TOTAL_BUDGET_BYTES 键名与位数项长得一样。 登记表里两种键混排, 不显式排除就会变成一个「分区名」(registered 里真的多出这个键)。 这是 D4 负向用例能抓到的地方。
  4. shm_size 硬上限确实生效,但这反而会误导人。 64MiB 下 4 段 × 6.4MB = 25.6MB 建得出来; 若按 9 位算(32GB)会 ENOSPC。 所以「段建得出来」不能证明口径正确 —— 只不过这次错误口径恰好让容量变小, 于是连 ENOSPC 这个唯一的自然报警信号都没触发。方向很重要:低估容量最难发现。
  5. app/ 是 COPY 进镜像的,改完必须 docker compose up -d --build, restart/exec 跑的还是旧代码。
  6. ops/apply_fix.sh --apply 会覆盖自己的改动,别裸跑; --status 可以用来对照。

自查三问

  1. 把 effective_capacity 拿出来单算一遍,数字与「保留 n 位的段应该多大」对得上吗?
  2. 把登记表的某个位数改掉,段的容量会跟着变吗?(本例修复前:不会)
  3. 把契约和登记表故意改成不一致,程序会大声失败还是静默选一个?(本例修复前:静默)

同类影响面

  • 所有「按位数/数量级描述容量」的配置(10^n 记法与 n 记法混用);
  • tmpfs/shm_size/memfd 的容量规划,以及「多段/多租户合计 vs 总预算」核算;
  • K8s emptyDir.medium: Memory + sizeLimit、cgroup memory.max、 Redis maxmemory、innodb_buffer_pool_size 等容量口径;
  • 一切「契约文件 + 运维登记表」双来源的配置合并逻辑 (必须显式定义权威来源,并对不一致大声失败)。

版权声明

本文为博主原创文章,遵循 CC BY-NC-SA 4.0 许可协议。 转载请注明出处:http://localhost/blogs/2026-10-02-0000-10-tmpfs-shm

分享这篇文章