技术文章

用 HotXLS 深度重算审计 Excel 公式缓存

HotXLS 回答的是每条电子表格流水线迟早要问的问题:工作簿里存着的数字,还能不能对上产出它们的公式。CalculateAndVerify 把整张依赖图重算进一个隔离的覆盖层,把每个结果与单元格里已有的缓存值对比,然后报告不一致之处。默认情况下它什么都不改

这件事重要的原因在于:电子表格文件对每个公式单元格存两样东西——公式本身,和某人最后一次算出的值。Excel 让两者保持同步,而世界上其他一切都不保证。一份经过老库、一次不完整重算、一次手工编辑的 XML 部件、或者一个只写值不重算的工具的文件,会欣然呈现一个已经不能从输入推导出来的合计数,而文件格式里没有任何东西标记这件事

为什么缓存值与公式不一致如此危险?

因为它在所有常规读取路径上都不可见。用查看器打开文件、通过 API 读单元格、导出成 CSV 或 PDF,你拿到的都是缓存数字。公式就在同一个单元格里,却没人去比对。不一致只在有人用 Excel 打开这个工作簿时才浮出水面——大多数设置下 Excel 会在加载时重算——于是上一季度已经签字的报表突然显示了不同的合计

这个审计存在的意义,就是让那场比较成为一次刻意安排、按计划执行的操作,而不是一场事故。它相当于电子表格版的校验和验证:便宜到可以放进接入流水线里跑,也是唯一能把一个静默的数据完整性问题变成一份你能采取行动的报告的东西

var
  Book: TXLSWorkbook;
  Options: TXLSRecalcAuditOptions;
  Report: TXLSCalculationAuditReport;
  I: Integer;
begin
  Book := TXLSWorkbook.Create(nil);
  try
    Book.LoadFromFile('quarterly-close.xls');
    Options := TXLSRecalcAuditOptions.Default;
    Options.MaxIssues := 500;
    Report := Book.CalculateAndVerify(Options);
    try
      for I := 0 to Report.Count - 1 do
        if Report[I].Kind = xlcaiCacheMismatch then
          Writeln(Report[I].SheetName, '!',
                  Report[I].Row, ':', Report[I].Col, '  ',
                  Report[I].Formula,
                  '  cached=', VarToStr(Report[I].Actual),
                  '  recomputed=', VarToStr(Report[I].Expected));
      if Report.Truncated then
        Writeln('issue budget reached, raise MaxIssues');
    finally
      Report.Free;
    end;
  finally
    Book.Free;
  end;
end;

一共有三个重载,回答三个不同的问题。无参的 CalculateAndVerify 返回不一致计数,健康检查用这个就够了。带 out 不一致数组的重载给你具体单元格。接受 TXLSRecalcAuditOptions 的重载返回完整的 TXLSCalculationAuditReport——当你不仅想知道哪个值对不上、还想知道审计为什么没能求值某处时,用这个

覆盖层,以及审计为什么不写

每个重算出的值都落进覆盖层,而不是单元格缓存,而且覆盖层注入在两套工作簿引擎的单元格读取回调的最前端。正是这个位置让审计自洽:B1 被重算之后,依赖 B1 的 C1 看到的是本次审计产生的值,而不是陈旧的缓存值。没有这一点,一个上游错误只会被报告一次然后被吸收,下游每个单元格看起来都与一个错误的输入「相安无事」

重算值与缓存一致的单元格完全不进入覆盖层。这不是微优化,这是审计能保持低成本的原因。一份带十万条公式的干净工作簿执行零次覆盖层写入,整趟开销控制在完整重算的 1.35 倍以内——这就是「每次接入都能跑」与「一个季度才敢跑一次」之间的差别

HotXLS 深度重算审计流水线:工作簿加载时缓存原样不动,每个依赖节点先标记为脏、按拓扑序各求值一次,重算值落进一个隔离覆盖层,两套引擎的单元格读取回调都先查它,结果与缓存值对比,经 CalculateAndVerify 归类进 TXLSCalculationAuditReport,全程不向磁盘写入任何东西
重算值落进单元格读取回调之前的覆盖层,一致的单元格从不碰它,磁盘上的工作簿保持原样,除非 ApplyResults 在一趟完全干净的审计后提交

求值按依赖图推导出的串行拓扑序进行,所有节点先标记为脏,所以每个单元格都在其输入之后恰好计算一次。如果你要的是让一个活动工作簿保持最新值的增量机制,而不是审计一个存档的工作簿,那是另一套机制,见 增量重算与依赖图

失败是分类的,不是一锅端

一个审计无法求值的单元格,与一个值对不上的单元格,不是同一类发现,TXLSCalculationAuditIssueKind 把类别分开保管。xlcaiCacheMismatch 是值不一致。xlcaiMissingFunctionxlcaiMissingName 表示求值器遇到了它没实现或无法解析的东西。xlcaiUnsupportedArguments 覆盖受支持子集之外的参数形态。xlcaiExternalReferenceDeniedxlcaiExternalReferenceMissing 把「策略拒绝」和「工作簿缺席」分开。xlcaiCircularReferencexlcaiDataTableSkippedxlcaiParseFailurexlcaiCancelledxlcaiInternalFailure 补全整个集合

HotXLS 审计问题分类:TXLSCalculationAuditIssueKind 把报告为 xlcaiCacheMismatch 的值不一致,与求值失败类别分开——xlcaiMissingFunction、xlcaiMissingName、xlcaiUnsupportedArguments、xlcaiExternalReferenceDenied 与 xlcaiExternalReferenceMissing 这一对,以及 xlcaiCircularReference;正的 Excel 错误码算作结果而非失败
一个类别报告值不一致,其余类别报告求值器为什么无法判定某个单元格;Excel 错误值是一种计算结果,所以刻意保留的错误单元格产出的发现是零

有一个区别值得点明,因为它颠覆一个常见假设。正的 Excel 错误码是一个结果,不是失败。一个名正言顺地算出 #DIV/0! 的单元格已经正确完成了计算,所以审计把这个错误存进覆盖层,像对待任何其他值一样与缓存比对。一份充满刻意错误单元格的工作簿产出的发现是零;而一份自缓存以来错误出现或消失的工作簿,产出的恰好是你想要的那些发现

循环引用得到单独的待遇。环上的节点永远进不了拓扑序,所以每一个都被单独报告为 xlcaiCircularReference,审计不运行迭代求解器。这是一份刻意的只读契约:迭代是否开启影响的是结果码该怎么解释,而不是审计做什么。迭代求值的机制在 迭代计算与循环引用里单独讲述

读一条失败链

一条公式求值失败时,只知道哪个单元格失败通常不够,因为失败往往藏在引用链的三层之下。因此每个问题携带一个 Stack 字符串,最外层帧在前,形如 Sheet1!A1 > Sheet1!B2 > Data!C7,报告指向的是真正坏掉的那个单元格,而不是你碰巧在看的那一个

记录器是有界的。MaxStackFrames 默认 64、下限 8,被保留的是最深的那条失败链:失败在某内层帧发生时由它记下链条,之后外层帧展开时不会覆盖它。如果有任何链条超出了预算,Report.StackTruncated 会被置位——它告诉你「这条链很短」和「这条链你没看全」之间的区别

HotXLS 审计失败链:引用链三层之下的公式失败时,Stack 以最外层帧在前渲染——Sheet1!A1 然后 Sheet1!B2 再 Data!C7;最内层帧记录链条且外层帧展开不覆盖,MaxStackFrames 默认 64、下限 8,Report.StackTruncated 标记你没看全的链条
Stack 以最外层帧在前渲染,报告因此指向真正坏掉的单元格;被保留的是最深那条失败链,StackTruncated 把短链与被截断的链区分开
// 默认只读。ApplyResults 只在一趟完全成功的审计之后提交
// 覆盖层,且处于一个写守卫之下:审计运行期间工作簿结构
// 一旦变化,提交即被拒绝
Options := TXLSRecalcAuditOptions.Default;
Options.ApplyResults := True;
Options.AbsoluteTolerance := 0;     // 精确比较,让漂移现形
Options.RelativeTolerance := 0;
Options.OnProgress := HandleProgress;

Report := Book.CalculateAndVerify(Options);
try
  if Report.Applied then
    Book.SaveToFile('quarterly-close-repaired.xls')
  else
    Writeln('not applied: ', Report.Count, ' issues blocked the commit');
finally
  Report.Free;
end;

procedure THarness.HandleProgress(ASender: TObject;
  ACurrent, ATotal: Integer; var ACancel: Boolean);
begin
  ACancel := FUserRequestedStop;   // 审计在下一个节点边界停下
end;

什么时候可以让审计修复工作簿?

仅当审计回来的结果完全没有失败类问题时——这正是 ApplyResults 替你强制执行的条件。提交发生在一趟完全成功、未被取消、并通过结构守卫的审计之后:二进制引擎盯着一个工作簿变更标识符,OOXML 引擎快照每张工作表的结构代号。审计运行期间有任何东西动了,结果描述的就是一个已经不存在的工作簿,提交被拒绝

注意这里刻意的不对称。缓存不一致不阻止应用,因为它们恰恰是提交要修复的东西。失败类问题会阻止它,因为一份有些公式无法求值的工作簿只会被修一半,而一份修了一半的工作簿,比一份你知道该怀疑的、没修的工作簿更糟

容差是策略决定,不是默认值

默认比较是 1E-6 绝对容差、相对容差关闭,既保留经典行为,也悄悄接受 4E-7 量级的漂移。这通常是对的:产出文件的求值器与当前求值器之间的浮点求值顺序差异,在长连加里就会产生这个量级的不同,把它们报成完整性发现纯属噪音

当问题不同时,把两个容差都设为零——比如你想弄清某个求值器在版本之间行为变没变,或者某个第三方工具是否在以微妙不同的方式改写值。归零之后,同一个 4E-7 漂移变得可见,其他一切也一样。按你在问哪个问题来选容差,并把选择记录在报告旁边——因为没有容差的报告是没法解读的

两个相邻的能力补全整个图景。想知道单条公式为什么产出那个值时,公式求值追踪器的逐步视图是合适的工具。刻意要让缓存值生效、完全不做重算时——例如一条必须原样复现来件的接入路径——那种模式在 不重算读取缓存的公式值里有描述。审计就站在这两者之间:它告诉你信任缓存是否安全。它随 HotXLS Delphi spreadsheet component 提供,二进制与 OOXML 两套引擎都支持