Postgres LSN十六进制转十进制:原理、SQL解法与排障实践
2026/9/24 20:13:25 网站建设 项目流程

一碰到 Postgres 的 LSN 十六进制转十进制这种事,很多人的第一反应就是打开计算器。上次处理一个压测环境的复制延迟告警,监控面板上主库的pg_current_wal_lsn()显示为1C/6B32E8F0,备库的pg_last_wal_replay_lsn()1C/5B3122A0,值班同事随手就找在线进制转换工具,想把两个都换成十进制再减。我跟他说,如果只是想看延迟,直接用pg_wal_lsn_diff()就行,一行 SQL 的事,根本不用人肉换算;但如果你要的是监控采集里的 WAL 字节数、要算每秒钟产生了多少 WAL、或者想把某个工具导出的十进制位置写回recovery_target_lsn,那就必须把这串十六进制的结构彻底吃透。

这篇文章就当一次排障笔记来写,不端着讲课的架子。下面要讲的六件事,都是这类换算里真正绕不开的点:LSN 为什么长成两段十六进制、手工怎么算、用 SQL 怎么写最稳、算完用在哪儿、我在实际操作中踩过的边界和坑,以及最后怎么快速验证你没算错。

1. LSN为什么长成"两段十六进制"的样子

1.1 本质上就是一个不停递增的64位计数器

Postgres 里每个 WAL 记录都有一个唯一的日志序列号 LSN,它的底层类型就是无符号 64 位整数。从这套 WAL 逻辑编号体系开始工作起,这个数就一直往上加,每次写入一个 WAL 记录,它就前进相应的字节数。你可以把它理解成一个"累计写入的 WAL 字节数",只不过这个计数器的起点不只是 0,而且它跨越了无数次服务器启停、日志段轮换,依旧在同一个逻辑空间里持续递增。

pg_lsn类型在输出时把这个 64 位整数拆成两半:左边是高 32 位,右边是低 32 位,各自用十六进制表示,前导零省略,中间用斜杠分隔。比如1C/6B32E8F0,左边的0x1C是高 32 位的值,右边的0x6B32E8F0是低 32 位的值。每一位十六进制代表 4 个二进制位,所以两边正好各占 8 位十六进制,看起来就像一段内存地址。

提示:LSN 不是内存地址,也不是某个真实文件里的绝对偏移。它是一个逻辑空间的字节位置,通过换算可以映射到 WAL 文件名和段内偏移。

为什么 PostgreSQL 不直接显示成十进制?两个原因。第一,这种 X/Y 格式跟 WAL 文件名的生成逻辑天然对应,DBA 扫一眼就能大概判断"这个位置落在哪个日志段附近";第二,64 位无符号数很快会变成很大的十进制数,反而不如十六进制紧凑。所以 PostgreSQL 从设计上就选了这种"两个 32 位十六进制段"作为人类可读的展示格式。

1.2 高位和低位之间隔了整整 2^32 个字节

把 64 位拆成两半之后,最容易搞混的就是:左边的数每增加 1,不代表右边滚了一圈 16MB,而是代表跨过了 2^32 个字节,也就是 4294967296 字节。默认 WAL 段大小是 16MB(2^24 字节),2^32 字节正好等于 256 个段。所以 LSN 从0/FFFFFF走到0/1000000时,只是跨过了 16MB 的边界,此刻左边还是 0;要一直到低 32 位写满0xFFFFFFFF,下一个位置才会进位成1/0。换句话说,左边高 32 位每加 1,WAL 已经累计往前推进了 4GB。

这个结构直接决定了 WAL 文件名的拼法。WAL 文件名是 24 个十六进制字符,由三段组成:8 位时间线号、8 位逻辑段号的高 32 位、8 位逻辑段号的低 32 位。逻辑段号本身等于"LSN 除以段大小"取整,段内偏移等于"LSN 对段大小取余"。理解了这一层,后面所有换算就都顺了——你真正要操作的对象是一个连续的 64 位字节计数器,而不是两个毫无关系的十六进制数。

2. 手工换算:拆开、乘以2的32次方、再加回去

2.1 核心公式:decimal = X × 4294967296 + Y

先把 LSN 字符串按斜杠拆成 X 和 Y 两个十六进制数,再把各自转成十进制,代入公式:LSN 的十进制表示 = X 的十进制值 × 4294967296 + Y 的十进制值。乘的这个 4294967296 就是 2^32,也就是高 32 位每跳 1 所代表的字节数。

1C/6B32E8F0来算一遍。左边0x1C等于十进制的 28;右边0x6B32E8F0按位权展开:

6×16^7 + 11×16^6 + 3×16^5 + 2×16^4 + 14×16^3 + 8×16^2 + 15×16 + 0 = 1610612736 + 184549376 + 3145728 + 131072 + 57344 + 2048 + 240 = 1798498544

于是完整十进制 LSN = 28 × 4294967296 + 1798498544 = 122057582832。

如果嫌逐位乘 16 麻烦,Windows 计算器的程序员模式、macOS 系统自带的计算器,甚至手机上的进制转换工具都能做。不过终端里最利索的方式是直接用 shell 内置功能:

printf '%d\n' 0x6B32E8F0

立刻得到 1798498544。低 32 位和完整 64 位都能这么转,只要 printf 的宿主 shell 支持 64 位整数运算。

我整理了一份常见 LSN 样例的换算对照表,方便你核对公式:

LSN(十六进制)高32位十进制低32位十进制完整十进制LSN段号段内偏移
0/FFFFFF01677721516777215016777215
0/16C26680238649362386493617087720
1C/6B32E8F028179849854412205758283272753336432
FFFFFFFF/FFFFFFFF4294967295429496729518446744073709551615109951162777516777215

这个表里最后一行是理论最大值,生产库现实中到不了这个量级,但它最能说明"为什么中间值必须用 numeric 而不是 bigint",后面第 5 节会展开讲。

2.2 反向:十进制还原成 LSN

反过来,要把十进制数还原成 X/Y 格式,就对这个数做除以 4294967296 的整除和取余:商就是 X,余数就是 Y。比如 122057582832 除以 4294967296,商是 28,余数是 1798498544;把 1798498544 写成十六进制得到6B32E8F0,于是还原为1C/6B32E8F0

这个反向过程很多时候比正向更实用。备份工具、运维平台导出的位置信息经常是十进制字节数,而 Postgres 的recovery_target_lsn参数只认1C/6B32E8F0这种格式,配置 PITR 时就得做这一步还原。手工做的话,用整除取余很快;要自动化的话,直接跳到下一节用 SQL 里的pg_lsn加法,一行就够。

2.3 顺手把 WAL 文件名和段内偏移也算出来

拿到完整十进制 LSN 之后,还能继续算 WAL 日志文件名,这在实际排障里价值很大。默认段大小 16MB,也就是 16777216 字节。逻辑段号 = 完整十进制 LSN ÷ 16777216 取整,段内偏移 = 完整十进制 LSN ÷ 16777216 取余。

对 122057582832 来说:122057582832 ÷ 16777216 = 7275 余 3336432。段号 7275 写成十六进制是0x1C6B,所以在时间线为 1 的情况下,WAL 文件名是000000010000000000001C6B,段内偏移 3336432 字节,也就是从段头开始约 3.18MB 的位置。

这个映射关系非常值得记住:当监控或日志里出现一个 LSN,你先用这个办法定位到具体 WAL 文件,然后直接去$PGDATA/pg_wal目录下找对应文件检查,比对着十六进制发懵高效得多。如果段大小不是 16MB,用完整十进制 LSN 除以实际的段大小字节数即可,公式在任何段大小下都成立。

3. SQL原生解法:让pg_lsn自己完成进位

3.1 最省事:用 pg_lsn 减法,一行搞定

如果能直接在数据库里操作,最省事的方式不是自己拆字符串,而是利用pg_lsn类型自带的减法运算符。pg_lsn减去pg_lsn得到 numeric,也就是两个位置之间相差的字节数:

SELECT pg_current_wal_lsn() - '0/0'::pg_lsn AS lsn_decimal; SELECT '1C/6B32E8F0'::pg_lsn - '0/0'::pg_lsn AS lsn_decimal;

第二条语句返回122057582832。它的原理就是拿目标 LSN 跟全零 LSN 做差,差值天然就是完整的十进制 LSN 大小。反过来,要把十进制变回 LSN,直接加回去:

SELECT '0/0'::pg_lsn + 122057582832 AS lsn_hex; -- 结果:1C/6B32E8F0

这是我在脚本里最常用的姿势。让 Postgres 自己处理高低位的进位和类型转换,出错概率最低。

3.2 通用解法:hex字符串用bit(32)::bigint强转

有些场景不能直接依赖pg_lsn类型,比如你从日志、监控系统拿到的是一个纯文本 LSN 字符串,想在一条 SQL 里就地完成换算。这时候可以用 Postgresbit类型的输入解析特性:字符串以x开头时,bit类型会按十六进制解析。配合lpad补足 8 位十六进制,再转成 bigint,就能拿到单半边 32 位的十进制值:

SELECT ('x' || lpad('1C', 8, '0'))::bit(32)::bigint AS high_dec, ('x' || lpad('6B32E8F0', 8, '0'))::bit(32)::bigint AS low_dec;

第一行返回 28,第二行返回 1798498544。组合成完整 LSN 时要注意:如果直接让 bigint 乘 4294967296,在 X 很大的情况下会溢出。所以乘法前先把它转成 numeric,这是稳妥写法:

WITH p AS ( SELECT lsn, split_part(lsn::text, '/', 1) AS high_hex, split_part(lsn::text, '/', 2) AS low_hex FROM (SELECT pg_current_wal_lsn() AS lsn) s ) SELECT (('x' || lpad(high_hex, 8, '0'))::bit(32)::bigint)::numeric * 4294967296::numeric + ('x' || lpad(low_hex, 8, '0'))::bit(32)::bigint::numeric AS lsn_decimal FROM p;

如果这套逻辑经常要用,建议封装成自定义函数,放进公共 schema:

CREATE OR REPLACE FUNCTION lsn_to_decimal(lsn pg_lsn) RETURNS numeric LANGUAGE sql IMMUTABLE AS $$ SELECT (('x' || lpad(split_part(lsn::text, '/', 1), 8, '0'))::bit(32)::bigint)::numeric * 4294967296::numeric + ('x' || lpad(split_part(lsn::text, '/', 2), 8, '0'))::bit(32)::bigint::numeric; $$;

再配一个反向函数decimal_to_lsn

CREATE OR REPLACE FUNCTION decimal_to_lsn(v numeric) RETURNS pg_lsn LANGUAGE sql IMMUTABLE AS $$ SELECT '0/0'::pg_lsn + v; $$;

两个函数配合使用,decimal_to_lsn(lsn_to_decimal('1C/6B32E8F0'::pg_lsn))可以原样还原。

3.3 先别急着换算:有些场景根本不需要十进制

很多场景下 Postgres 已经帮你把换算做好了。比较两个 LSN 直接可以用比较运算符,因为pg_lsn类型内部就是按 64 位整数比较的;计算复制延迟用pg_wal_lsn_diff();要知道某个 LSN 在哪个 WAL 文件、文件里偏移多少,用pg_walfile_name_offset()。这些函数返回的都是语义化结果,比你手动转十进制再自己算要可靠得多。

我自己定位问题时的顺序是:先看pg_wal_lsn_diff()能不能满足需求,再看pg_walfile_name_offset(),最后才考虑自己换算。换算只用于那些"必须把 LSN 变成纯数值"的场景,比如监控采集、跨系统传输、二次计算。

4. 换算成十进制之后,最常落地的三个场景

4.1 监控:把两次采样差变成WAL字节数

数据库监控里最常见的需求是算"每秒钟产生了多少 WAL"。大多数监控采集器拿到pg_current_wal_lsn()时,它是个文本值,不能直接求导。把每次采样换算成十进制后落库,两次采样值的差就是这段时间写入的 WAL 字节数,除以时间间隔就是速率。

举个具体例子。假设 10 分钟内采集到两个 LSN:

  • t1:1C/6B32E8F0→ 122057582832
  • t2:1C/7B42F398→ 122327069592

差值是 269486760 字节,约 257MB。除以 600 秒,得到每秒约 0.43MB 的 WAL 产生速率。这个数字可以直接喂给 Prometheus 画曲线,也可以写成告警规则,比如"WAL 产生速率超过 X MB/s 时报警"。我见过不少团队用这个方法做"日志风暴"告警——WAL 速率突然飙高,往往意味着有大批量更新或者被遗忘的循环写任务在跑。

采集 SQL 可以直接复用 3.2 节里的 CTE 写法,集成到 postgres_exporter 的自定义查询里即可。注意采样落库时用 numeric 类型,不要用 float,原因在第 5 节讲。

4.2 复制延迟:备库落后多少字节

复制延迟的标准算法本来就应该用pg_wal_lsn_diff(),一条 SQL 精确到字节。在备库上执行:

SELECT pg_wal_lsn_diff(pg_last_wal_receive_lsn(), pg_last_wal_replay_lsn()) AS receive_replay_lag_bytes;

但如果你的监控系统里只存了两条文本 LSN,或者你想在事后复盘时用历史数据重新计算,那就只能先把两个 LSN 都转成十进制再相减。这里有个细节:备库上别用pg_current_wal_lsn(),它反映的是本地 WAL 状态,容易误导;备库该用的是pg_last_wal_receive_lsn()(接收到的位置)和pg_last_wal_replay_lsn()(回放到的位置)。这两个函数返回的都是pg_lsn,可以直接做差。

用十进制换算还有一个附加价值:字节数可以跟网络传输速率、磁盘 IO 对账。比如备库接收位置落后主库 800MB,按当前网络吞吐估算恢复时间,这些都需要字节粒度,而不是只看一个"看起来差不多的十六进制数"。

4.3 PITR/归档:把十进制位置写回 recovery_target_lsn

recovery_target_lsn参数只接受1C/6B32E8F0这种格式。如果你从备份元数据或某个运维平台拿到的是十进制位置,就用反向转换还原成 LSN 再配置:

psql -XAt -c "SELECT '0/0'::pg_lsn + 122057582832;"

输出1C/6B32E8F0,直接填进 recovery.conf 或 postgresql.conf 的recovery_target_lsn。另外pg_controldata输出的Latest checkpoint locationpg_basebackup的起始位置,都是这类 LSN,用同样思路做归档对账:把某两个备份点的 LSN 差除以段大小,就能估算两个备份之间需要保留多少个 WAL 段,这对存储规划很有用。

LSN差(字节)÷ 段大小 + 1 = 中间跨越的WAL段数量

这个公式在规划归档保留策略时比任何估算都准。

5. 最容易翻车的几个边界点

5.1 bigint放不下整个LSN,要用numeric

LSN 是 64 位无符号整数,理论最大值FFFFFFFF/FFFFFFFF换算成十进制是 18446744073709551615,而 PostgreSQL 的 bigint 最大只有 9223372036854775807,连一半都不到。所以任何"把整个 LSN 塞进 bigint 再计算"的写法都有溢出隐患。尤其是我前面强调过的组合写法,X 一旦超过 0x80000000,X × 2^32就爆掉 bigint。

正确做法是全程用 numeric。目前生产库的 LSN 远没到溢出阈值,但写函数和监控脚本时养成 numeric 习惯,能把隐患消灭在设计阶段。顺便说一句,pg_wal_lsn_diff()pg_lsn减法的返回值设计成 numeric,不是为了装高精度小数,正是为了绕过这个容量问题。

另一个隐藏坑是 float 精度。如果你在 Python 里用 float 存 LSN 十进制值,float64 只有 53 位有效数字,无法精确表示 64 位整数,采样多了误差会累积。JSON 接口传大整数也要注意,很多语言的 JSON 解析器会把它转成 float。跨系统传递 LSN 十进制值时,一律用字符串或数值字符串。

5.2 版本差异:xlog改名为wal,函数别用老了

PostgreSQL 10 之前,WAL 相关函数叫 xlog,函数名里全是 xlog;10 之后统一改成 wal。如果你还在维护老版本,或者在一堆旧脚本里扒代码,这个对照表很实用:

PostgreSQL 9.4 ~ 9.6PostgreSQL 10+
pg_current_xlog_location()pg_current_wal_lsn()
pg_xlogfile_name(lsn)pg_walfile_name(lsn)
pg_xlogfile_name_offset(lsn)pg_walfile_name_offset(lsn)
pg_xlog_location_diff(a, b)pg_wal_lsn_diff(a, b)

另外 PG13 开始多了pg_current_wal_insert_lsn(),返回的是插入位置;pg_current_wal_lsn()返回的是刷盘位置。算 WAL 产生速率时,如果写入很频繁,两者会有细微差别。选一个口径并保持一致就行,别在同一个报表里混用两个函数。

5.3 wal_segment_size 未必是 16MB

默认 16MB 在绝大多数环境里成立,但 PG11 之后initdb支持用--wal-segsize指定 1MB、4MB、16MB、64MB、256MB。一旦段大小不是 16MB,我前面提过的"高位换算文件号"那套位运算就不成立。但好消息是:只要你不是死记位运算,而是用完整十进制 LSN 直接除以实际段大小的字节数,公式在任何段大小下都成立。

判断当前实例的段大小:

SHOW wal_segment_size;

拿到的是类似16MB的字符串,写脚本时记得转成字节数再去除。处理历史备份时也要注意:不同实例可能用不同段大小初始化,从旧备份恢复前先确认目标实例的段大小,否则算出的文件号对不上。

5.4 lpad和bit强转的细节坑

('x' || hex)::bit(32)::bigint这个技巧时,我踩过两次坑。第一次是忘了 lpad:左侧高 32 位在很多真实 LSN 里只有一两位十六进制,比如0/16C2668,如果直接对'0'::bit(32),会因为位数不够而报错。所以不管哪半边,都先用lpad补足 8 个十六进制字符。

第二次是输入来源不干净。如果 LSN 字符串来自外部系统,可能带0x前缀、带空格、大小写混用,甚至因为 JSON 解析被截断。进 SQL 之前最好统一做一次trimreplace('0x','')等清洗。大小写其实没问题,bit 类型的十六进制解析大小写都认,但前缀和空格一定要处理干净。

6. 花一分钟验证换算结果(附shell/Python速转)

6.1 往返验证法

最靠谱的验证方式是"去又回"。算完十进制之后,马上用反向加法还原成pg_lsn,看是否等于原始值。拿第 3 节的两个自定义函数:

SELECT lsn_to_decimal('1C/6B32E8F0'::pg_lsn) AS dec, decimal_to_lsn(lsn_to_decimal('1C/6B32E8F0'::pg_lsn)) AS back, lsn_to_decimal('1C/6B32E8F0'::pg_lsn) = 122057582832 AS ok;

如果ok列是t,说明函数逻辑和手工计算全部对上。任何换算逻辑上线前,先用几个已知 LSN 做一遍往返验证,能拦下绝大多数低级错误。

6.2 用pg_walfile_name_offset和磁盘文件对拍

另一个更贴近实际的验证,是看换算结果跟 Postgres 官方函数是否一致。把同一个 LSN 交给pg_walfile_name_offset(),让它返回文件名和偏移,再跟你自己算的 7275 号和 3336432 偏移对比:

SELECT * FROM pg_walfile_name_offset('1C/6B32E8F0'::pg_lsn);

返回(000000010000000000001C6B, 3336432)。然后去数据目录看一眼这个文件的大小是不是 16777216 字节:

ls -l $PGDATA/pg_wal/000000010000000000001C6B

再往前一步,用十六进制查看器或者xxd读一下文件头部,确认不是空文件、没被截断:

xxd -l 64 $PGDATA/pg_wal/000000010000000000001C6B

WAL 文件头部有固定的魔数字段,HxD 这类编辑器也能直接打开看。这一套对拍做完,基本可以放心把换算逻辑写进任何脚本。

6.3 终端里秒换算

日常排查时我一般直接在终端处理,没必要开数据库。三个顺手命令分享一下:

# 低32位十六进制转十进制(bash内置printf) printf '%d\n' 0x6B32E8F0 # 任意完整LSN转十进制(Python) python3 -c "x,y='1C/6B32E8F0'.split('/'); print(int(x,16)*2**32 + int(y,16))" # 有psql环境就直接用类型自带的减法 psql -XAt -c "SELECT '1C/6B32E8F0'::pg_lsn - '0/0'::pg_lsn;"

三个输出的结果应该完全一样:122057582832。这也是我最推荐的验证思路——不同工具算同一个数,结果一致才是真的正确。

最后分享一点个人体会:不要在脑子里死记 LSN 和 WAL 文件号之间的位运算,更不要写死 16MB。把"LSN 是一个 64 位无符号字节计数器"这个认知建立起来,所有换算都围绕十进制字节数展开,遇到任何段大小、任何版本、任何函数名都能快速推导。真到了要算偏移的时候,先试试 Postgres 自带的pg_walfile_name_offset()能不能直接满足需求——能,就别自己造轮子。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询