八块有界标签与四百个请求标识标签组成的密集网格对照;相同的九组件、二十副本系数得到 1440 与 72000 条序列。

基数是指标的预算

一个很短的指标名,背后可能对应大量存储与查询成本。Prometheus 使用指标名及完整标签集合标识一条时间序列,每出现一种新的实际标签组合,就形成另一条需要采集、建立索引、保留和查询的序列。基数预算关注的是,哪些区别值得付出这笔成本,以及服务增长时,这些区别的数量是否仍有明确上限。它不是单纯要求标签越少越好,而是要求每个维度都有可解释的用途。 原文练习是序列数量模型:八种有界组合全部出现,每个副本都有这些组合。九个经典直方图核心组件和二十副本同等放大两种方案。数量不是实测的内存或磁盘占用。 统计真正出现的组合 标签维度会扩大可能的组合数,但只有每种组合都实际出现时,各维度取值数的乘积才等于真实数量。两条路由、两种方法、两类状态最多可以形成八种组合;如果每条路由只允许一种方法,导出的组合就可能更少。因此既要计算理论上界,也要核对实际输出,并把实例等采集目标标签纳入服务端最终接收的序列估计。 取值集合比标签名字是否好听更重要。标准化路由可以把同一类订单操作归在一起,而包含每个订单编号的原始路径会持续增加取值。请求编号、时间戳、用户标识以及任意查询字符串,都可能随着事件量增长。开发者把标签叫作路由,并不意味着它真的有界;还要检查真实流量中的值,尤其是错误路径、未匹配路径和很少出现的边界请求,避免正常测试掩盖高基数来源。 把指标表示方式一起算进去 对于每组实际标签,普通计数器通常产生一条主要序列,另外可能存在伴随指标。经典直方图则会展开成多个桶、总和与计数组件。如果有六条有限桶边界,再加正无穷桶、总和及计数,每组标签就对应九条核心序列。有些客户端还会导出创建时间序列,真实清单必须把它算上,不能将用于理解原理的简化公式当成完整埋点总量。 多个副本还会增加实例维度。采集间隔决定每条序列多久写入一个样本,保留期限决定历史数据保存多久,它们与不同序列的数量不是同一概念。不断变化的标识还会制造时序更替:生产端已经消失的短命组合,历史样本仍可能继续留在存储里。因此应分别观察活跃序列、新建速度、写入量、内存和查询表现,不能拿一个固定的“每条序列占多少字节”覆盖所有配置与工作负载。 坏掉的样子 看似方便排障的标签,可能把聚合指标变成逐请求事件仓库。只在仪表盘查询中去掉该标签,无法撤销已经发生的采集和保留成本。对每个标识计算哈希,也不会消除其唯一性;截断随机标识通常只是推迟增长问题,同时引入碰撞。如果业务真正需要的是分类统计,就应选择有明确上限的类别,而不是给无限集合换一种编码方式。 也不能为了降低成本,把识别用户影响所必需的区别全部压平。保留有意义且有界的结果或服务维度,把单次请求调查交给具有相应访问控制的日志和链路记录。如果在采集过程中删除标签,还要检查一次采集内是否出现重复的最终序列,以及诊断信息是否丢失。能够修改生产端时,应优先在那里修正,再验证新标签集合,并明确旧历史序列如何随保留策略逐步退出。 快速验证 保存为 cardinality.py 并使用 Python 3 执行。人为构造的事件把路由、方法和状态的每一种组合重复五十次。一种表示只保留这些有界维度,另一种再增加唯一的请求标识。计算假定二十个副本都导出相同的组合,并使用六条有限边界的经典直方图;这个前提在代码旁明确列出,便于读者核对乘法来源。 from itertools import product routes = ["/orders/{order_id}", "/products/{product_id}"] methods = ["GET", "POST"] statuses = ["200", "500"] events = list(product(routes, methods, statuses, range(50))) bounded = {(route, method, status) for route, method, status, _ in events} with_request_id = {(route, method, status, f"request-{i}") for i, (route, method, status, _) in enumerate(events)} finite_buckets = 6 # Core classic-histogram series: finite buckets, +Inf, _sum, _count. # Optional _created series are deliberately excluded from this calculation. series_per_combination = finite_buckets + 3 replicas = 20 bounded_series = len(bounded) * series_per_combination * replicas request_id_series = len(with_request_id) * series_per_combination * replicas assert len(events) == 400 and len(bounded) == 8 assert len(with_request_id) == 400 assert bounded_series == 1440 and request_id_series == 72000 print(f"observed label sets: bounded={len(bounded)}, request_id={len(with_request_id)}") print(f"core classic-histogram series: bounded={bounded_series}, request_id={request_id_series}") print(f"multiplier={request_id_series // bounded_series}x") 四百条事件在有界表示下被聚合为八种组合,带请求标识时则保留四百种。扩展到二十个副本后,两者分别产生一千四百四十条与七万二千条核心直方图序列,相差五十倍。示例明确排除了可选创建时间序列和其他指标族,因此输出是给定假设下的序列数量模型,不是对 Prometheus 内存或磁盘占用进行过真实压测的结果。 ...

七月 21, 2027 · 1 分钟 · XB Field Notes
回放覆盖与新快照两道所需条件汇合,得到余额 80。

只读副本服务查询需求

只读副本允许一份数据库服务查询,另一份数据库处理写入。报表、搜索或其他适合的读取,可以由此离开写入节点,但把连接路由到某个地址只是设计的一部分。应用还必须说明返回结果需要包含哪些已经提交的变更。本文采用 PostgreSQL 物理热备库,它在正常恢复模式下可以读取,但不能接受普通业务写入。 坏掉的样子 异步复制下,成功写入之后立刻查询副本,仍然可能读到旧值。用户刚修改账户并刷新页面,之前的内容却又出现了。此时副本可能连接健康、日志接收持续推进,只是相关提交还没有被重放。如果新鲜度检查只看接收位置、不看回放位置,就检查了错误的完成边界。 即使回放已经推进,旧事务快照仍然可能影响结果。可重复读事务本来就需要在恢复继续进行时保留自己的视图。等待副本应用某次写入,不会顺便刷新这个事务。因此,要实现写后读到本次修改,既需要足够的回放进度,也需要查询使用在这个进度条件满足之后取得的新快照,两个条件缺一不可。 新鲜度承诺也需要限定范围。包含这个用户较早的一次写入,不等于保证其他地方不存在更新的事务。若读取余额是为了批准下一笔扣款,仍然必须在写入路径中进行事务性核对。副本提供的查询容量,不能替代保护业务动作的并发控制,也不能使一个只读结果天然变成后续写入的有效授权。 回放覆盖不会刷新已有快照。100 与 80 是待运行验证的 PostgreSQL 夹具值,不是容量基准。 快速验证 在 Linux 环境中以非超级操作系统账户运行脚本,并准备 PostgreSQL 18 服务端和客户端程序。原文报告使用 PostgreSQL 18.6;本轮源文审阅未重跑夹具。脚本建立私有主备集群,通过 Unix 套接字连接,不打开 TCP 监听。Unix 套接字仍是监听端点,需要执行环境允许。一个命名管道保持读会话存活,让实验能够明确控制事务快照边界,无需额外安装数据库驱动。 #!/bin/sh set -eu umask 077 lab=$(mktemp -d /tmp/xbcatm-read-replica.XXXXXX) mkdir "$lab/socket" primary=$lab/primary standby=$lab/standby reader_pid='' reader_open=0 cleanup() { if test "$reader_open" = 1; then exec 3>&-; fi if test -n "$reader_pid"; then wait "$reader_pid" || :; fi for cluster in "$standby" "$primary"; do if test -f "$cluster/postmaster.pid"; then pg_ctl -D "$cluster" -m fast -w stop >/dev/null fi done } trap cleanup EXIT initdb -D "$primary" --no-locale --encoding=UTF8 --auth-local=trust --auth-host=reject >/dev/null cat >> "$primary/postgresql.conf" <<CONF listen_addresses = '' unix_socket_directories = '$lab/socket' port = 56432 wal_level = replica wal_keep_size = '64MB' CONF pg_ctl -D "$primary" -l "$lab/primary.log" -w -t 15 start >/dev/null export PGHOST=$lab/socket PGPORT=56432 PGDATABASE=postgres PGUSER=$(id -un) export PGCONNECT_TIMEOUT=2 psql -X -v ON_ERROR_STOP=1 -qAt <<'SQL' CREATE TABLE account (id integer PRIMARY KEY,balance integer); INSERT INTO account VALUES (1,100); SQL pg_basebackup -D "$standby" -R -X stream -c fast -w cat >> "$standby/postgresql.conf" <<CONF port = 56433 hot_standby = on CONF pg_ctl -D "$standby" -l "$lab/standby.log" -w -t 15 start >/dev/null mkfifo "$lab/reader.sql" PGPORT=56433 PGAPPNAME=replica_snapshot psql -X -v ON_ERROR_STOP=1 -qAt <"$lab/reader.sql" >"$lab/reader.log" 2>&1 & reader_pid=$! exec 3>"$lab/reader.sql" reader_open=1 printf '%s\n' 'BEGIN ISOLATION LEVEL REPEATABLE READ;' 'SELECT balance FROM account WHERE id=1;' >&3 attempt=0 while test "$(PGPORT=56433 psql -X -v ON_ERROR_STOP=1 -qAt -c "SELECT EXISTS (SELECT FROM pg_stat_activity WHERE application_name='replica_snapshot' AND state='idle in transaction' AND backend_xmin IS NOT NULL)")" != t; do attempt=$((attempt+1)) test "$attempt" -lt 100 || exit 1 sleep 0.1 done PGPORT=56433 psql -X -v ON_ERROR_STOP=1 -qAt -c 'SELECT pg_wal_replay_pause()' >/dev/null attempt=0 while test "$(PGPORT=56433 psql -X -v ON_ERROR_STOP=1 -qAt -c "SELECT pg_get_wal_replay_pause_state()='paused'")" != t; do attempt=$((attempt+1)) test "$attempt" -lt 100 || exit 1 sleep 0.1 done psql -X -v ON_ERROR_STOP=1 -qAt -c 'UPDATE account SET balance=80 WHERE id=1' target=$(psql -X -v ON_ERROR_STOP=1 -qAt -c 'SELECT pg_current_wal_lsn()') printf 'primary_balance ' psql -X -v ON_ERROR_STOP=1 -qAt -c 'SELECT balance FROM account WHERE id=1' printf 'paused_replica_balance ' PGPORT=56433 psql -X -v ON_ERROR_STOP=1 -qAt -c 'SELECT balance FROM account WHERE id=1' for attempt in 1 2 3 4 5; do test "$(PGPORT=56433 psql -X -v ON_ERROR_STOP=1 -qAt -c "SELECT pg_last_wal_replay_lsn()>='$target'::pg_lsn")" = f sleep 0.1 done echo fresh_read_gate_timed_out PGPORT=56433 psql -X -v ON_ERROR_STOP=1 -qAt -c 'SELECT pg_wal_replay_resume()' >/dev/null attempt=0 while test "$(PGPORT=56433 psql -X -v ON_ERROR_STOP=1 -qAt -c "SELECT pg_last_wal_replay_lsn()>='$target'::pg_lsn")" != t; do attempt=$((attempt+1)) test "$attempt" -lt 150 || exit 1 sleep 0.1 done test "$(PGPORT=56433 psql -X -v ON_ERROR_STOP=1 -qAt -c 'SELECT balance FROM account WHERE id=1')" = 80 echo fresh_snapshot_after_replay_80 printf '%s\n' 'SELECT balance FROM account WHERE id=1;' 'COMMIT;' >&3 exec 3>&- reader_open=0 wait "$reader_pid" reader_pid='' test "$(cat "$lab/reader.log")" = "$(printf '100\n100')" echo old_repeatable_read_snapshot_still_100 printf 'lab_directory %s\n' "$lab" 初始余额是一百。备库上的可重复读事务先执行一次查询,再保持打开。脚本核对这个后端确实处于事务内空闲状态并持有快照,随后请求暂停日志回放,并等待实际暂停。主库把余额提交为八十,捕获提交之后的日志位置,作为这次读取需要达到的进度目标。 ...

七月 12, 2027 · 3 分钟 · XB Field Notes
三个独立映射状态块显示空集符号、NULL 与带引号 NULL,区分缺失、空值与文本。

hstore:在一格里装键值映射

hstore 扩展把文本键与文本值组成的扁平映射存进一个 PostgreSQL 值,其中值还可以为空。它适合不同记录拥有不同可选描述属性的情况,但灵活性不会免除重要键定义、值校验和按查询设计索引的工作。权限开关、价格和凭据都需要认真建模,不能因为装在一列里,就被当成没有后果的普通备注。 分清缺失、空值与文本 键不存在,与键存在但值为空,是两种状态。-> 对这两种情况都会返回 SQL NULL;? 用来确认键是否出现,defined 则检查它是否对应非空值。带引号的字符串“NULL”只是普通文本。整列本身还可以是 SQL NULL;如果不需要这个额外状态,应把映射列声明为 NOT NULL,再明确单个属性如何表示未知或缺失。 键不能是空值,键值对的顺序也没有语义。重复写同一个键不会生成列表,而只会留下其中一个值,且没有保证留下哪一个。需要确定性处理时,应在存储前拒绝重复键。应用应使用驱动支持的适配方式或参数化构造函数,不能把不可信字符串直接拼接成 hstore 字面量。驱动是否自动读写字典取决于实际支持和配置,不是任何语言环境都成立的承诺。 映射仍然可以有约束 CHECK 可以检查映射并限制指定属性,所以“数据库无法限制 hstore 内容”的说法不正确。但 SQL CHECK 在表达式结果为未知时也允许通过,单纯比较一个可能为空的提取结果,可能意外放行缺失键或空值。应明确出现条件、空值政策和允许值,再分别测试这些边界。需要时,提取键上的唯一表达式索引也可以约束重复值,并非只有普通列才能声明这种规则。 经常参与关联或业务约束的重要字段,仍然通常更适合独立的有类型列。理由是结构更清楚、操作更简单,而不是映射完全不能校验。增加一个可选键也许不需要改表,但改变键的含义仍可能需要历史数据检查、回填、客户端协调和索引调整。灵活性把这些决策移到另一处,并没有让迁移成本自然消失,更不能保证所有已有记录已经满足新含义。 对齐操作符与转换规则 GIN 和 GiST 支持 hstore 的包含与键存在操作符。提取文本再比较则属于另一种表达式,可能需要单独的 B-tree 表达式索引。给整个映射建 GIN,不意味着所有键值计算都自动有索引。应比较应用最终发出的 SQL、条件选择性和实际计划,再决定是否值得承担存储与写入开销;小表选择顺序扫描,也不能单独证明索引无效。 标准 hstore 到 jsonb 的转换把非空值保留为 JSON 字符串,把空值变成 JSON null,不会猜测文本“12”应该成为数字。反方向没有内置、对称的 jsonb 到 hstore 强制转换;任意嵌套 JSON 需要先定义允许的形状以及具体转换政策。不能把两个方向都描述成一条语句就能无损来回的免费迁移。存储大小也应由真实数据测量,不能承诺 hstore 总是更小。 ...

六月 16, 2027 · 2 分钟 · XB Field Notes