有界标签与请求标识形成的高基数标签,产生不同规模的直方图序列。

基数是指标的预算

一个很短的指标名,背后可能对应大量存储与查询成本。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
主库喂给一个承接查询的只读副本。

只读副本服务查询需求

只读副本允许一份数据库服务查询,另一份数据库处理写入。报表、搜索或其他适合的读取,可以由此离开写入节点,但把连接路由到某个地址只是设计的一部分。应用还必须说明返回结果需要包含哪些已经提交的变更。本文采用 PostgreSQL 物理热备库,它在正常恢复模式下可以读取,但不能接受普通业务写入。 坏掉的样子 异步复制下,成功写入之后立刻查询副本,仍然可能读到旧值。用户刚修改账户并刷新页面,之前的内容却又出现了。此时副本可能连接健康、日志接收持续推进,只是相关提交还没有被重放。如果新鲜度检查只看接收位置、不看回放位置,就检查了错误的完成边界。 即使回放已经推进,旧事务快照仍然可能影响结果。可重复读事务本来就需要在恢复继续进行时保留自己的视图。等待副本应用某次写入,不会顺便刷新这个事务。因此,要实现写后读到本次修改,既需要足够的回放进度,也需要查询使用在这个进度条件满足之后取得的新快照,两个条件缺一不可。 新鲜度承诺也需要限定范围。包含这个用户较早的一次写入,不等于保证其他地方不存在更新的事务。若读取余额是为了批准下一笔扣款,仍然必须在写入路径中进行事务性核对。副本提供的查询容量,不能替代保护业务动作的并发控制,也不能使一个只读结果天然变成后续写入的有效授权。 快速验证 在 Linux 环境中以非超级操作系统账户运行脚本,并准备 PostgreSQL 18 服务端和客户端程序。本次使用 PostgreSQL 18.6。脚本建立私有主备集群,通过 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
在一个字段里装一串带标签的键值对。

hstore:在一格里装键值映射

当模型不断长出松散的属性时,hstore 把答案放进同一行,而不是越连越长的关联查询链。每个产品迟早都会长出当初没人预料到的配置:按租户的开关、对接第三方的令牌、展示偏好、导入时顺手带进来的残留字段。每个属性都加一列,意味着无休止的迁移;每个属性都建一张表,意味着为鸡毛蒜皮反复关联。hstore 给了中间道路——用一列装下扁平的字符串映射,专门收留那些数量多、可选、单个都不重要的属性。 这笔交易条件写得很明白:拿类型安全和约束能力,换建模的敏捷。键和值都是文本,没有嵌套结构,数据库几乎无法对内容做什么强制。真正松散的元数据配这笔交易稳赚,需要参与业务推理的数据则稳亏。每个属性落在哪一边,分清楚就是全部的手艺。 一种类型、一列搞定 启用扩展后,一个 hstore 列在一个单元格里就能存许多键值对,比额外建表去镜像结构简单得多。用扩展启用语句打开功能,再声明该列类型即可。写入的字面量很自然,更新用拼接与删除操作符只动单个键,不必手工重写整个映射。多数驱动会把这一列直接读成字典,往返不需要专门的解析层。 从迁移成本看,这是最便宜的灵活性:一个扩展、一列、零新表,存量行不需要回填——缺席的键就是不存在,而不是以空值占位。下个季度冒出新属性,连结构变更语句都不用跑,写入端直接开始带这个键。只凭这一条,hstore 就配得上那些变化快、属性集不定型的场景,否则替代方案就是每个迭代一次迁移。 用操作符查询 用 hstore 的操作符做存在性与包含判断,并用通用倒排索引托底,让这些查询在映射变大时依然快。按某个具体键去过滤,就像一次轻量查表。? 检查键是否存在,-> 取出文本值,@> 匹配一组完整的键值;它们覆盖存在性、投影和包含,不必把映射拖到客户端拆开。GIN 直接支持存在性与包含查询;如果热点查询总是取同一个键再比较值,应为那个键加表达式索引,别假定通用 GIN 能回答所有取值条件。 查询要围绕整键访问设计,别对序列化文本动模式匹配的手术。用结构操作符取值的写法尊重了映射,把整列转成文本再模糊匹配的写法则亲手毁了它:既用不上索引,又会把根本不是键的子串也命中。这条规则可以推广:操作符尊重结构,类型转换摧毁结构。评审 hstore 查询先看有没有转文本,因为文本层面的匹配又慢又错,错的那些边界数据平时还看不出来。 更富的行用 JSONB hstore 只能存字符串,当键需要真实类型或更严格的引用时,JSONB 往往更适合更富的行。纯键值映射用 hstore,值需要结构化时选 JSONB。分界线穿过三个问题:值需不需要数字、布尔或嵌套(需要就选 JSONB);值需不需要约束或外键引用(两个都不合适,该规范化建表);是不是每个值都确实是自由文本且访问模式一致(hstore 以简单和更小的存储胜出)。 两者互相迁移都只是一次类型转换,所以选错可以反悔,不必纠结。形状扁平且不明朗时从 hstore 起步,第一个嵌套值出现的那季度升到 JSONB,某个键变成业务命脉的那季度升到正式列。阶梯是扁平映射、结构化文档、规范化表——确定性涨一分再往上爬一级,永远别提前。 坏掉的样子 经典故障是变成顶梁柱的字符串:当初当装饰存的键成了关键——价格档位、访问开关、路由规则——却还住在无类型无约束的文本里。拼写稍有出入就分裂出平行现实,排序按字典序而不是数值来,没有任何约束能拦住这两样。业务逻辑开始分支依赖它的那一刻,就该把它升成带检查的正式列;迁移很便宜,它消除的歧义一点也不便宜。 第二种故障是没建索引的膨胀:映射在各行累积出成千上万个互不相同的键,查询按键过滤却没有倒排索引,读取退化成全表扫描加逐行拆包。趁键空间还没爆炸先把索引建上,并定期审计查询真正碰过哪些键——从没被过滤过的键,赶回日志或对象存储,别占着索引列的位置。 快速验证 验证操作符与索引行为: SELECT 'a=>1, b=>2'::hstore -> 'a', 'a=>1, b=>2'::hstore ? 'c'; CREATE INDEX ON prefs USING GIN (attrs); EXPLAIN SELECT * FROM prefs WHERE attrs @> 'theme=>dark'; 第一条练习取值与存在性。包含查询在数据量和选择性足够时应使用 GIN;小表选择顺序扫描可能完全正确,所以要用接近生产的数据量比较计划。 ...

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