项目里最容易被写成"形式主义"的一块代码,就是 finally 代码块。我见过太多 Object Pascal 工程,try...finally 写得工工整整,但真出了异常、真碰上提前 Exit、真遇到句柄耗尽,该漏的还是漏。这篇笔记是我在 Delphi 11 上重刷 Object Pascal 第 9 章异常处理部分时,结合这几年排查线上问题的记录整理出来的,重点不在语法怎么写,而在于 finally 到底在什么时候执行、什么情况下不执行、以及执行了却依然出错的原因。不管你是刚接触 Object Pascal 的新人,还是写了多年 Delphi 却一直把 finally 当"保险丝"用的老手,下面这些内容都能对得上号:从执行路径的拆解,到对象、文件句柄、锁、事务四类资源的落地模板,再到批量导入时事务批大小的参数计算和排查速查表,都可以直接拿去改改就用在项目里。
1. 为什么 finally 值得单独拎出来讲
1.1 一段看着很顺的代码是怎么漏资源的
先看一段我早年写过的典型代码,逻辑上没人挑得出毛病:
procedure TReportJob.ExportToFile(const AFileName, ATemplate: string); var FS: TFileStream; SL: TStringList; begin FS := TFileStream.Create(AFileName, fmCreate); SL := TStringList.Create; SL.LoadFromFile(ATemplate); // 模板文件被占用、编码不对,都会在这里抛异常 SL.Text := BuildContent; SL.SaveToStream(FS); SL.Free; FS.Free; end;问题在于第二行和第三行之间那道看不见的裂缝。只要LoadFromFile抛异常,控制流就直接从这一行飞出去,后面两行Free永远等不到执行。单次调用看不出问题,可这个导出任务在服务端是每分钟跑一次的常驻线程,跑上两天,TFileStream占用的原生文件句柄就堆到几百个,最后Create直接报"文件无法打开"或者句柄无效,而错误现场早就跑远了,日志里只剩下一个莫名其妙的异常。
这类坑的共同特征很一致:分配点和释放点之间隔着若干行可能抛异常的语句。只要这段距离存在,靠顺序执行做清理就是赌运气。finally 代码块存在的唯一理由,就是把这段距离用语言机制焊死——无论中间怎么跳出去,清理代码一定被执行一次,且只执行一次。
1.2 try...finally 和 try...except 是两条不同的轨道
新手最容易混淆的地方,是把 finally 当成"捕获异常的地方"。这两者的职责完全不重叠,我在团队规范里直接用一句话区分:except 负责收拾"出了什么事",finally 负责收拾"留下的东西"。
| 对比项 | try...except | try...finally |
|---|---|---|
| 主要职责 | 捕获并处理异常,决定是否继续传播 | 无条件执行清理,不关心发生了什么 |
| 执行时机 | 仅在异常发生时进入 | 无论正常结束、异常、Exit 都进入 |
| 是否吞掉异常 | 不重新 raise 就吞掉 | 不影响异常传播方向 |
| 典型场景 | 记录日志、转成业务异常、降级重试 | 释放对象、关闭句柄、解锁、回滚事务 |
| 常见误用 | 用空 except 掩盖问题 | 把业务逻辑写进 finally |
Object Pascal 有一个很多人第一次写会卡住的语法限制:同一个 try 块不能同时挂 except 和 finally。想要既捕获又清理,只能嵌套,而且顺序很有讲究:
procedure TImportJob.Run; var Conn: TImportConnection; begin Conn := TImportConnection.Create(GetConnStr); try try Conn.Open; DoImport(Conn); except on E: EImportError do begin Log.Warn('导入失败:' + E.Message); raise; // 转成上层能识别的异常,别吞 end; end; finally Conn.Close; Conn.Free; Conn := nil; end; end;内层负责语义,外层负责资源,职责一刀切开。我试过把这个顺序反过来(外层 except、内层 finally),结果就是捕获到的异常信息里丢失了清理阶段的状态,排查时非常被动。
1.3 选型背后:为什么我把清理逻辑全塞进 finally
有人会问,既然Free在过程退出时也可能由别的方式触发,为什么不用别的手段兜底?我踩过几次坑之后的结论是:在 Object Pascal 里,finally 是唯一一个不依赖程序员记性的清理机制。
对象字段由析构函数清理,那是对象级的生命周期;接口变量由引用计数清理,那是编译期插入的隐藏代码;这两者都管不到过程内局部变量和原生句柄。而 finally 是显式写在过程里的、编译期确定会被生成的清理路径。它不依赖 GC,不依赖引用计数,不依赖你记得在每一处Exit之前补一行Free。
另外还有一个实际收益:把清理收拢到 finally,过程体里就可以放心用Exit做早退。以前没有 finally 的代码,早退意味着要在每个出口前写一遍清理,写三遍就必然有一处写错。现在的规矩很简单——过程体里可以随便 Exit,清理只在 finally 里写一遍。
2. finally 的执行语义拆到底
2.1 四条出口路径与 finally 的触发关系
只知道"finally 会执行"是不够的,得知道在哪些出口路径下它会执行。我按实际遇到的情况整理成下面这张表,这是理解后面所有坑的基础:
| 出口路径 | finally 是否执行 | 说明 |
|---|---|---|
| 正常执行到 end | 执行 | 最普通的情况 |
| 过程体内 raise 异常向外传播 | 执行 | 异常继续往外走,不改变方向 |
| 过程体内 Exit | 执行 | 先跑 finally,再真正退出过程 |
| 循环体内 Break / Continue 跳出到 try 之外 | 执行 | 若 try 在循环体内,跳出即触发 |
| Halt 或进程级强制结束 | 不执行 | 进程被掐断,栈不展开,清理无从谈起 |
| 线程被强制终止 | 不保证 | 属于不可依赖的路径,务必避免 |
前四条是我每天都在依赖的,第五条是我在调试时用来解释"文件被占用却没有任何报错"的常见原因。有些开发者喜欢在出错时直接Halt结束程序,看着"干净利落",实际上所有 finally 里的落盘、解锁、关句柄全部跳过,下次启动就会出现残留锁文件、日志半截、数据未刷盘的情况。我的建议很直接:业务代码里不要出现 Halt,退出走正常的异常传播或主循环退出流程。
2.2 嵌套与展开顺序:从内到外像剥洋葱
多层 try...finally 嵌套时,释放顺序是严格的"后进先出",和栈的行为完全一致:
procedure TOrderService.Submit; var Lock: TOrderLock; Txn: TOrderTransaction; Detail: TOrderDetail; begin Lock := TOrderLock.Create(GetOrderId); try Lock.Acquire(3000); // 3 秒拿不到锁就抛 Txn := TOrderTransaction.Create(Conn); try Txn.BeginTransaction; Detail := TOrderDetail.Create; try BuildDetail(Detail); Txn.Insert(Detail); Txn.Commit; finally Detail.Free; // 第一个退:内层对象 end; finally if Txn.InTransaction then Txn.Rollback; // 第二个退:事务 Txn.Free; end; finally Lock.Release; Lock.Free; // 最后退:锁 end; end;这个顺序不能乱。如果先释放锁再回滚事务,中间会出现一个窗口期:别的线程拿到锁读到了未提交的数据。反过来,先回滚再解锁就安全得多。我在代码评审时看到锁的 Release 写在事务 finally 之前,基本直接打回去重写。
嵌套层数也有讲究。三层以上就开始难以阅读,我一般的做法是把"锁 + 事务 + 对象"三层压缩成两层:锁和事务交给一个封装好的TUnitOfWork统一管理,业务代码只管最内层的对象。这样每层 finally 都有明确的语义,不会出现"这个 Free 到底该放哪一层"的犹豫。
2.3 在 finally 里写 Exit 或 raise 会发生什么
这是我在第 9 章反复看了两遍才彻底弄明白的地方,也是最容易写出的隐蔽 bug。
在 finally 里写 Exit,会把正在传播的异常吞掉。原因不复杂:异常向外传播的机制需要 finally 块正常走完,运行时才知道"要不要继续往外扔"。你在 finally 里执行Exit,控制流直接离开过程,异常对象就此失去传播的机会,外层 except 什么也抓不到,日志里一片安静,业务却已经错了。写个最小复现:
procedure TWorker.Process; var Item: TWorkItem; begin Item := TWorkItem.Create; try Item.Execute; // 这里抛出 EDataError finally Item.Free; Exit; // 灾难:EDataError 被彻底吞掉 end; end;调用方看到的是"执行成功但什么也没做",排查时能把人逼疯。我的规矩是:finally 里只允许出现清理语句,不允许出现 Exit、Continue、Break。如果确实需要根据清理结果决定流程,把判断移到 try 外面去。
在 finally 里 raise 新异常,会覆盖原始异常。下面这段是另一个经典写法错误:
finally try FlushBuffer; except raise Exception.Create('刷盘失败'); // 原始异常信息全部丢失 end; FS.Free; end;外层 except 抓到的只有"刷盘失败",至于最初到底是因为数据格式错误还是网络中断才走到这里的,一点线索都没有。处理这类情况,正确的姿势是清理阶段的异常单独记录、不覆盖主线:
finally try FlushBuffer; except on E: Exception do Log.Error('清理阶段异常,已忽略:' + E.ClassName + ' / ' + E.Message); end; FS.Free; end;清理失败记日志,主异常继续原样传播,两条信息都在,排查时才有完整链路。
2.4 对象、接口、句柄三类资源的销毁顺序
同一段代码里往往混着三种资源,它们的释放规则完全不同,混着写就会出问题。
对象靠Free,顺序是"先创建的后释放"。父对象持有子对象时,如果子对象是字段,交给析构函数;如果是局部变量,就在 finally 里手动释放,而且必须在父对象释放之前。
接口靠引用计数,不需要也不能手动 Free。这里有个反直觉的点:局部接口变量在过程出口时由编译器插入的清理代码递减引用计数,即使你是从 try 块里 Exit 出去的也一样。所以给接口变量套 finally 写IFoo := nil通常是多余的。但有一个例外必须注意——接口之间存在循环引用时,引用计数永远降不到零,对象会一直挂着。这种情况我会显式在 finally 里断开其中一条引用:
var Parent, Child: INode; begin Parent := TNode.Create; Child := TNode.Create; Parent.Attach(Child); // 互相持有 try DoWork(Parent); finally Parent.Detach(Child); // 必须手动断链,否则两个对象都泄漏 Parent := nil; Child := nil; end; end;原生句柄(文件、套接字、系统内核对象)则必须显式关闭,Free一个包装类只是间接关闭,包装类没写好的情况下句柄依然会漏。我一般把句柄的关闭放在包装类自己的Close里,外层 finally 里先Close再Free,双保险且语义清晰。
3. 落地实操:把 finally 用成肌肉记忆
3.1 对象资源标准模板与 FreeAndNil 的取舍
我团队里所有新代码的对象清理都统一成下面这个模板,几乎没有例外:
procedure TCacheManager.Rebuild; var NewIndex: TCacheIndex; begin NewIndex := TCacheIndex.Create(FCapacity); try NewIndex.LoadFrom(FSourcePath); NewIndex.BuildBuckets; SwapIndex(NewIndex); // 成功才换入,失败保留旧索引 NewIndex := nil; // 所有权已转移,finally 不再释放 finally NewIndex.Free; // nil 上调用 Free 是安全的 end; end;这里有两个细节值得说。第一,构造放在 try 外面。因为如果构造函数本身抛异常,对象根本没建成,try 还没建立,finally 里的变量是未初始化的野指针,Free会直接崩。第二,NewIndex.Free对 nil 是安全的,因为TObject.Free内部会先判断Self <> nil再调用Destroy。这也是我推荐用Free而不是Destroy的原因——Destroy对 nil 调用会直接访问违例。
至于FreeAndNil,我的用法是分场景的:局部变量用Free就够(过程一结束变量就没了,置不置 nil 无所谓),字段和跨方法共享的变量用FreeAndNil(置 nil 能在后续误用时立刻暴露,而不是对着一个已释放的内存踩下去)。这条规则的实战价值很高,我在排查"释放后再访问"这类问题时,十次有八次是因为字段只Free没置 nil。
还有一点容易被忽略:赋 nil 表示"所有权转移"这个约定必须在代码里显式写出来。上面模板里的NewIndex := nil如果漏了,SwapIndex之后对象被下一次 finally 释放掉,缓存直接变成悬空指针,而错误会在几分钟后的另一次读取时才爆出来,跟现场完全对不上。
3.2 文件与原生句柄:Close 和 Free 要分开写
TFileStream的常见写法是把Free放在 finally 里,这没错,但不够。因为Free会连带关闭底层句柄,如果关闭过程中抛异常(比如网络盘断开),finally 里的Free抛出的异常会覆盖原始异常,前面 2.3 节讲过这个坑。我的做法是把Close和Free拆开,关闭失败只记日志:
procedure TLogArchiver.Archive(const SrcFile: string); var Src, Dst: TFileStream; Buf: array of Byte; Read: Integer; begin Src := TFileStream.Create(SrcFile, fmOpenRead or fmShareDenyWrite); try Dst := TFileStream.Create(SrcFile + '.gz.tmp', fmCreate); try SetLength(Buf, 64 * 1024); repeat Read := Src.Read(Buf[0], Length(Buf)); if Read > 0 then CompressWrite(Dst, Buf, Read); until Read = 0; finally try Dst.Free; except on E: Exception do Log.Error('归档写入流关闭失败:' + E.Message); end; end; finally try Src.Free; except on E: Exception do Log.Error('源文件流关闭失败:' + E.Message); end; end; end;嵌套的 try...except 看着有点啰嗦,但这是我在处理网络盘、移动存储这类"关闭也可能失败"的场景时唯一放心的写法。本地磁盘当然可以直接Free,但代码往往是复用的,今天写本地明天改网络,不如一次写对。
3.3 锁与事务:finally 是唯一可靠的解锁点
临界区、互斥锁、数据库事务这三样东西有一个共同点:忘记释放的后果比忘记 Free 一个对象严重得多——对象漏了只是内存涨,锁漏了就是整个服务卡死。
procedure TInventoryService.Adjust(const Sku: string; Delta: Integer); begin FInventoryLock.Enter; try FConn.StartTransaction; try ApplyDelta(Sku, Delta); WriteAuditLog(Sku, Delta); FConn.Commit; except on E: Exception do begin if FConn.InTransaction then FConn.Rollback; Log.Error(Format('库存调整失败 sku=%s delta=%d err=%s', [Sku, Delta, E.Message])); raise; end; end; finally FInventoryLock.Leave; // 无论哪条路径都必须走到这里 end; end;注意InTransaction这个判断。很多数据库组件在连接异常断开后,事务状态已经不明确了,直接Rollback又会抛第二个异常,原始信息就丢了。所以回滚前先判断,回滚本身再包一层保护,这是我在生产代码里固定下来的写法。
锁还有超时问题。Enter无参数版本会一直等下去,一旦某个线程因为死循环没释放,整条服务线就僵在那里。我现在的习惯是统一带超时:
if not FInventoryLock.TryEnter(3000) then raise EResourceBusy.Create('库存锁等待超过 3 秒,sku=' + Sku);3 秒这个值是算出来的,不是拍的。库存调整单次业务耗时实测 P99 在 180 毫秒左右,乘以 10 倍留足余量约 1.8 秒,再考虑高并发下的排队,3 秒是个既能容忍正常排队、又不会让调用方干等太久的值。如果业务耗时变了,这个参数要跟着重算。
3.4 一次批量导入的参数计算与实测记录
事务边界的划分是 finally 用得最多、也最容易算错的地方。我们有个从外部文件导入 10 万条记录的任务,最初是每条一个事务,跑完要 40 多秒;后来改成全体一个事务,快了但内存暴涨且失败时全部回滚。最后按批划分,参数是这么定的:
单条插入实测耗时约 0.4 毫秒,一次事务提交固定开销约 15 毫秒。设每批 N 条,总条数 100000,则提交次数为 100000/N,提交总开销为15 × 100000/N毫秒,插入开销恒为0.4 × 100000 = 40000毫秒。
- N = 1:提交开销 1500000 毫秒,显然不能这么干(实际测试因为批处理优化没有这么夸张,但量级方向是对的)
- N = 500:提交 200 次,开销 3000 毫秒,合计约 43 秒
- N = 2000:提交 50 次,开销 750 毫秒,合计约 40.8 秒
- N = 10000:提交 10 次,开销 150 毫秒,合计约 40.2 秒
继续加大 N 收益递减,但内存占用和锁持有时间线性上升。10 万条记录单条约 600 字节,N = 2000 时批数据约 1.2MB,属于完全可控的量级;N = 10000 时 6MB,在并发三四个任务时就开始吃内存了。最终选 2000。
| 每批条数 | 提交次数 | 提交开销(ms) | 批数据内存 | 失败回滚代价 |
|---|---|---|---|---|
| 500 | 200 | 约 3000 | 约 300KB | 极低 |
| 2000 | 50 | 约 750 | 约 1.2MB | 低 |
| 10000 | 10 | 约 150 | 约 6MB | 中 |
对应的代码骨架就是这样,批循环 + 每批一个事务 + finally 保证收尾:
procedure TImportJob.Execute; const BATCH_SIZE = 2000; var Batch: TList<TImportRow>; Row: TImportRow; Count: Integer; begin Batch := TList<TImportRow>.Create; try Count := 0; while FReader.Next(Row) do begin Batch.Add(Row); if Batch.Count >= BATCH_SIZE then begin FlushBatch(Batch); Inc(Count, Batch.Count); Batch.Clear; end; end; if Batch.Count > 0 then begin FlushBatch(Batch); Inc(Count, Batch.Count); end; Log.Info(Format('导入完成,共 %d 条', [Count])); finally Batch.Free; FReader.Close; // 读取器必须关,否则文件句柄挂着 FReader := nil; end; end; procedure TImportJob.FlushBatch(ABatch: TList<TImportRow>); begin FConn.StartTransaction; try InsertRows(ABatch); FConn.Commit; except on E: Exception do begin if FConn.InTransaction then FConn.Rollback; Log.Error(Format('批次写入失败,本批 %d 条已回滚:%s', [ABatch.Count, E.Message])); raise; end; end; end;FReader.Close放在 finally 而不是跟在循环后面,是因为循环里任何一个FlushBatch抛异常都会让读取器失去关闭机会,下次再导入同一个文件就是"文件被占用"。
3.5 老代码迁移:从裸清理到 finally 的六步走
手上有大量历史代码没有 finally,我的迁移流程是固定的,按步骤来基本不会出事故:
- 先定位分配点:搜索
Create、Open、BeginTransaction、Enter,列出每个过程里的资源清单。 - 标出释放点:把
Free、Close、Commit、Leave的位置圈出来,看是否和分配点在同一过程内。 - 找出所有出口:
Exit、raise、循环里的Break,每一个都要在脑子里过一遍会不会跳过释放点。 - 加 try 包住使用区间:从第一个使用语句开始,到最后一次使用结束,这一段整体进 try,释放语句移到 finally。
- 处理所有权转移:过程中如果有"对象交给别人管"的情况,在转移点后显式赋 nil,并加注释。
- 补回归用例:重点是制造异常路径——故意让中间某一步失败,验证资源确实被释放了(用句柄计数或者内存快照验证)。
第 6 步是最容易被省略、也最能救命的。我一般会临时在第 4 步的 try 块中间插一句raise Exception.Create('test');,跑一遍,看任务管理器里的句柄数有没有回落,回落了就说明写对了,然后把测试语句删掉。
4. 常见问题与排查技巧实录
4.1 问题速查表
下面这张表基本覆盖了我这几年遇到的 finally 相关问题,按出现频率排序:
| 现象 | 最可能的原因 | 排查手段 | 修复方向 |
|---|---|---|---|
| 异常凭空消失,上层无感知 | finally 里有 Exit / 封装代码吞了异常 | 在 finally 首行加日志断点 | 移除 finally 内的流程控制语句 |
| 日志里只有清理阶段的错误 | finally 里 raise 覆盖了原异常 | 看调用栈最内层是不是清理代码 | 清理异常改为只记录不抛出 |
| 句柄数持续上涨 | 分配点在 try 外,抛异常时未释放 | 任务管理器或性能计数器观察句柄曲线 | 把分配点挪进 try,或改用嵌套结构 |
| 对象已释放仍被访问,偶发崩溃 | 字段 Free 后未置 nil | 打开 FastMM 一类的内存诊断 | 字段统一用 FreeAndNil |
| 服务卡死无响应 | 锁在异常路径上未释放 | 转储线程栈看谁持有锁 | 锁的 Enter/Leave 必须进 try...finally |
| 事务已提交但数据不对 | 回滚逻辑写在提交之后 | 通读 finally 块顺序 | 回滚必须在提交失败分支内 |
| 清理代码本身抛异常 | 关闭网络流、关闭已断开的连接 | 单独 try 包住清理并记日志 | 清理阶段异常降级为日志 |
4.2 三个现场踩坑记录
第一个坑是"服务运行三天后必然挂"。现象是导出功能全部失败,报文件无法打开。查了两天,最后发现是某个导出过程中,模板文件读取失败时走了Exit早退,而那一版代码的Free写在早退语句后面。修复方式很简单,把整个使用区间包进 try,Free放 finally。但从这件事之后,我在团队规范里加了一条硬性要求:任何Exit出现的位置,其上方必须有对应的 try...finally,否则代码评审不通过。
第二个坑更隐蔽。有个批量处理任务偶发"少处理了最后一批数据",日志显示任务成功完成。翻了很久才发现,finally块里有个Exit,是在某次"优化"时顺手加的——原作者的意思是"如果读取器已关闭就提前退出清理流程",结果它把外层抛出的异常吞了,任务被当成成功。这个坑教育我:finally 里的代码要当作"没有控制流的纯清理"来写,条件判断可以有(比如if InTransaction),但不能有跳出语句。
第三个坑跟性能有关。有一段代码在 finally 里写了比较重的操作,每次都会把缓冲区刷新到磁盘并计算校验和,导致整体耗时增加了近 30%。这个不怪 finally 本身,怪的是把本该放在主流程里的收尾计算挪到了清理路径上。finally 里只放"必须执行且开销小"的操作,重活要么提前做,要么挪到专门的后置步骤里。
4.3 调试与自检的几个小技巧
第一,用TStopwatch给关键 try 块计时,如果发现某个过程的耗时和内部逻辑对不上,八成是 finally 里有隐藏开销(我上面那个 30% 的例子就是这么发现的)。
第二,把清理阶段单独打上标记日志,格式统一成"cleanup: xxx",出问题时按关键字过滤,一眼就能看出异常路径走到了哪一步清理。这套标记在排查"异常被吞"类问题时特别有效,因为你能通过缺失的清理日志判断出代码在哪一行飞出去了。
第三,写一个简单的资源泄漏自检函数,在开发期周期性地打印当前进程句柄数和对象实例数,跑长稳测试时挂上去。曲线应该是平的,一旦发现有斜率,就说明有 finally 漏了。
第四,Delphi 11 的调试器在异常断点上做得比较方便,把"抛出异常时中断"打开,再配合调用栈,能很直观地看到 finally 有没有被执行——断点停在 finally 的首行,说明路径是对的;没停就直接跳到外层,那就说明中途有出口绕过了它。
5. 关于风格与取舍的一些个人习惯
5.1 代码风格上的几条约定
清理语句的书写顺序我统一成"关闭 → 置空 → 释放",也就是先Close断掉外部依赖,再把引用置 nil 防止误用,最后Free回收内存。这个顺序在多线程环境下很关键,因为析构过程中对象的字段还在,别的线程如果恰好拿到引用就会读到半死不活的状态。
另一个约定是try 的范围要尽量小。有些人的写法是整个方法体包一个大 try,finally 里堆七八行清理,看着整洁,实际上出问题时根本分不清是哪一块清理失败。我倾向于按资源配 try,一个 try 对应一类资源,嵌套三层也比一个巨型 try 好维护。
还有一处细节:清理代码里不要调用可能触发业务逻辑的方法。我见过在 finally 里调用事件回调的写法,回调里又去操作已经释放了一半的对象,结果就是随机崩溃。清理阶段就应该"只做减法",不做任何可能引发新分配的调用。
5.2 什么时候我会主动放弃 finally
finally 不是万能的。有几种场景我反而会避开它,改用别的结构。
一是纯粹的短生命周期值对象,比如一个只在三行代码里用到的临时TStringList,我会考虑改用接口化的临时对象或者记录类型,让编译器在作用域结束时自动回收,省掉一层缩进。二是确定不会抛异常的纯计算逻辑,硬塞一个 try...finally 只会让人误以为这里有资源需要关心,反而是噪音。三是涉及异步和回调的场景,finally 只能覆盖同步执行路径,回调里抛出的异常它管不着,这种地方要靠状态机或者显式的会话管理,而不是指望一个 finally 兜住全部。
最后再分享一个我最近在做的小调整:把异常处理的日志格式统一成"入口 → 清理 → 出口"三段式,每段带同一个流水号。这样一段业务流程在日志里的轨迹非常清楚,哪一步的清理没走到,扫一眼就知道。这个改动本身不涉及任何技术难点,但把排查效率提了一大截,比在代码里多写十行注释有用得多。