技术文章

HotPDF:Delphi 流式加载远程 PDF 的区间合并

HotPDF 可以从你实现的任意随机访问源加载 PDF,而 THPDFCoalescingRandomAccessSource 会包装这个源,把解析器分散的小块读取合并成一组有界的缓存区块区间,并配合异步预取。对于通过 HTTP 区间请求提供的文档,这意味着几百次往返和几十次往返之间的差别

解析器本身没有任何变化。你仍然调用 LoadFromRandomAccessSource,返回的还是同一个文档对象,页面 API 也照常工作。变化的只是底层的网络流量

为什么同一份 PDF 在本地秒开,在网络上却慢如蜗牛?

因为 PDF 解析器不是在"读"一个文件,而是在"导航"一个文件。它先跳到文件末尾找 startxref,再跳回交叉引用表,解析尾部字典,跟随引用找到 Catalog,再到页面树根节点,再到某个页面节点,最后到它的资源字典。这一连串步骤中的每一步,都要从不同的偏移量读取几十字节

在本地文件上,这种访问模式几乎不花钱:操作系统早就把周围的 4 KiB 页缓存住了,第二次读取的代价只是一次 memcpy。但在网络传输上没有这种局部性可言。每一次读取都是一个带独立延迟的请求,300 次串行请求、每次 40 毫秒,就是十二秒几乎全部花在等待上。解决办法不是少读——解析器需要的就是它要求的那些数据——而是让每一次物理读取覆盖更多下一次逻辑读取会用到的内容

合并读取改变了什么

合并读取源会把每一次读取都向上取整到一个区块,并缓存这个区块。BlockSize 默认值为 262,144 字节,MaxCacheBytes 默认值为 2,097,152 字节,因此默认会常驻八个区块,并按最近最少使用(LRU)的顺序在一个硬性字节预算下淘汰。解析器对尾部关键字的一次 40 字节读取,会带出周围的 256 KiB,而交叉引用表和 Catalog 数据恰好就在这附近,接下来那十几次读取都会直接从内存中提供

你自己实现的源可以保持简单。实现 GetSizeReadAt,如果你的传输层支持中途中止,就重写 ReadAtCancellable,其余的缓存、合并和预取都交给包装器处理

type
  THttpRangeSource = class(THPDFRandomAccessSource)
  private
    FClient: TMyHttpClient;
    FUrl: string;
    FSize: Int64;
  public
    function GetSize: Int64; override;
    function ReadAt(Offset: Int64; var Buffer; Count: Longint): Longint; override;
    function ReadAtCancellable(Offset: Int64; var Buffer; Count: Longint;
      CancellationToken: THPDFCancellationToken): Longint; override;
  end;

var
  Raw: THttpRangeSource;
  Cached: THPDFCoalescingRandomAccessSource;
  Pdf: THotPDF;
begin
  Raw := THttpRangeSource.Create('https://files.example.com/contract.pdf');
  // OwnsSource=True:包装器会随自身一起释放 Raw
  Cached := THPDFCoalescingRandomAccessSource.Create(Raw, True, 262144, 8388608);
  Pdf := THotPDF.Create(nil);
  try
    Cached.AsyncPrefetchEnabled := True;
    Cached.AdaptiveReadAheadEnabled := True;
    Cached.MaxReadAheadBlocks := 8;

    if Pdf.LoadFromRandomAccessSource(Cached, True) = 1 then
      RenderFirstPage(Pdf);
  finally
    Pdf.Free;
  end;
end;

应该预读多远?

自适应预读针对每份文档给出这个问题的答案,而不是逼你去猜。启用 AdaptiveReadAheadEnabled 后,随着持续的向前读取不断累积,预读窗口会依次经过 1、2、4、8 个区块增长,且永远不会超过 MaxReadAheadBlocks 或已配置的缓存容量。一旦某次读取的位置不再大致衔接上一次读取的结束点,窗口就会立即收缩,预取也会被抑制

SequentialReadToleranceBytes(默认 4,096)定义了"大致"这个词的边界。落在上一次读取结束位置这段距离以内的读取仍然算作顺序读取,这一点很重要,因为 PDF 解析器遍历内容流时不会产生完全连续的偏移量——这里跳过一个长度字段,那里跳过一个内联字典。容差设得太低,正常的向前扫描会被误判为随机访问,预读永远不会启动。容差设得太高,真正的随机访问又会被当成顺序访问,白白多取用不上的若干兆字节。默认值是为内容流遍历校准的,实际统计数据会告诉你你的传输层是否不吃这一套

这种不对称是刻意设计的:增长是渐进的,收缩是立即的。在随机访问的负载模式下过度预取,在计量计费的传输链路上会消耗实实在在的带宽和金钱,所以宁可犯代价小的错误,也不犯代价大的错误

真正能中止传输的取消机制

基类声明了 ReadAtCancellable,合并读取源会全程遵守这个约定。当一个前台读取请求到达、而正在进行的预取又没有覆盖这个区间时,预取会被取消而不是放任它跑完,这样用户的页面请求就不会排在投机性流量后面等待。THPDFRandomAccessSource 上的默认实现会退回到普通的 ReadAt,也就是说这项能力是按传输层各自选择是否启用的:支持中止请求的 HTTP 客户端能获得真正的取消能力,更简单的源则照常工作不受影响

把这一点和贯穿你 UI 层的取消令牌结合起来,用户关闭文档时就能真正中止网络流量,而不是干等它跑完。同一套令牌模型也支撑着带请求队列的后台渲染一文中描述的排队机制,因此一个令牌就能覆盖从视口到套接字的整条路径

读取区间缓存统计信息

GetStatistics 会填充一个 THPDFRangeCacheStatistics 记录,把传输层做了什么和缓存做了什么区分开来。SourceReadCountSourceBytesRead 是物理流量。CacheHitCountCacheMissCount 是逻辑流量。SequentialReadCountRandomReadCount 显示访问模式被归类成了什么,CurrentReadAheadBlocksPeakReadAheadBlocks 显示预读窗口打开到了多大,PrefetchRequestCountPrefetchCompletedCountPrefetchCancelledCountSuppressedPrefetchCount 则显示这些投机预取是否值回了成本

var
  S: THPDFRangeCacheStatistics;
begin
  Cached.GetStatistics(S);
  Log(Format('physical %d reads / %d bytes, hits %d, misses %d',
    [S.SourceReadCount, S.SourceBytesRead, S.CacheHitCount, S.CacheMissCount]));
  Log(Format('pattern: %d sequential, %d random, peak window %d blocks',
    [S.SequentialReadCount, S.RandomReadCount, S.PeakReadAheadBlocks]));
  Log(Format('prefetch: %d issued, %d completed, %d cancelled, %d suppressed',
    [S.PrefetchRequestCount, S.PrefetchCompletedCount,
     S.PrefetchCancelledCount, S.SuppressedPrefetchCount]));
end;

有三种读数会告诉你该调整什么。取消的预取很多、同时随机读取的比例也很高,说明文档被乱序访问,这时应该调低 MaxReadAheadBlocks,别再为白白丢弃的带宽买单。未命中很多、但预读窗口的峰值始终停留在 1,说明容差把一个实际上属于顺序访问的模式当成了随机访问,这时应该调高 SequentialReadToleranceBytes。而读取的字节数远远超过文件本身的大小,说明缓存在抖动,这时应该先调高 MaxCacheBytes,别的先不要动

线性化文件会改变整个算法

如果你能控制生成端,把文档线性化改变的是问题本身,而不只是优化了问题。线性化的 PDF 会把第一页的对象和一张提示表放在文件开头,这样查看器只需要读取开头这一兆字节左右,就能渲染出第一页,不需要看到文件的其余部分。HotPDF 通过 GetProgressiveLinearizedLoadInfoReadProgressiveLinearizedFirstPageSection 直接暴露了这条路径,写入端在生成带提示表的线性化 PDF一文中有说明

这两种技术可以叠加使用。合并读取让任何文档在慢速链路上都能忍受;线性化让你自己生成的文档能快速呈现第一页。对于那些放在本地磁盘上、但大到装不进内存的文件,直接文件 API 工作流一文中描述的内存映射文件和惰性流路径通常是更好的选择,因为这种场景里本来就没有往返延迟需要摊销

HotPDF 是面向 Delphi 和 C++Builder 的原生 VCL PDF 组件,解析器不依赖任何外部 DLL,并提供完整源码。随机访问源 API、合并读取包装器和渐进式加载入口都记录在 HotPDF Delphi PDF 组件页面