SQL Server误删恢复:事务日志解析与ApexSQL Log回放

发布时间:2026/10/30 16:45:18
SQL Server误删恢复:事务日志解析与ApexSQL Log回放

简介:ApexSQL Log 误删数据库还原破解版面向数据库管理员、运维工程师以及需要处理误删数据的开发人员,主要解决数据库误操作后的日志排查与恢复辅助问题。它可读取并分析多种数据库日志文件,描述中标注亲测在 SQL Server 2008 环境可用,适合用于误删记录定位、事务日志查看、恢复流程演练以及故障复盘等场景。压缩包采用 zip 格式,整体约 26.11MB,页面未提供明确的文件总数与类型明细,实际内容需以下载后的目录为准。当前已有 609 人学习或下载,具备一定关注度。对需要补充日志恢复思路、了解误删数据找回手段的读者,这份工具包可作为参考素材和实操入口;由于不同数据库版本的日志结构差异较大,建议先在测试环境中验证恢复效果并做好数据备份。

1. ApexSQL Log 误删还原:一次没有 WHERE 的 DELETE 之后,日志里还剩什么

上周三下午四点,运维群里弹出一条消息:生产库一张 27 万行的订单表被没有 WHERE 条件的 DELETE 清空了,最近一次完整备份是六小时前。这种时候被翻出来的工具通常就是 ApexSQL Log——它直接读 SQL Server 的事务日志和日志备份文件,把 DELETE、UPDATE 的原始行值还原成可回放的脚本,也就是圈内说的“误删数据库还原”。但它的能力边界和数据库恢复模式强绑定:SIMPLE 模式下日志早被 checkpoint 截断,工具再强也读不出东西。这篇写给两类人:已经出了事正在抢救的,和想提前把恢复链搭起来、别等出事再翻车的。

2. 事务日志的三层结构:VLF、LSN 和 LOP 记录怎么对上一条被删的行

在打开任何工具之前,得先确认一件事:你要找的那条 DELETE 记录,到底还在不在磁盘上。这决定了后面是走“日志解析还原单表”,还是老老实实做“时间点整体还原”。很多人跳过这一步直接连工具,结果界面上干干净净,然后开始怀疑工具是不是坏了——其实日志早就被日志重用机制冲掉了。

2.1 恢复模式决定你有没有事后翻账的资格

三种恢复模式对误删场景的意义完全不同。SIMPLE 模式下,每次 checkpoint 之后日志的 inactive 部分就可以被重用,日志文件循环覆盖,误删记录能存活多久取决于日志文件大小和业务写入量,可能是几小时,也可能是几分钟。FULL 模式下日志一直保留到下一次日志备份,只要备份链没断,DELETE 记录就稳稳躺在里面。BULK_LOGGED 是个中间态,普通 DML 照常记全日志,但大容量导入类操作走最小日志,还原时精度会打折。

先跑一句确认现状,重点看log_reuse_wait_desc:

SELECT name, recovery_model_desc, log_reuse_wait_desc, log_reuse_wait FROM sys.databases WHERE name = 'OrderDB';

recovery_model_desc是恢复模式,log_reuse_wait_desc告诉你日志为什么还不能被重用。如果返回LOG_BACKUP,意思是“我在等日志备份”,这时日志内容完整,皆大欢喜;返回NOTHING就要警惕了,说明当前没有任何因素阻止日志重用,inactive 的 VLF 随时可能被覆盖。这时候第一优先级不是分析,是立刻做一次日志备份把内容固化下来。

顺便提一句,如果库本身就是 SIMPLE 模式,且距离误删已经过去几个小时、写入量又不小,别抱幻想,直接从备份链里挑一个最近的完整备份做还原,然后靠业务侧补数据。硬啃日志只会浪费时间。

2.2 VLF 与 LSN:断链就是翻车现场

事务日志文件在物理上被切成一个个 VLF(虚拟日志文件),状态只有两种有用:活动和非活动。查一下当前布局:

USE [OrderDB]; GO DBCC LOGINFO;

结果里Status = 2的 VLF 是活动部分,里面装着尚未提交或尚未备份的事务日志。FSeqNo是 VLF 的序号,和 LSN 的第一段是对应的。一个日志文件里如果有几十上百个 VLF,说明日志增长得比较碎,这在频繁做小事务的库上很常见,对恢复本身没影响,但会让日志备份文件变得零散。

LSN 是三段式结构,形如0000002f:00000128:0002,依次是 VLF 序号、日志块偏移、槽位号。它单调递增,是整个恢复链的坐标。日志备份能不能接上,判断依据就是备份文件头里的 First LSN / Last LSN 能不能和上一个文件首尾相接。查备份历史:

SELECT database_name, backup_start_date, backup_finish_date, first_lsn, last_lsn, checkpoint_lsn, type FROM msdb.dbo.backupset WHERE database_name = 'OrderDB' ORDER BY backup_finish_date DESC;

type里 D 是完整备份、I 是差异、L 是日志。把first_lsn和前一行的last_lsn对一遍,能接上才算一条完整的链。断在中间的话,后面所有日志备份都无法还原,但注意:无法还原不等于无法解析,ApexSQL Log 读单个日志备份文件做分析时,对链的完整性要求比 RESTORE 宽松得多,这个差别在抢救时很关键。

2.3 用 fn_dblog 亲手摸一遍 DELETE 的痕迹

不装任何工具也能看到日志内容,SQL Server 自带的未公开函数fn_dblog就够用。第一个参数是起始 LSN,第二个是结束 LSN,都传 NULL 表示读当前活动日志全部:

SELECT [Current LSN], [Transaction ID], Operation, Context, AllocUnitName, [Page ID], [Slot ID], [Begin Time], [End Time], [RowLog Contents 0], [RowLog Contents 1] FROM fn_dblog(NULL, NULL) WHERE Operation IN ('LOP_DELETE_ROWS', 'LOP_INSERT_ROWS', 'LOP_MODIFY_ROW') ORDER BY [Current LSN] DESC;

几个字段值得盯死。Operation是操作类型,LOP_DELETE_ROWS就是删除,LOP_INSERT_ROWS是插入,LOP_MODIFY_ROW是更新(它的RowLog Contents 0存前像,RowLog Contents 1存后像,做 UPDATE 回滚时两个都要)。AllocUnitName告诉你这条记录落在哪个索引或堆上,形如dbo.Orders.PK_Orders或dbo.Orders,靠它过滤目标表比用 Page ID 猜靠谱得多。

RowLog Contents 0是被删行的原始字节,十六进制长串。它就是还原的全部原料——但它是裸字节,没有列名、没有类型信息,你得像切蛋糕一样按表结构一列一列切。定长列好办,变长列和可空列要靠行尾的 NULL 位图和变长列偏移数组来定位,这一层解析在最后一章展开。

想确认这批删除属于哪个事务、一共删了多少行,去抓事务开始记录:

SELECT [Current LSN], [Transaction ID], Operation, [Transaction Name], [Begin Time], [End Time], [Transaction SID] FROM fn_dblog(NULL, NULL) WHERE Operation = 'LOP_BEGIN_XACT';

[Transaction Name]通常会直接写出DELETE,[Transaction ID]拿去和上面的删除记录做关联,就能判断是“一条语句删了 27 万行”,还是“某个存储过程分多次删的”。这个信息影响后面脚本要不要分批回放。

至于读日志备份文件而不是在线日志,用的是另一个函数fn_dump_dblog,参数长得离谱,除了前五个是起止 LSN、设备类型、文件序号、路径,后面要补 63 个 DEFAULT 占位:

SELECT [Current LSN], Operation, AllocUnitName, [RowLog Contents 0] FROM fn_dump_dblog( NULL, NULL, N'DISK', 1, N'D:\recover\OrderDB_Tail.trn', DEFAULT, DEFAULT, DEFAULT, DEFAULT, DEFAULT, DEFAULT, DEFAULT, DEFAULT, DEFAULT, DEFAULT, DEFAULT, DEFAULT, DEFAULT, DEFAULT, DEFAULT, DEFAULT, DEFAULT, DEFAULT, DEFAULT, DEFAULT, DEFAULT, DEFAULT, DEFAULT, DEFAULT, DEFAULT, DEFAULT, DEFAULT, DEFAULT, DEFAULT, DEFAULT, DEFAULT, DEFAULT, DEFAULT, DEFAULT, DEFAULT, DEFAULT, DEFAULT, DEFAULT, DEFAULT, DEFAULT, DEFAULT, DEFAULT, DEFAULT, DEFAULT, DEFAULT, DEFAULT, DEFAULT, DEFAULT, DEFAULT, DEFAULT, DEFAULT, DEFAULT, DEFAULT, DEFAULT, DEFAULT, DEFAULT, DEFAULT, DEFAULT, DEFAULT, DEFAULT, DEFAULT, DEFAULT, DEFAULT, DEFAULT, DEFAULT, DEFAULT, DEFAULT);

占位参数少写一个都会直接报错,复制脚本时别顺手删行。另外这个函数对日志备份文件的版本有要求,高版本实例生成的备份不能拿到低版本实例上读。

3. 先别打开工具:把库复制出来再动手的四步备份链

我见过最常见的事故二次伤害,是在原库上直接操作。有人在生产库上跑 UPDATE 试图补救,有人在原实例上反复尝试还原,结果日志被新事务覆盖、恢复链被自己搞断,原本能救回来的数据彻底没了。动手之前有一条铁律:任何分析、任何回放,只允许在副本上做。

3.1 停止写入:比做备份更紧急的一步

误删发生后的第一分钟,动作顺序应该是:先把应用侧停掉或者把连接切到只读库,再做备份。原因很直白——每一条新事务都在往日志里写东西,日志空间不够时 SQL Server 会尝试重用 inactive 的 VLF,你正想抢救的那段记录可能就被覆盖了。

几个不该做的动作也列一下:不要 shrink 日志文件,收缩会重置 VLF 状态,日志备份链直接断;不要重启实例,重启会触发恢复过程,虽然一般不会丢已提交事务的日志,但会打断你正在做的尾日志备份;不要在原库上建表、建索引做任何补救性实验。这些操作我在别人的事故现场都见过,血泪经验。

3.2 尾日志备份:把最后一段还没备份的日志固化下来

确认了log_reuse_wait_desc是LOG_BACKUP或ACTIVE_TRANSACTION之后,立刻做尾日志备份:

BACKUP LOG [OrderDB] TO DISK = N'D:\recover\OrderDB_Tail_20240511_1630.trn' WITH NORECOVERY, INIT, CHECKSUM, COMPRESSION, STATS = 10;

参数逐个说。NORECOVERY表示备份完把数据库置为“正在还原”状态,后续可以继续接日志,同时也切断了新的写入——这正是你想要的,但如果这是生产库、还没跟业务方对齐,先别加这个选项,改用NO_TRUNCATE。NO_TRUNCATE的语义是“即使数据库损坏也照样备份日志”,它不会让库进入还原状态,写入还能继续,代价是截断点不会前移。抢救场景下我一般优先NO_TRUNCATE,因为它更安全,副本上还能再折腾。

CHECKSUM会对备份页做校验,读的时候能发现页损坏;COMPRESSION在日志备份上压缩比很高,能省下大量磁盘;STATS = 10每 10% 打一行进度,大库上不至于干等。

这个.trn文件有两个用途:一是接着还原链做时间点还原,二是直接丢给 ApexSQL Log 当输入源做日志解析。后者不需要链完整,只要文件本身能读就行,这是它比 RESTORE 灵活的地方。

3.3 还原到旁路实例:把 RESTORE 链一行行拼对

拿到完整备份、差异备份、若干日志备份和刚才的尾日志之后,在另一台实例或者同实例的不同库名上拼还原链。全备打底:

RESTORE DATABASE [OrderDB_RC] FROM DISK = N'D:\recover\OrderDB_Full_20240511_1000.bak' WITH MOVE 'OrderDB' TO N'D:\recover\OrderDB_RC.mdf', MOVE 'OrderDB_log' TO N'D:\recover\OrderDB_RC_log.ldf', NORECOVERY, REPLACE, CHECKSUM, STATS = 5;

MOVE的两个逻辑名必须和备份文件里的原始逻辑名完全一致,查法是从备份头读:

RESTORE FILELISTONLY FROM DISK = N'D:\recover\OrderDB_Full_20240511_1000.bak';

REPLACE允许覆盖已存在的同名库,NORECOVERY让库停在还原中状态等下一个日志。接着把日志逐条接上,最后一条决定是继续等还是收尾:

RESTORE LOG [OrderDB_RC] FROM DISK = N'D:\recover\OrderDB_Log_20240511_1200.trn' WITH NORECOVERY, STATS = 5; RESTORE LOG [OrderDB_RC] FROM DISK = N'D:\recover\OrderDB_Tail_20240511_1630.trn' WITH RECOVERY, STATS = 5;

如果目标是“整体回到误删前一秒”,最后一条改成带STOPAT的形式最省事:

RESTORE LOG [OrderDB_RC] FROM DISK = N'D:\recover\OrderDB_Tail_20240511_1630.trn' WITH RECOVERY, STOPAT = N'2024-05-11T16:02:17', STATS = 5;

STOPAT的时间精度受日志记录时间戳粒度影响,通常是毫秒级,不是精确到微秒。时间点填错一秒,可能把误删语句本身包含进去,也可能少恢复几条正常业务。我的习惯是先还原到一个明显早于误删的时间点,再用日志里的[Begin Time]反推误删语句的准确时刻,然后重新走一遍还原链。宁可多还几次,不要一次赌准。

3.4 校验副本可信度:别拿一个坏库当基准

副本还原完成之后,先做完整性和行数核对,再谈取数:

DBCC CHECKDB (N'OrderDB_RC') WITH NO_INFOMSGS, ALL_ERRORMSGS; SELECT COUNT_BIG(*) AS cnt FROM OrderDB_RC.dbo.Orders; SELECT COUNT_BIG(*) AS cnt FROM OrderDB_RC.dbo.Orders WITH (NOLOCK) WHERE CreatedAt >= '2024-05-11 10:00:00';

CHECKDB报页级损坏的话,说明基础备份本身有问题,得换更早的备份重来。行数核对要和业务方提供的口径对齐,比如“误删前一刻应该有 27.3 万行左右”。这一步看着啰嗦,但它是后面所有还原脚本的对照组——没有基准,你不知道回放出来的数据对不对。

4. ApexSQL Log 实操:连接、筛选与导出可回放脚本

到这一步,如果STOPAT已经能整体回到误删前,其实就不需要工具了。工具的价值在另一类场景:不能整库回退。比如误删了三小时前的 200 行关键数据,但这三小时里业务还在正常跑,整库回退等于丢三小时的正常交易。这时候只能精准地把那 200 行挖出来补回去。

4.1 授权边界:为什么我不建议碰破解版

先说清楚一件事。这类日志解析工具的核心工作是把 SQL Server 的私有日志格式翻译成人类可读的行值,它对版本极其敏感,每次 SQL Server 大版本升级,日志结构和记录格式都可能微调。来源不明的修改版二进制,你无法确认它到底改了什么,也无法确认它解析出来的RowLog Contents有没有被截断或错位。

更实际的风险是数据本身。日志备份文件里装的是完整的业务数据,包含客户信息、订单金额、账户明细。用一个来路不明的程序去打开这个文件,等于把整个库的数据交给对方。而且解析结果出错时,你根本分不清是源数据有问题、还是程序被动过手脚——黑匣子最麻烦的地方就在这里,出了问题没有后悔药。

我的做法是:用官方试用版做验证性分析,确认能读出需要的数据、确认这些数据足以补回业务,然后再谈授权的事情。试用版在导出环节通常有数量限制,这个限制正好够你验证“能不能救”,不够你直接跑生产回放,反而逼着你先在测试库上验证一遍脚本,是个好事。

4.2 连接与筛选:四个参数决定你看到什么

连接环节一般给三个入口:在线数据库、日志备份文件、分离的 mdf/ldf 组合。抢救场景下我优先选日志备份文件,原因很简单——.trn 是静态的,读它不会对生产库产生任何额外压力,也不会因为日志重用而中途失效。在线库虽然能读最新内容,但它需要VIEW SERVER STATE权限,而且读的过程中日志还在滚动。

日志来源确定之后,四个筛选维度要设对:

维度建议设法设错的后果
时间窗口覆盖误删前后各 10 分钟窗口太窄会漏掉事务开始记录,看不到完整事务
操作类型先全选,看清后再收敛到 DELETE/UPDATE只勾 DELETE 会漏掉关联的 UPDATE 前像
对象过滤按表名或索引名精确过滤用模糊匹配会带出大量无关表的记录,卡死界面
事务范围勾选包含事务开始/结束标记不勾的话导出脚本缺少事务边界,回放难回滚

时间窗口这个参数有个细节:日志记录里的[Begin Time]是事务开始时间,不是语句执行时间。一个大事务可能从 15:50 开跑到 16:02 才提交,如果你把窗口起点设在 16:00,事务开始记录就被过滤掉了,工具仍然能显示删除行,但你不知道这批行属于哪个事务、后面还有没有同事务的其他操作。所以我一般把起点往前放,宁可多看一点。

4.3 从结果网格到脚本:Undo 和 Redo 的语义差别

结果网格里一行就是一条日志记录,字段和fn_dblog输出基本对应,只是做了可读性翻译。选中目标行后导出脚本,通常有两个方向:Undo 和 Redo。这两个词容易搞混,说清楚:

  • Undo 是“反向操作”,把状态退回到这条记录执行之前。对 DELETE 记录来说,Undo 就是 INSERT,把那行数据插回去。
  • Redo 是“正向操作”,重新执行这条记录。对 DELETE 记录来说,Redo 就是再删一次——基本没用,但在做变更审计、复现问题时有价值。

做误删恢复,要的是 Undo。导出之后拿到的脚本大致长这样:

-- 由日志解析生成的反向脚本(示例结构) BEGIN TRANSACTION; INSERT INTO dbo.Orders (OrderID, CustomerID, Amount, Status, CreatedAt) VALUES (102431, 7781, 259.00, 'PAID', '2024-05-11 09:12:33'); INSERT INTO dbo.Orders (OrderID, CustomerID, Amount, Status, CreatedAt) VALUES (102432, 7790, 118.50, 'PAID', '2024-05-11 09:13:02'); -- ... 剩余行 COMMIT TRANSACTION;

字段映射这一层要人工过一遍。工具按列在行里的物理顺序还原,遇到表结构在误删之后变过(加过列、改过类型)的情况,映射就会错位。我在一个案例里见过:表在误删后加了一个Remark列,工具按旧结构解析,结果Status的值全塞进了Remark。所以脚本导出来第一件事是拿几十行和副本库里的真实数据比对,别整批跑。

4.4 分批回放:一次性插入 27 万行的三个问题

把整批 INSERT 塞进一个事务里跑,会同时踩三个坑:日志文件暴涨(插入也是要记日志的)、锁升级导致表锁、一旦中途出错整个事务回滚、前面几分钟白跑。我一般按 LSN 分段,每段 5000 到 20000 行,取决于单行大小:

SET NOCOUNT ON; DECLARE @batch INT = 1, @maxBatch INT = 20; WHILE @batch <= @maxBatch BEGIN BEGIN TRANSACTION; INSERT INTO dbo.Orders (OrderID, CustomerID, Amount, Status, CreatedAt) SELECT OrderID, CustomerID, Amount, Status, CreatedAt FROM dbo.Orders_RecoveredStage WHERE BatchNo = @batch AND NOT EXISTS (SELECT 1 FROM dbo.Orders o WHERE o.OrderID = dbo.Orders_RecoveredStage.OrderID); COMMIT TRANSACTION; SET @batch += 1; END

这个写法里NOT EXISTS是幂等保护——同一批跑两遍不会主键冲突。中间表Orders_RecoveredStage是我习惯的做法:先把工具导出的 INSERT 语句改写成 INSERT...SELECT 灌到中间表,加上BatchNo列,再从中间表往正式表补。这样重复执行安全,出错也能精确知道卡在第几批。回放完成后记得对一遍行数和关键字段的CHECKSUM:

SELECT COUNT_BIG(*) AS cnt, CHECKSUM_AGG(BINARY_CHECKSUM(OrderID, CustomerID, Amount, Status)) AS ck FROM dbo.Orders WHERE CreatedAt >= '2024-05-11 09:00:00';

拿副本库同口径跑一遍,两个数对上才算真的还原到位。

5. 避坑与排查:五个把我按在地上摩擦的场景

下面这五条都是实际遇到过的,按“现象、原因、解决”写。看着像小事,每一条都能让人多熬两小时。

5.1 现象:连上去空空如也,一条记录都没有

原因排序:最常见的是过滤条件把记录筛没了,尤其是时间窗口设成了误删之后;其次是把在线库当输入源,但恢复模式是 SIMPLE 且日志早已重用;再次是权限不足,账号没有VIEW SERVER STATE,工具只能读到部分元数据,界面不报错但结果为空。

解决顺序:先把所有过滤条件清空只留时间范围,确认有没有数据;再用fn_dblog直接查同样时间段的LOP_DELETE_ROWS记录,两边对比;如果fn_dblog也查不到,那基本可以确认日志已被覆盖,转去走备份链的时间点还原。

5.2 现象:回放脚本跑到一半主键冲突

原因:误删之后,业务侧可能用相同的 OrderID 重新写入过数据(比如订单从别的系统重新同步进来)。这时候直接 INSERT 必然撞主键。

解决:把回放逻辑从 INSERT 改成 MERGE,或者用上面那套NOT EXISTS过滤。但要注意,NOT EXISTS跳过的是“已存在”的行,这些行到底该保留新数据还是覆盖回旧数据,得让业务方决策,不要自己拍脑袋。我的习惯是先把冲突行单独导出来,标上“新旧差异”,让业务确认后再执行。

5.3 现象:数据补回去了,但自增列号段全乱

原因:自增列的当前值不会因为补插历史数据而回退。库里表的最大 OrderID 现在是 120000,你补进去的是 102431,自增列的种子还停在 120001,看起来没影响;但如果当时是把整表清空后重建,种子可能被重置成了 1,再补历史数据就会和新数据撞号。

解决:补数时显式指定自增列的值,必须开SET IDENTITY_INSERT dbo.Orders ON,插入完成后OFF。全部补完之后再用DBCC CHECKIDENT ('dbo.Orders', RESEED, <当前最大值>)校准种子。这一步漏了,后面新订单会撞主键,属于典型的二次事故。

5.4 现象:日志备份拿到手上,工具报“无法读取”或链不完整

原因有三类。一是备份文件本身产生时用了WITH COPY_ONLY之外的非常规选项,或者文件被复制过程中截断,文件大小比预期小。二是备份链中间断过——有人做过日志收缩、有人切过恢复模式、有人删过某个日志备份。三是版本不匹配,高版本实例生成的日志备份不能拿到低版本实例上解析。

解决:先看文件头确认完整性,RESTORE HEADERONLY FROM DISK = N'...trn'能读出DatabaseVersion和 LSN 范围;再看相邻备份文件的 LSN 能否首尾相接;版本不匹配就只能找对应版本的实例来读。断链场景下,单个备份文件仍可能被解析出部分记录,只是覆盖范围不完整,这时得权衡是继续拼还是有其他来源。

5.5 现象:TEXT、NVARCHAR(MAX)、VARBINARY(MAX) 列还原出来是乱码或者全是问号

原因:大对象类型的值不是存在行内,行内只留一个 16 字节的指针,真正的数据散落在 LOB 页上。日志解析工具在读行内记录时拿到的只有指针,指针指向的 LOB 页如果已经被覆盖,内容就丢了。字符集搞错也会出乱码,比如把 UTF-16 的数据按单字节读。

解决:优先确认数据是否还在,用工具的“LOB 数据恢复”类功能去追 LOB 页,读不出来就只能从备份链整体还原。字符集问题看列的实际类型,NVARCHAR一律按 UTF-16LE 解析,VARCHAR则要确认排序规则对应的代码页。这一块靠自研脚本很难做全,工具的价值主要就体现在这里。

6. 自建日志解析脚本:把十六进制字节切成能核对的行值

不想每次都被工具卡住的话,写一个自己的解析脚本是个好投资。fn_dblog已经把原始字节交到你手上,剩下的事就是把RowLog Contents 0按表结构切开。前提是表里都是定长列或者你只需要定长那几列——变长列和 NULL 位图的处理复杂度高一个量级,那种场景还是交给专业工具。

先把目标表的列定义和物理顺序拿出来,注意是物理顺序,不是sys.columns的顺序:

SELECT c.column_id, c.name, t.name AS type_name, c.max_length, c.is_nullable FROM sys.columns c JOIN sys.types t ON c.user_type_id = t.user_type_id WHERE c.object_id = OBJECT_ID('dbo.Orders') ORDER BY c.column_id;

然后把fn_dblog的结果导成 CSV,用 Python 切字节。下面这段是我常用的骨架,处理 int、datetime、decimal 和定长 char:

import csv import struct from datetime import datetime, timedelta # 按列物理顺序定义的 schema:(列名, 类型, 占用字节数) SCHEMA = [ ("OrderID", "int", 4), ("CustomerID", "int", 4), ("Amount", "decimal", 5), # decimal(9,2) 占 5 字节 ("Status", "char", 8), ("CreatedAt", "datetime", 8), ] SQL_EPOCH = datetime(1900, 1, 1) def parse_row(hex_str): raw = bytes.fromhex(hex_str) offset = 0 row = {} for name, typ, size in SCHEMA: chunk = raw[offset:offset + size] offset += size if typ == "int": row[name] = struct.unpack("<i", chunk)[0] elif typ == "decimal": # 简化处理:只取整数部分,符号位和标度位需按表精度调整 row[name] = int.from_bytes(chunk, "little", signed=True) / 100.0 elif typ == "char": row[name] = chunk.decode("gbk", errors="replace").rstrip() elif typ == "datetime": days, ticks = struct.unpack("<iI", chunk) row[name] = SQL_EPOCH + timedelta( days=days, milliseconds=ticks * 300 / 1000) return row def main(src, dst): with open(src, newline="", encoding="utf-8") as f, \ open(dst, "w", newline="", encoding="utf-8") as out: reader = csv.DictReader(f) writer = csv.DictWriter(out, fieldnames=[c[0] for c in SCHEMA]) writer.writeheader() for line in reader: hex_val = line.get("RowLog Contents 0", "").strip() if not hex_val: continue try: writer.writerow(parse_row(hex_val)) except (struct.error, ValueError): continue # 字节长度不符的记录直接跳过,事后单独核查 if __name__ == "__main__": main("dblog_export.csv", "recovered_rows.csv")

几个关键点。struct.unpack("<i")的小端序不能改,SQL Server 存整数一律小端。decimal的物理存储是符号位加 128 进制编码,上面用int.from_bytes是偷懒版本,只在小数位为 2 且数值范围有限时近似正确,正式用要按精度和标度逐位解。datetime是两个 4 字节字段拼起来:前 4 字节是相对 1900-01-01 的天数,后 4 字节是当天 0 点起的 1/300 秒计数,除以 300 换算成秒,这个换算关系搞错会导致时间整体偏移好几天,是新手很容易踩的坑。

字符串编码我用了 gbk 且带errors="replace",遇到非中文排序规则的库要换成对应的代码页,否则整列都是问号,属于典型的“看着像还原失败其实是编码错”。

跑完脚本,拿输出的 CSV 和副本库对比三个指标:总行数、主键集合的差集、金额字段的求和。三项全对才敢往生产写。从那以后我每次做日志回放,都强制走一遍“副本还原、行数比对、分批回放、事后 CHECKSUM”这四步,一步不省。出事那天脑子一热直接在生产库上动手的教训,一次就够了。

希望帮到你。

本文还有配套的精品资源,点击获取