Debezium日常分享系列之:深入解析SQL Server事务日志
Debezium日常分享系列之:深入解析SQL Server事务日志
- 准备 SQL Server
- 配置 SQL Server 容器
- 在虚拟机中安装 SQL Server
- 创建测试数据库
- SQL Server 事务日志内部机制
- 虚拟日志文件 (VLFs)
- VLF 偏移量与状态管理
- 块、记录与 LSN
- SQL Server 数据结构
- 分区与分配单元
- 页:基本存储单元
- 探索表与页结构
- 使用DBCC IND探索数据页
- 使用 DBCC PAGE 读取页面
- 结论
SQL Server 通过 变更表 提供变更数据捕获(CDC)功能——这是一组特殊的系统表,用于记录选定“普通”表中的数据修改。若需实时监控变更,需定期查询这些变更表。这正是 Debezium 目前的工作方式:它以配置的间隔轮询 SQL Server 的变更表,并将结果转换为连续的 CDC 记录流。这种方式效果良好,但我们能否更进一步?
捕获的表由 SQL Server Agent 填充,该代理读取事务日志、提取变更,并将其存储到变更表中。理论上,我们可以绕过中间环节,直接解析事务日志。类似 OpenLogReplicator 的工具正是以这种方式处理 Oracle 数据库的 CDC。让我们深入 SQL Server 内部,探索其工作原理及记录存储方式。
在本文中,我们将:
- 准备一个本地 SQL Server 实例用于实验
- 探究 SQL Server 事务日志的内部结构
- 理解记录如何存储在磁盘上
准备 SQL Server
我们将本地部署 SQL Server,以便深入分析其日志文件。您有两种主要选择:Docker 容器或虚拟机安装。以下分别介绍这两种方法。
配置 SQL Server 容器
使用容器非常简单,只需启动即可:
docker run -it --rm --name sqlserver \-e "ACCEPT_EULA=Y" \-e "MSSQL_SA_PASSWORD=Password!" \-p 1433:1433 mcr.microsoft.com/mssql/server:2022-latest
当容器运行后,您可以直接从宿主机连接,或附加到容器内部使用内置的 mssql-tools 工具。
docker exec -it sqlserver /bin/bash
/opt/mssql-tools18/bin/sqlcmd -S localhost -U SA -P Password! -C -No
在虚拟机中安装 SQL Server
请按照适用于 RHEL 或 Fedora 的 官方指南 进行操作。添加适当的软件仓库、安装及配置服务器的步骤均简单明了:
curl -o /etc/yum.repos.d/mssql-server.repo https://packages.microsoft.com/config/rhel/9/mssql-server-2022.repo
dnf install -y mssql-server
/opt/mssql/bin/mssql-conf setup
您可能还需要安装 SQL Server 客户端工具:
curl -o /etc/yum.repos.d/mssql-release.repo https://packages.microsoft.com/config/rhel/9/prod.repo
dnf install -y mssql-tools18 unixODBC-devel
并本地连接:
/opt/mssql-tools18/bin/sqlcmd -S localhost -U SA -P Password! -C -No
如果要从虚拟机外部连接,请打开端口 1433:
firewall-cmd --zone=public --add-port=1433/tcp --permanent
firewall-cmd --reload
创建测试数据库
服务器启动并运行后,我们可以在其中创建测试数据库和表:
CREATE DATABASE TestDB;
GOUSE TestDB;CREATE TABLE products (id INT PRIMARY KEY,name NVARCHAR(255) NOT NULL,description NVARCHAR(512),weight FLOAT
);INSERT INTO products VALUES(1,'scooter','Small 2-wheel scooter',3.14),(2,'car battery','12V car battery',8.1),(3,'12-pack drill bits','12-pack of drill bits with sizes ranging from #40 to #3',0.8);
GO
SQL Server 事务日志内部机制
在 SQL Server 中,所有持久性变更(包括插入、更新、删除或架构变更)均会被写入事务日志。该日志确保在系统崩溃时能够恢复事务。默认情况下,数据库文件存储在 /var/opt/mssql/ 目录中。事务日志文件以 .ldf 为扩展名,存放在 /var/opt/mssql/data 路径下。该目录还包含 *.mdf 文件——即存储数据本身和架构信息的主数据文件。例如,我们的示例数据库文件为 TestDB.mdf 和 TestDB_log.ldf。
若因故无法在此路径找到相关文件,可通过运行以下查询直接从 SQL Server 获取物理文件位置:
SELECT name, physical_name
FROM sys.master_files
WHERE database_id = DB_ID('TestDB');
GO
SQL 服务器应该给出如下结果:
name physical_name
------------------------------ ------------------------------
TestDB /var/opt/mssql/data/TestDB.mdf
TestDB_log /var/opt/mssql/data/TestDB_log
虚拟日志文件 (VLFs)
事务日志并非一个庞大的单一空间,而是被划分为多个虚拟日志文件 (VLFs)。SQL Server 根据日志大小决定创建多少 VLFs。您可以通过调用 sys.dm_db_log_info 存储过程,获取 VLF 的序列号及其他数据库相关信息:
SELECT file_id, vlf_sequence_number, vlf_begin_offset, vlf_size_mb
FROM sys.dm_db_log_info(DB_ID('TestDB'));
以下是一个示例输出,包含 VLF 序列号(每个文件唯一)、起始偏移量(在文件中的起始位置)以及大小(以 MB 为单位):
file_id vlf_sequence_number vlf_begin_offset vlf_size_mb
------- -------------------- -------------------- ------------------------2 42 8192 1.92999999999999992 0 2039808 1.92999999999999992 0 4071424 1.92999999999999992 0 6103040 2.1699999999999999
VLF 偏移量与状态管理
除了 VLF 序列号外,另一个关键点是 VLF 偏移量。实际上,第一个 VLF 并非从日志文件的起始位置开始,而是位于文件开头的 8192 字节(8KB)之后——这部分空间被保留为日志文件头。
VLF 文件头中记录了该 VLF 是否处于活动状态。VLF 以循环方式使用,未活动的 VLF 可被新创建的 VLF 复用。但若 VLF 处于活动状态,则无法复用。这种情况通常发生在以下场景:
- 包含未提交事务的记录
- 记录尚未归档或未被系统其他组件处理(如未复制到变更表、未同步至其他服务器等)
VLF 从活动状态转为非活动状态的过程称为日志截断(log truncation)。
VLF 复用的具体时机和规则还取决于数据库的恢复模式:
- SIMPLE 恢复模式:检查点(checkpoint)操作后,VLF 最终会被复用。
- FULL 恢复模式:检查点操作不会导致事务日志截断。
您可以通过以下 SQL 命令查询数据库当前的恢复模式:
SELECT name, recovery_model,recovery_model_desc
FROM sys.databases
WHERE name = N'TestDB';
GO
磁盘上 VLF 的物理结构与上述列表中的 VLF 行相同。这也可以从 VLF 偏移量 vlf_begin_offset 中看出。但是,VLF 的逻辑结构可能有所不同。VLF 的顺序由 vlf_sequence_number 决定。0 表示 VLS 尚未使用。
使用 DBCC 工具运行以下命令可以获得与上述查询非常相似的输出:
DBCC LOGINFO('TestDB')
本文后面将会提到有关 DBCC 实用程序的更多信息。
块、记录与 LSN
每个 VLF(虚拟日志文件)被划分为块(blocks)——这是日志中最小的物理写入单元。块的大小范围从 512 字节到 60 KB,以 512 字节为增量逐步增长,直至达到最大尺寸。块是实际写入磁盘的单位,通常在事务提交(commit)或检查点(checkpoint)时执行写入。
块中存储的是实际的事务日志记录(log records)。日志记录是对数据库执行的原子性变更操作,例如插入(insert)、更新(update)、事务提交(TX commit)等。这些记录在块中的存储顺序与它们在数据库中的执行顺序一致,因此同一个块内可能混杂不同事务的记录。此外,当块被写入磁盘时,其中可能包含未提交事务的记录。
SQL Server 事务日志文件的整体结构如下图所示:
块及其内部记录均被分配了唯一的序列号:
- 块序列号:长度为 4 字节
- 记录序列号:长度为 2 字节
- 若将这两者与 VLF 序列号结合,即可通过三元组(VLF序列号 + 块序列号 + 记录序列号)唯一标识每条记录。
<VLF sequence number>:<block sequence number>:<record sequence number>
这一唯一记录标识符被称为日志序列号(Log Sequence Number),通常简称为 LSN。Debezium 正是利用此编号来存储偏移量(offsets)——它记录了 Debezium 已处理的最新变更以及最新提交的事务。
SQL Server 数据结构
在前一章中,我们探讨了 SQL Server 事务日志的结构。如前所述,事务日志会记录针对数据库执行的所有操作。但由于日志最终可能被截断,其包含的信息可能会丢失。为确保数据持久性和性能,实际数据及相关元数据(如索引)会永久存储在磁盘上。下面我们详细分析 SQL Server 如何组织这些数据。
分区与分配单元
每个表或索引存储在一个或多个分区(partition)中,单个表或索引最多可包含 15,000 个分区。存储于单个分区中的表或索引子集称为 HOBT(Heap 或 B-tree 的缩写)。
- 堆(Heap):指无聚集索引的表,其数据未按索引结构组织。
- 聚集索引表:数据按索引结构组织,实际存储的是索引行。
分区包含存储实际数据行的数据页(data pages),主要分为三类:
- 行内数据页(In-row data pages):存储固定长度数据或可单页容纳的数据。
- 行溢出数据页(Row-overflow data pages):当变长数据类型(如 varchar、nvarchar)超出单页容量时存储于此。
- LOB 数据页(LOB data pages):存储大型对象(如 XML 或二进制数据)。
同一分区中类型相同的数据页会被归类到分配单元(allocation units)中。
页:基本存储单元
在 SQL Server 中,页(Page) 是最基础的存储单元,存储于前文提到的 .mdf 文件中。每个 .mdf 文件被划分为 8 KB 的块,每个块即为一页。
页的结构
- 页头(Header)占用 96 字节,存储元数据,包括:
- 页号(Page number)
- 页类型(Page type)
- 页内剩余空间(Free space)
- 所属分配单元(Allocation unit)的编号等
- 数据行(Data Rows)
- 页头之后存储实际数据行。
- 每行在页中的位置由 行偏移量(Row Offset) 决定:
- 行偏移量为 2 字节,表示该行起始位置距离页开头的字节数。
- 所有偏移量以逆序存储在页末尾,形成行偏移数组(Row Offset Array)。
设计优势
- 这种设计使 SQL Server 能够高效地:
- 查找、插入或移动页内行数据
- 无需重写整个页
页的具体结构如下图所示:
变长数据和大对象(LOB)通常无法完整存储在一个页面中,且由于行数据不能跨页存储,这些大型值会被存放在行内数据分配单元之外。它们会被置于由行溢出或LOB分配单元管理的页面中。
页面本身会被分组为所谓的"区段"(extents),这是SQL Server管理磁盘空间的基本单位。每个区段由8个页面组成,总计64KB。区段分为两种类型:统一区段(所有8个页面属于同一对象)和混合区段(页面可能属于不同对象)。SQL Server通过特殊的分配映射来跟踪区段使用情况:全局分配映射(GAM)记录区段是否已分配,共享全局分配映射(SGAM)则标识仍包含空闲页面的混合区段。
关于这些机制的更深入探讨超出了本文范围,您可以通过官方页面与区段架构指南了解更多细节。
探索表与页结构
从实际应用角度,可以通过系统视图 sys.indexes、sys.tables 和 sys.columns 查询索引、表及列的信息。分区与分配单元的详细信息则存储在 sys.partitions、sys.allocation_units 以及 sys.system_internals_allocation_units 中。这些视图可通过 OBJECT_ID 函数关联查询。例如,若要统计 products 表包含的分区数量,可执行以下查询:
SELECT object_id, partition_id, partition_number, hobt_id
FROM sys.partitions
WHERE object_id = OBJECT_ID(N'products');
输出可能如下所示:
object_id partition_id partition_number hobt_id
----------- -------------------- ---------------- --------------------901578250 72057594045726720 1 72057594045726720
然后可以使用生成的partition_id来获取有关此表的分配的信息:
SELECT *
FROM sys.allocation_units
WHERE container_id = (SELECT partition_idFROM sys.partitionsWHERE object_id = OBJECT_ID(N'products')
);
查询给出了以下结果:
allocation_unit_id type type_desc container_id data_space_id total_pages used_pages data_pages
-------------------- ---- ------------------------------------------------------------ -------------------- ------------- -------------------- -------------------- --------------------72057594052476928 1 IN_ROW_DATA 72057594045726720 1 9 2 1
通过关联这些系统视图,我们可以收集关于表和索引结构的详细信息。但需要注意的是,这些视图不会直接暴露实际存储数据的页面内容。
使用DBCC IND探索数据页
SQL Server提供了DBCC工具集来检查单个数据页的结构和内容。虽然其中许多功能未被官方文档记录,但在社区资源中有详细描述。
DBCC IND ( [ database_name | database_id ], table_name, index_id )
index_id 可以从 sys.indexes 中获取,或者 -1 显示所有索引和 IAM(索引分配图),-2 仅显示 IAM。
对于我们的产品表:
DBCC IND ('TestDB', 'products', -1)
结果
PageFID PagePID IAMFID IAMPID ObjectID IndexID PartitionNumber PartitionID iam_chain_type PageType IndexLevel NextPageFID NextPagePID PrevPageFID PrevPagePID
------- ----------- ------ ----------- ----------- ----------- --------------- -------------------- -------------------- -------- ---------- ----------- ----------- ----------- -----------1 231 NULL NULL 901578250 1 1 72057594045726720 In-row data 10 NULL 0 0 0 01 336 1 231 901578250 1 1 72057594045726720 In-row data 1 0 0 0 0 0
第一条记录代表 IAM 页,而第二条记录对应于包含表行的实际数据页。这里的关键值是 PageFID 和 PagePID,我们将在下一个命令 DBCC PAGE 中使用它们。
使用 DBCC PAGE 读取页面
DBCC 实用程序的另一个未记录的函数是 DBCC PAGE,它会转储所请求页面的内容。在运行 DBCC PAGE 之前,必须启用跟踪标志 3604 才能将输出发送到客户端:
DBCC TRACEON(3604);
DBCC PAGE 命令的语法是
DBCC PAGE ( [ database_name | database_id ], file_number, page_number, print_option )
其中 file_number 和 page_number 分别是 DBCC IND 中的 PageFID 和 PagePID。print_option 控制详细级别,其值可以是 0-3。添加 WITH TABLERESULTS 可将输出格式化为表格形式。
对于我们的数据页表:
DBCC PAGE ('TestDB', 1, 336, 0)
我们得到
PAGE: (1:336)BUFFER:BUF @0x00000009000FDA40bpage = 0x000000101A8B0000 bPmmpage = 0x0000000000000000 bsort_r_nextbP = 0x0000000000000000bsort_r_prevbP = 0x0000000000000000 bhash = 0x0000000000000000 bpageno = (1:336)bpart = 4 bstat = 0x10b breferences = 0berrcode = 0 bUse1 = 22094 bstat2 = 0x0blog = 0x1cc bsampleCount = 0 bIoCount = 0resPoolId = 0 bcputicks = 0 bReadMicroSec = 294bDirtyPendingCount = 0 bDirtyContext = 0x000000100C4947A0 bDbPageBroker = 0x0000000000000000bdbid = 5 bpru = 0x00000010047A0040PAGE HEADER:Page @0x000000101A8B0000m_pageId = (1:336) m_headerVersion = 1 m_type = 1m_typeFlagBits = 0x0 m_level = 0 m_flagBits = 0x8000m_objId (AllocUnitId.idObj) = 222 m_indexId (AllocUnitId.idInd) = 256 Metadata: AllocUnitId = 72057594052476928Metadata: PartitionId = 72057594045726720 Metadata: IndexId = 1Metadata: ObjectId = 901578250 m_prevPage = (0:0) m_nextPage = (0:0)pminlen = 16 m_slotCnt = 3 m_freeCnt = 7761m_freeData = 425 m_reservedCnt = 0 m_lsn = (42:208:29)m_xactReserved = 0 m_xdesId = (0:0) m_ghostRecCnt = 0m_tornBits = 0 DB Frag ID = 1Allocation StatusGAM (1:2) = ALLOCATED SGAM (1:3) = NOT ALLOCATED PFS (1:1) = 0x40 ALLOCATED 0_PCT_FULLDIFF (1:6) = CHANGED ML (1:7) = NOT MIN_LOGGED
页头部分包含一些有用的元数据,例如:
- m_lsn - 此页面最后一次更改的日志序列号,在恢复过程中非常有用。
- m_slotCnt - 页面上的行数(槽位)
- pminlen - 固定长度数据的大小
- m_freeData - 可用空间的起始偏移量
- m_prevPage / m_nextPage - 指向相邻页面的指针(此处为空,因为数据量太小,无法跨越多个页面)
将 print_option 设置为 1 或 3 后,DBCC PAGE 还会在 DATA 部分显示行内容的字节表示。您可以轻松识别其中的变量字符串列。为简洁起见,此处仅显示第一行:
Slot 0, Offset 0x60, Length 81, DumpStyle BYTERecord Type = PRIMARY_RECORD Record Attributes = NULL_BITMAP VARIABLE_COLUMNSRecord Size = 81
Memory Dump @0x000000096A0260600000000000000000: 30001000 01000000 1f85eb51 b81e0940 04000002 0........ëQ¸. @....0000000000000014: 00270051 00730063 006f006f 00740065 00720053 .'.Q.s.c.o.o.t.e.r.S0000000000000028: 006d0061 006c006c 00200032 002d0077 00680065 .m.a.l.l. .2.-.w.h.e000000000000003C: 0065006c 00200073 0063006f 006f0074 00650072 .e.l. .s.c.o.o.t.e.r0000000000000050: 00
在输出的末尾,您还可以看到偏移图,以与磁盘上写入的相同(相反)的顺序转储:
OFFSET TABLE:Row - Offset2 (0x2) - 254 (0xfe)1 (0x1) - 177 (0xb1)0 (0x0) - 96 (0x60)
结论
在这篇博文中,我们探讨了 SQL Server 事务日志的基本结构,并研究了表在磁盘上的物理存储方式。从安装 SQL Server 到转储数据页内容,这些实践示例进一步完善了理论知识。