一次招标,一百多家供应商在截止前两小时集中上传投标文件。后台的预览队列从下午四点开始积压,到晚上十点还有几十份没转出来,第二天的开标评审只能改成逐个下载原件、本地打开。运维当时的判断是核数不够,于是把转换服务迁到一台核数翻倍的机器上,队列只快了一点,内存先报警了。
这里真正稀缺的不是核。文档转换属于不可切分的长任务:一份标书从头到尾是一个整体状态,多给它几颗核并不会让它转得更快,而每一个正在转换的进程都会实打实地占住一份内存和一份临时盘。容量该怎么算、卡住不退出的进程该怎么收、字体缺了会捅出什么篓子,这三件事决定预览服务会不会在最关键的半天里掉链子。本篇只谈这一类任务,不涉及视频转码,也不涉及前端渲染框架的使用教程。
先把结论摆出来:转换服务的容量要按并发实例数乘以单任务峰值内存乘以最坏耗时来算,而不是按核数算;超时与僵尸回收必须当成一等公民而不是补丁;字体清单要固化进镜像而不是运行时补齐。下面按这个顺序展开。
做在线预览,第一件事不是选服务器,而是选路径。路径只有两条,各自的成本结构完全不同,选错了后面所有容量计算都是白算。
第一条是服务端转换:把 Office 类、CAD 类、图片类原件在服务端转成 PDF 或逐页图片,前端只负责翻页、缩放、搜索。第二条是前端渲染:把原文件或者 PDF 直接交给客户端的渲染器解析绘制,服务端只当文件仓库。
服务端转换的好处集中在"可控"两个字上。版式在所有客户端上一致,评审人员看到的第 5 页和你看到的第 5 页是同一张图;可以在服务端加明水印暗水印、控制可预览页数、屏蔽下载与打印;转换结果可以缓存复用,同一份标书被二十个人打开只转一次;客户端配置再差也能打开;还能在转换前统一做病毒扫描与内容检查。代价也很直接:要等,要占内存与临时盘,要建队列,原文一旦更新就得重转,转换结果本身也要占存储。
前端渲染的好处是服务端几乎不耗资源,也没有转换等待,PDF 本身是最终版式,跨端一致性相对好。代价是它把不确定性转嫁给了客户端:依赖客户端装的字体和客户端性能,复杂排版容易与原始版式对不上;大文件要整份下载,很吃带宽;水印和防下载在客户端基本不可信,懂点技术就能绕;移动端内存有限,大文档可能直接把标签页搞崩;服务端也难留下统一的"谁看过什么"的日志。
常被问到的一句话是:能不能用浏览器渲染代替转换?答案取决于四件事。第一,版式一致性是不是硬性要求,招投标文件、合同、对账单这类场景答案是肯定的,因为一旦页数和排版对不上,评审结论就可能站不住。第二,需不需要服务端控权限和水印,需要的话前端渲染方案基本不成立。第三,文件有多大、客户端可不可控,如果是内网固定终端,客户端能力可以假设得强一些;如果是公网投标方,什么设备都可能来。第四,有没有留痕要求。
实践中最常见的折中是分层处理:PDF 走前端渲染,服务端只对它做轻量处理,比如页数统计、缩略图生成、权限封装;Office 与 CAD 一律服务端转换;或者服务端先转出 PDF,再把 PDF 交给前端渲染,把重活和显示分开。这样既保留了版式一致性,又不必让每一次翻页都回到服务端。需要强调的是,只要还保留服务端转换这一步,后面关于容量、字体、僵尸进程的讨论就全部适用,因为它并没有消失,只是调用频率降低了。
主流无头转换引擎,无论是办公套件的无头模式还是各类文档处理库,在一次转换里基本是单进程为主,内部并行度有限。这意味着一个任务大约吃满一个核,你给它第二个核帮助很小。多核机器的正确用法是多跑几个实例,而不是让单个任务跑得更快。这个判断是整篇的地基,也是"加 CPU 常常没用"的直接原因。
内存涨在四个地方,按影响从大到小排列通常如下。第一是图片,而且是解码后的原始像素量而不是压缩后的文件体积;第二是页数带来的排版对象树;第三是嵌入字体,每套字体都要加载进内存;第四是中间产物,包括渲染中间位图和临时 PDF。
这里有一个特别容易踩的认知偏差:文件小不等于省内存。一张几 MB 的压缩图片,解码成原始像素后的占用可能是几十 MB 量级,一份二十 MB 且含几十张高清图的文档,峰值内存到 GB 量级并不奇怪。反过来,一份两百 MB 的纯文本扫描 PDF 可能反而很省。所以判断输入成本时,绝不能只看文件体积,要看页数、图像分辨率、色彩位数、是否嵌入字体、是否有复杂矢量对象。
临时目录的 IO 是第二个常被忽略的成本。解包、解压、中间格式落地、渲染中间产物,主要形态是大量随机小文件写入,对 IOPS 敏感而不只是对带宽敏感。这个特性决定了临时目录应该放在本地盘而不是网络存储上,网络存储在小文件随机写上的延迟会被放大成数量级的差距。
还有一个启动期特征:第一个任务往往特别慢,因为要做字体缓存生成与配置初始化。如果对第一个任务设置和后续任务相同的超时阈值,它很可能被误判为失败。处理方式是要么做预热,要么对冷启动任务单独放宽超时。
最后是长尾问题。同为 PDF,十页纯文本和十页满版高清图完全不是一个量级;同为 Office 文档,一个空表格和一个带几万行公式的工作表也完全不是一个量级。所以容量规划里要用最坏耗时,不能用平均耗时,平均值会让队列在真实峰值下彻底失算。
缺字体这件事的危险之处在于它不报错。转换引擎遇到文档里引用了本机没有的字体时,通常不会失败,而是静默回退到另一套字体继续渲染。进程退出码是 0,输出文件存在,页数可能都对,看起来一切正常。
但字体的替换会改变字宽、字高、行距和字间距。这些尺寸一变,换行位置就变,段落高度就变,接着表格跨页、页码偏移、目录标明的页码对不上实际位置、页眉页脚错位。对于合同和标书,这种偏移不是美观问题:条款所在页码变了,引用条款时就会引错;表格被拆到两页,评审时可能漏看一行报价。
中文场景尤其严重。西文缺字时常能回退到字形相近的字体,视觉差异相对可控;中文缺字往往直接显示成方框或空白,而且中文字体文件体积大、字形数量多,一旦缺失影响的字符面也广。这就是为什么很多团队在本地测试一切正常、上线后发现某些文档整页方框——本地装了字体,服务器镜像里没有。
嵌入字体是另一面。文档里嵌入了字体可以保证显示效果,但会显著增大文件体积,而且部分字体的授权条款并不允许随文档自由分发或随服务镜像分发。在做镜像字体清单时,字体授权是需要单独核对的一项,不能因为"网上能下载到"就打包进去。
工程上的处置有四条。第一,把字体清单固化进镜像,作为构建产物的一部分,而不是运行时按需安装;运行时安装会让不同实例之间出现字体差异,同一个文件这次转出来和下次转出来不一样。第二,字体缓存预生成,避免每个任务重复扫描字体目录。第三,字体版本或引擎版本升级要视为基线变化,连带把转换结果缓存失效并重转,否则新旧混用会出现同一份文档两种版式。第四,对缺字体这件事要报警而不是静默通过,可以在转换前做一次字体引用清单检查,发现缺失时把任务标记为"版式风险",而不是假装成功。
还有一条只在前文提到的混合路径下才成立:如果服务端转完再交给前端渲染,前端所在的客户端字体同样会影响最终显示。这种情况下服务端的一致性保证会被客户端字体重新破坏一次,所以更稳的做法是服务端直接转出逐页图片或扁平化的 PDF,让客户端没有任何字体发挥余地。
把前面两条合起来,容量的算法就很清楚了。设 N 为并发实例数,M 为单任务峰值内存,T 为最坏耗时,那么内存基线是 N 乘以 M 再加上系统与其他常驻服务的余量;吞吐大致是 N 除以平均耗时;最坏情况下的排队时长大致是队列长度乘以 T 再除以 N。
核数在这个公式里只扮演一个配角:只要总核数不少于 N 加上系统开销就够了,再往上加对吞吐没有帮助。反过来内存是硬约束,一旦 N 乘以 M 超过可用内存,系统就开始换页甚至触发内存溢出,转换服务本身和它同机的其他服务会被一起拖下去。
这里必须显式限制单实例内存,无论用容器内存上限、控制组还是进程级限制。理由不是省资源,而是改变失败形态:不设上限时,一个异常大文档可以把整机内存吃干,内存溢出 killer 会按自己的规则挑进程杀掉,数据库、缓存、业务进程都可能在名单上;设了上限之后,失败被限制在"这一个任务失败",其余任务和其他服务不受影响。可预期的失败远好过不可预期的整机事故。
配实例数时还要注意超线程。逻辑核不等于物理核,超线程出来的那个逻辑核在密集计算上拿不到完整性能,如果按逻辑核数去配 N,实际吞吐会低于估算。稳妥的做法是按物理核留出余量后确定 N,再用内存公式复核,取两者的较小值。
倒推也很常用。假设峰值两小时内会到达 K 份文件,业务能接受的最坏排队时长是 X,那么 N 约等于 K 乘以 T 再除以 X。这个式子的价值在于它把"要几台机器"这个问题翻译成了"能等多久"这个业务问题,而后者是业务方能回答的。
常被问到的另一句话是:到底该按 CPU 还是按实例数配?答案是按实例数。CPU 只要够每个实例一个核加系统开销,内存按 N 乘 M 配,本地盘按下节的方式配。这三者的配比确定之后,机器选型才有依据。这类「长任务 + 高内存」的转换服务,一万网络(深耕 19 年,成立于 2007 年)能提供的是内存与本地盘配比明确的整机方案,让客户按并发实例数而不是按核数下单。
下面这张表给出的是量级框架,不是可以直接抄用的数字。所有耗时与内存都必须以自有样本实测为准,因为格式、版本、内容复杂度、字体情况的差异可以把同类任务的耗时拉开一个数量级以上。
| 任务类型(页数与格式) | 单任务耗时量级 | 峰值内存量级 | 建议并发上限 | 超时设置 |
|---|---|---|---|---|
| 纯文本 PDF,十页以内 | 毫秒级到秒级,需以自有样本实测为准 | 百 MB 量级以下 | 可设得较高,仍受内存总额约束 | 秒级到十几秒 |
| 图文混排 PDF,几十页 | 秒级到十秒级,需实测 | 数百 MB 量级 | 中等,按内存公式反推 | 三十秒到分钟级 |
| 含高分辨率扫描图的 PDF,上百页 | 十秒级到分钟级 | GB 量级,随分辨率线性放大 | 偏低,建议单独队列 | 分钟级 |
| Office 文档(文档、表格、演示)几十页 | 秒级,复杂排版可到十秒级 | 数百 MB 量级 | 中等 | 分钟级留足余量 |
| 大型表格、复杂排版、嵌入字体多的文档 | 分钟级 | GB 量级 | 很低,建议单独队列限流 | 数分钟 |
| 图纸类或超大单文件 | 分钟级以上 | GB 量级及以上 | 单队列单实例串行 | 需单独设定并单独监控 |
这张表的使用方式是:先用自有样本把耗时量级和内存量级两列填成自己的数字,再按住最坏耗时与峰值内存去定并发上限和超时,最后用前一节的排队时长公式校验能不能满足业务等待要求。顺序不能反,先定超时再定并发,很容易出现"超时设置得太短,正常的大文件也被判失败"的荒谬结果。
临时盘的空间算法和内存同理,是峰值并发乘以单任务临时峰值再乘一个安全系数,再加上输出缓存和系统余量。安全系数存在的理由是转换任务的临时占用不是恒定的,而是在过程中出现一个尖峰,多个任务的尖峰在时间上可能重叠。
同样要记住:单任务临时峰值不等于源文件体积。压缩包的解压、图片的解码、矢量对象的展开都会放大体积,放大倍数因内容而异。用源文件大小去估临时盘,是这类服务最常见的容量事故来源之一。
目录组织上有两条硬性要求。第一,每个任务必须使用独立子目录,目录名带任务 ID,绝不能多个进程共用一个固定目录。共用一个目录时,两个进程写出同名中间文件会互相覆盖,表现出来是"偶发转换出错,重试又好了",这种间歇性故障极难定位。第二,临时目录与输出目录分开,最好落在不同盘上;输出盘写满只影响落盘,临时盘写满会让所有在跑的任务一起失败,是更大的共享故障域。
清理要分三层。第一层是正常退出路径清理,这是最理想的情况。第二层是异常路径清理,包括收到终止信号、超时被杀、引擎内部异常抛出,这些路径必须通过 finally 之类的方式保证清理执行。第三层是定时兜底扫描,按目录年龄清理孤儿目录。第三层不可省略,因为前两层总有覆盖不到的情况,比如进程被强杀、机器异常重启。
还有一个指标容易被漏掉:inode。转换中间产物往往是大量小文件,空间还剩很多但 inode 用完的情况完全可能发生,表现同样是写入失败。所以监控项里除了临时盘使用率,还要有 inode 使用率、孤儿目录数量、最老孤儿目录的年龄。
选型时还有一个容易被忽略的点:多数整机方案默认给的是通用业务配比,容量偏大但速度一般,内存与本地盘的比例也不是按转换服务设计的。转换服务需要的是内存与本地盘都偏重的一类配比,这个配比在下单前与一万网络确认清楚,比事后单独加内存或加盘要省事得多,也避免了加完之后才发现瓶颈其实在临时盘 IOPS 上。
转换服务里有两类"僵尸",需要分开说。一类是严格意义上的僵尸进程,即子进程已退出但父进程没有回收,它占一个进程号但不占 CPU 和内存,危害有限。另一类是僵死进程,进程还活着,占着 CPU 或内存,既不干活也不退出。真正吃掉实例、让队列越排越长的是后者,本篇讨论的重点也是后者。
僵死的成因通常是输入异常:文件结构畸形、压缩包损坏、字体文件损坏、输入文件位于网络存储而网络存储卡住、引擎内部进入死循环或等待一个永远不会到来的条件。这类输入在招投标、合同上传这类由外部人员提交文件的场景里几乎是必然出现的,只是频率高低的问题。
处置要分三层,缺一层都会漏。第一层是队列层的任务超时,任务进入处理中状态后超过阈值未回报结果,就判定为失败并回收槽位。第二层是进程层的软硬超时,先发终止信号给一个优雅退出的时间窗,让其有机会清理临时目录,超时未退再发强制终止信号。第三层是看门狗,定时扫描孤儿进程与孤儿临时目录,兜住前两层漏掉的情况。
杀进程时要注意进程组。转换引擎常常不是孤零零一个进程,它可能派生渲染子进程、字体服务子进程。只杀主进程,子进程可能继续存活并继续占资源,表现为"任务被判失败了,内存却没释放"。正确做法是按进程组或会话整体终止。
退出码为 0 也不等于成功。校验至少要做三件事:输出文件存在且大小合理、页数与预期在同一量级、文件头与格式标识正确。见过的情况是进程正常退出但因为写盘失败留下一个空文件,任务被标记为成功,用户点开预览看到一片空白,而监控上一切正常。
毒丸任务是另一个必须处理的概念。同一个畸形文件如果被判定为超时失败,按常规重试逻辑会再被转一次,然后再次卡住。这个文件会反复消耗实例,直到重试次数用尽。正确做法是把它标记为毒丸,移出主队列,转人工或返回明确的"该文件无法预览"提示,不再进入自动重试。
进程数上限也要设。正常运行时,转换进程数不应超过 N。一旦持续超过,说明有进程没有被正确回收,这时应该报警并触发看门狗强扫,而不是继续接受新任务。
超时阈值设多少合适?没有通用答案,只能给方法:以自有样本在最坏条件下的耗时为基准,再乘一个安全系数。千万不要用平均值,也不要因为"队列太长"就把超时往下调,后者会直接引发下一节说的雪崩。
单一队列的致命问题是队头阻塞。一个要跑几分钟的大文档占住一个实例,后面几十个秒级就能完成的小文档只能干等,整体平均等待时长被一个任务拉高。用户侧的感受是"我传了个一页的 PDF,等了二十分钟",而实际上系统一直在满负荷工作。
解法是分层。按页数、文件大小、格式把任务分成快队列和慢队列,各自分配独立的并发配额。比如总实例数 N 中,慢队列最多占用一到两个槽位,其余槽位永远留给快任务。这样大文档的吞吐会下降,但小文档的等待时长得到保护,而小文档在数量上通常占绝大多数,整体体验反而更好。
入口限流同样必要。要限制同一用户或同一批次同时提交的任务数。招投标场景里,一个供应商可能一次上传十几个附件,如果不限流,这一个用户就能占满全部实例,其他一百家供应商全部排队。
公平性的实现方式是按用户维度做并发配额,而不是只做全局并发控制。全局并发控制只能保证系统不垮,不能保证不被某一个用户垄断。
优先级可以设,但要防饥饿。紧急任务、内部审核任务可以优先,但如果高优先级源源不断,低优先级任务会永远得不到执行。常见的做法是让等待时长参与优先级计算,等待越久优先级越高,即老化机制。对于招投标这类有明确截止时间的场景,还可以按距离截止时间的远近动态调整。
状态机与持久化也不能省。任务状态至少要区分待处理、处理中、成功、失败、毒丸、取消六种。转换服务重启是常事,如果队列只存在内存里,重启就意味着所有进行中的任务丢失且用户侧没有任何提示。持久化之后,重启时可以按状态恢复:处理中的任务重置为待处理重新排队,这要求转换本身是幂等的。
幂等键的设计是去重的基礎,可以取源文件哈希加上转换参数版本、引擎版本、字体版本组合而成。四者任一变化,结果都可能不同,缺一个就会出现"改了参数却命中了旧结果"的错误。命中已有结果时直接返回,不必重新转换。
队列长度也要有上限,超限就降级。降级策略可以是拒绝新任务并提示稍后查看,也可以是只接受小文档、暂缓大文档。无限堆积的队列在用户侧表现为"一直转圈",比明确的失败提示更糟。
最后一条容易被忽略:给用户显示排队位次。重复提交是队列雪崩最常见的来源之一,用户看不到进度就会反复点刷新、反复重新上传,每一次都产生一个新任务。显示位次和预计等待时间,能显著减少这种无效任务。
重试是必需的,因为在分布式系统里临时性失败不可避免。但重试有一个危险的属性:它会放大负载。当失败率上升时,重试让实际进入队列的任务量按倍数增长,可用容量反而下降,于是更多任务超时失败,更多任务进入重试。这就是转换服务的雪崩链条,而它的起点往往只是一个太短的超时阈值。
典型链条是这样的:超时阈值设得偏短,正常的大文件被判为失败;失败触发重试,队列里的任务量翻倍;队列变长,等待时间变长,更多任务在真正开始处理前就耗尽了总时限;失败率进一步上升,重试进一步放大。系统从"有点慢"到"完全不可用",可能只需要一轮峰值。
所以第一条规则是:只在对可恢复失败上重试。可恢复失败包括资源不足、超时、临时性 IO 错误、实例被回收。确定性失败包括文件损坏、格式不支持、加了密码、权限不足,这类失败重试一万次也是同样的结果,只会浪费容量。
第二条规则是退避与抖动。重试间隔要指数退避,并且加上随机抖动,避免大批失败任务在同一时刻同时重试,形成新的尖峰。
第三条规则是设定重试预算。除了单次重试次数上限,还要有总时限预算:一个任务从提交到最终判定失败,总共不能超过某个总时长。超过总时限后即使重试次数没用完也不再重试。
去重是削减无效负载最有效的手段,效果往往比扩容更明显。除了前面说的幂等键,还要在入口处对同一用户短时间内的相同文件提交做合并。用户反复点击、反复刷新、网络重试导致的重复提交,在没有去重的系统里可以占到总任务量的相当比例,这部分负载是纯浪费。
熔断是保护系统的最后一道闸。当某个格式或某个队列的连续失败数达到阈值时,暂停该队列一段时间,只放行少量探测任务。没有熔断时,一批同类型的坏文件可以把全部实例占满,导致本来正常的其他格式任务也一起排队等待。
毒丸隔离在上一节已经提过,这里补充它与熔断的关系:熔断管的是"这一类任务先别来",毒丸管的是"这一个文件永远别来"。两者粒度不同,需要同时存在。
监控指标至少要覆盖这些:队列长度及其变化趋势、等待时长分布而不只是平均值、按原因分类的失败率、重试任务占比、实例占用率、僵死进程数、临时盘使用率与 inode 使用率、孤儿目录数量。其中"重试任务占比"是最容易被漏掉的,而它恰恰是雪崩的早期信号——当这个比例开始上升时,说明系统已经进入了失败放大的正反馈。
两者都有队列、都有超时、都要管临时盘,很容易被当成同一类问题用同一套方案处理,但它们的任务性质差别很大,套用会出问题。
第一,能不能切分。视频转码可以按时间片或按关键帧切片,切成几十段分给多台机器并行处理,最后拼接;文档转换基本不可切分。一份文档的页码、目录、交叉引用、页眉页脚、跨页表格是相互依赖的整体状态,把它按页切成两半各转一半,得到的两个半份在页码编号、目录链接、跨页表格续接上都对不上。这意味着文档转换的并行度只能做到实例级,做不到任务内切分。
第二,失败的后果不同。转码失败通常还有已转好的分片可用,可以续转或降级使用低码率版本;文档转换失败是全量重做,没有中间产物可用,也不会有"先给用户看个低质量版"这种降级选项。
第三,加速手段不同。转码有明显的硬件加速空间,可以用专用编码硬件或 GPU 大幅提速;文档转换主要依赖 CPU 单核性能与内存带宽,可用的加速手段少得多,靠堆硬件的收益有限。
第四,输入成本的可预测性不同。视频的输入成本大致可以用码率乘以时长估出来,误差可控;文档完全不行,二十 MB 的文档可能比两百 MB 的 PDF 更吃内存,因为决定成本的是页数、图像分辨率、嵌入字体、矢量复杂度,而不是文件体积。这直接导致文档转换的任务分级必须基于内容特征而不是基于文件大小。
第五,调度粒度不同。转码可以做细粒度的分片调度与进度续接;文档转换只能靠实例数乘队列这种粗粒度并行,因此队列分层、并发配额、公平性这些机制的重要性远高于转码场景。
共同点是有的:都必须设超时、都必须处理僵死进程、都必须管理临时盘并做清理兜底、都必须防重试放大。所以基础设施可以共用,但调度策略不能照抄。
本篇不提供可以直接引用的耗时与内存数字。原因很简单:转换耗时与峰值内存高度依赖文档内容、格式版本、引擎版本、字体配置与转换参数,任何脱离具体环境的精确数字都是误导。表中给出的只是量级框架,所有具体取值都需以自有样本实测为准。
如果需要建立自己的基线,可以用进程级观测自行核对,不需要任何特殊工具。观察单个转换进程的 CPU 占用核数,可以验证"一个任务吃单核"这个判断是否在你的引擎版本上成立;观察常驻内存随转换过程的变化曲线,可以拿到峰值内存而不是平均内存;观察临时目录在转换过程中的体积变化,可以拿到临时盘峰值而不是源文件体积。
样本选择上要覆盖三个分位:典型小文档、典型中等文档、最坏情况的大文档。只测典型样本是最常见的错误,因为容量是被最坏样本决定的,队列长度乘以最坏耗时才决定用户要等多久。
还有一条容易被忽略:基线会漂移。引擎升级、字体清单变更、转换参数调整、操作系统库更新,都可能改变耗时与内存基线。因此基线要作为版本化的配置项管理,每次变更后重测,并据此复核并发上限与超时阈值是否仍然合适。把基线写进代码注释或者一次性文档,最终一定会和实际情况脱节。
最后的限制说明:本篇讨论的是服务端转换这一类不可切分长任务的容量与稳定性设计,不涉及具体产品的性能比较,也不涉及前端渲染框架的使用细节。不同引擎在资源画像上有差异,落地前应当先用自有样本验证本篇提到的每一条判断,尤其是"单进程吃单核"和"内存随图像解码放大"这两条,它们是后面所有计算的输入。
Copyright © 2013-2020 idc10000.net. All Rights Reserved. 一万网络 科技有限公司 版权所有 深圳市科技有限公司 粤ICP备07026347号
本网站的域名注册业务代理北京新网数码信息技术有限公司的产品