
简介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_descSELECT 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, NDISK, 1, ND:\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 ND:\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 ND:\recover\OrderDB_Full_20240511_1000.bak WITH MOVE OrderDB TO ND:\recover\OrderDB_RC.mdf, MOVE OrderDB_log TO ND:\recover\OrderDB_RC_log.ldf, NORECOVERY, REPLACE, CHECKSUM, STATS 5;MOVE的两个逻辑名必须和备份文件里的原始逻辑名完全一致查法是从备份头读RESTORE FILELISTONLY FROM DISK ND:\recover\OrderDB_Full_20240511_1000.bak;REPLACE允许覆盖已存在的同名库NORECOVERY让库停在还原中状态等下一个日志。接着把日志逐条接上最后一条决定是继续等还是收尾RESTORE LOG [OrderDB_RC] FROM DISK ND:\recover\OrderDB_Log_20240511_1200.trn WITH NORECOVERY, STATS 5; RESTORE LOG [OrderDB_RC] FROM DISK ND:\recover\OrderDB_Tail_20240511_1630.trn WITH RECOVERY, STATS 5;如果目标是“整体回到误删前一秒”最后一条改成带STOPAT的形式最省事RESTORE LOG [OrderDB_RC] FROM DISK ND:\recover\OrderDB_Tail_20240511_1630.trn WITH RECOVERY, STOPAT N2024-05-11T16:02:17, STATS 5;STOPAT的时间精度受日志记录时间戳粒度影响通常是毫秒级不是精确到微秒。时间点填错一秒可能把误删语句本身包含进去也可能少恢复几条正常业务。我的习惯是先还原到一个明显早于误删的时间点再用日志里的[Begin Time]反推误删语句的准确时刻然后重新走一遍还原链。宁可多还几次不要一次赌准。3.4 校验副本可信度别拿一个坏库当基准副本还原完成之后先做完整性和行数核对再谈取数DBCC CHECKDB (NOrderDB_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列再从中间表往正式表补。这样重复执行安全出错也能精确知道卡在第几批。回放完成后记得对一遍行数和关键字段的CHECKSUMSELECT 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 和定长 charimport 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, signedTrue) / 100.0 elif typ char: row[name] chunk.decode(gbk, errorsreplace).rstrip() elif typ datetime: days, ticks struct.unpack(iI, chunk) row[name] SQL_EPOCH timedelta( daysdays, millisecondsticks * 300 / 1000) return row def main(src, dst): with open(src, newline, encodingutf-8) as f, \ open(dst, w, newline, encodingutf-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 且带errorsreplace遇到非中文排序规则的库要换成对应的代码页否则整列都是问号属于典型的“看着像还原失败其实是编码错”。跑完脚本拿输出的 CSV 和副本库对比三个指标总行数、主键集合的差集、金额字段的求和。三项全对才敢往生产写。从那以后我每次做日志回放都强制走一遍“副本还原、行数比对、分批回放、事后 CHECKSUM”这四步一步不省。出事那天脑子一热直接在生产库上动手的教训一次就够了。希望帮到你。本文还有配套的精品资源点击获取