🤖
AI审核中

READING IN THE WILD · 读一篇手记

Spring Boot 增量发布:131 MB 的 JAR,实际只上传约 1 MB

文章摘要

一次真实发版中,131 MB 的聚合 JAR 只上传了约 1 MB 数据。本文拆解嵌套 JAR 的压缩字节复用、三层 SHA-256 校验和恢复代码,并说明可复现构建、暂存切换与发布验收的边界。

最近一次给本站的 Spring Boot 聚合应用发版,最终 JAR 有 131,420,604 字节。代码只改了部分业务逻辑,字体、图片和大多数依赖都没有变化,但直接用 SCP 上传,仍然要发送整个文件。

第一次按最外层 ZIP 条目做增量,传输量降到了约 20 MB。继续拆进发生变化的业务 JAR 后,最终只需要上传 559,640 字节的增量数据,以及 477,730 字节的重建清单。

两者合计 1,037,370 字节,约为完整包的 0.79%。服务器重建出的 JAR 与本地构建产物 SHA-256 完全一致,随后完成启动和业务检查。

这篇文章记录的是一次单 JAR 交付的优化:怎样复用旧包中已经存在的字节,以及怎样确认重建结果可以发布。

1. 先把传输量算完整

这次最终候选包的数据如下。表中的 MB 按十进制计算,1 MB 为 1,000,000 字节。

项目 字节数 约合大小
完整新 JAR 131,420,604 131.42 MB
需要新上传的数据 559,640 0.56 MB
重建清单 477,730 0.48 MB
数据与清单合计 1,037,370 1.04 MB
从服务器旧包复用的数据 130,860,964 130.86 MB

这里有一个容易漏算的部分:清单本身也要上传。如果只说“增量只有 560 KB”,就少算了接近一半的文件传输量。上表没有计入 SSH 协议和少量辅助脚本的开销。

传输数据减少约 99.2%,也不代表整个发版过程快了 99.2%。编译、重建文件、校验、应用重启和健康检查仍然需要时间。这个优化解决的是慢上行链路上的大文件传输。

2. 为什么拆最外层还要传约 20 MB

Spring Boot 可执行 JAR 本身是 ZIP,内部通常有这样的结构:

application.jar
├── META-INF/MANIFEST.MF
├── org/springframework/boot/loader/...
└── BOOT-INF
    ├── classes/...
    └── lib
        ├── business-a.jar
        ├── business-b.jar
        └── third-party-library.jar

在多模块聚合工程里,业务模块也可能作为普通 JAR 放进 BOOT-INF/lib。一个业务 JAR 里除了 class,还包含模板、脚本、字体等资源。

如果增量工具只认识最外层条目,那么修改 business-a.jar 里的一个 class,就会把整个 business-a.jar 视为新数据。未变化的第三方依赖可以复用,但业务 JAR 中那些没有变化的大资源,仍然会被重新发送。

所以第一层增量已经有效,却还不够细。

继续往里看,需要区分两层压缩方式。Spring Boot 2.7 的可执行包要求:嵌套 JAR 对应的外层 ZIP 条目使用 STORED,内层 JAR 自己的条目仍然可以压缩。这是 Loader 能按偏移读取嵌套内容的基础,见官方格式说明。

外层 application.jar
└── business-a.jar            外层条目:STORED
    ├── Example.class        内层条目:可以是 DEFLATED
    └── static/font.woff2     内层条目:可以是 DEFLATED

外层没有再压缩这份业务 JAR,就可以把内层条目的位置换算为外层文件里的绝对偏移,然后直接复用它们的原始字节。

3. 目标是还原新包,而不是重新打一个包

把两个 JAR 全部解压,复制修改文件,再调用 ZIP 工具重新压缩,看起来很直接,但文件顺序、时间戳、extra 字段、压缩结果都可能改变。嵌套 JAR 的外层存储方式也需要保持正确。

这次采用的是另一种恢复方式:以已经构建完成的新 JAR 为唯一目标,逐段决定从哪里取得相同的字节。

每一段只有两种来源:

  • baseline:从服务器上的旧包读取。
  • payload:从本次上传的增量文件读取。

所有段按目标文件顺序连接,最后必须得到与本地新包完全相同的字节序列。服务器不需要再调用 Maven,也不需要重新压缩 JAR。

第一步:给旧包建立索引

先记录旧包的整体 SHA-256,再记录条目的位置、长度以及摘要。对准备继续拆分的业务 JAR,再建立一层内层条目索引。

嵌套条目的标识可以写成:

BOOT-INF/lib/business-a.jar!/static/font.woff2

文件名用于查找候选数据,不能单独作为复用依据。真正决定能否复用的,是对应字节的长度和摘要。

第二步:优先复用完整条目

如果新旧条目的完整字节段相同,直接复制旧包中对应的整段。这样,大多数没有升级的依赖 JAR 不需要进一步解析。

对于发生变化的业务 JAR,只有确认其外层条目为 STORED,才继续拆分内部内容。其他情况可以保守地把整个变化区域放进 payload。

第三步:比较内层的压缩数据

一个 ZIP 本地条目的数据起点,可以表示为:

条目数据起点 = 本地文件头起点 + 30 + 文件名长度 + extra 长度

其中长度都以字节计算。内层数据在整个可执行包中的位置,则是“业务 JAR 的外层数据起点”加上“内层数据相对业务 JAR 的偏移”。

这里比较和复用的是压缩后的原始数据。即使解压后的内容相同,重新压缩产生的字节也未必相同;若压缩字节不同,就上传新字节。

对于不能整段复用的条目,这次实现把新包中的本地文件头、条目尾部数据,以及中央目录等结构数据写进 payload。未变化的压缩数据则从旧包复制。包括 data descriptor 在内的条目尾部区域也不能漏掉。

中央目录直接使用新包里的原始字节,避免自己重写偏移和元数据。复用段与新增段最终必须覆盖整个新包,包括归档结尾。

第四步:生成重建清单

清单的核心结构如下,摘要字段在实际文件中都是完整的 SHA-256,下面仅展示字段含义:

{
  "baselineSha": "旧包整体摘要",
  "sha": "新包整体摘要",
  "operations": [
    {"source": "baseline", "offset": 1024, "length": 4096, "sha": "片段摘要"},
    {"source": "payload", "offset": 0, "length": 300, "sha": "片段摘要"}
  ]
}

offset 是在来源文件里的位置。操作在数组中的顺序,就是目标文件的写入顺序;它不要求旧包中的来源偏移连续。

4. 三层校验分别解决什么问题

只核对新包文件大小是不够的。传输截断、索引错误、旧版本不一致,都可能产生大小接近甚至相同的错误文件。

这次恢复过程保留了三层检查:

检查 主要防止的问题
旧包整体 SHA-256 服务器已经换过版本,清单仍基于旧基准
每段长度与 SHA-256 偏移错误、来源错误、增量数据损坏
重建后整体 SHA-256 段顺序错误、漏写元数据、组合结果不等于目标包

单个片段正确,不代表组合顺序正确;目标文件可被 ZIP 工具打开,也不代表它就是准备发布的那一份产物。

这些摘要解决的是字节完整性和版本对应关系。清单与目标摘要本身仍需要通过可信的发布链路传递,不能把一份来历不明的清单当成真实性证明。

如果允许多人同时发布,还应把现役版本复核放进发布锁保护范围:准备切换时发现现役包已变化,就停止当前流程,重新确认基准与版本顺序。

5. 服务器恢复的核心代码

下面省略 ZIP 索引生成,只展示如何消费清单。它只依赖 Python 标准库,按固定大小读取片段,避免把大段依赖一次性装进内存。

import hashlib
import os
import tempfile
from pathlib import Path

BLOCK = 1024 * 1024

def digest(stream):
    stream.seek(0)
    result = hashlib.sha256()
    for chunk in iter(lambda: stream.read(BLOCK), b''):
        result.update(chunk)
    return result.hexdigest()

def rebuild(old_path, payload_path, manifest, target_path):
    old_path = Path(old_path).resolve()
    payload_path = Path(payload_path).resolve()
    target = Path(target_path).resolve()
    if target in (old_path, payload_path):
        raise ValueError('目标文件不能覆盖输入文件')

    temporary = None
    try:
        with old_path.open('rb') as old, payload_path.open('rb') as payload:
            if digest(old) != manifest['baselineSha']:
                raise ValueError('基准版本已经变化')

            with tempfile.NamedTemporaryFile(
                dir=target.parent, prefix=target.name + '.',
                suffix='.partial', delete=False
            ) as output:
                temporary = Path(output.name)
                total = hashlib.sha256()
                sources = {'baseline': old, 'payload': payload}
                for operation in manifest['operations']:
                    source = sources[operation['source']]
                    offset, remaining = operation['offset'], operation['length']
                    if offset < 0 or remaining < 0:
                        raise ValueError('非法片段范围')
                    source.seek(offset)
                    piece = hashlib.sha256()
                    while remaining:
                        chunk = source.read(min(BLOCK, remaining))
                        if not chunk:
                            raise ValueError('来源文件不完整')
                        piece.update(chunk)
                        total.update(chunk)
                        output.write(chunk)
                        remaining -= len(chunk)
                    if piece.hexdigest() != operation['sha']:
                        raise ValueError('片段摘要不一致')
                if total.hexdigest() != manifest['sha']:
                    raise ValueError('最终产物摘要不一致')
                output.flush()
                os.fsync(output.fileno())

        os.replace(temporary, target)
    finally:
        if temporary is not None and temporary.exists():
            temporary.unlink()

目标应当是暂存目录中的候选包。临时文件与目标文件位于同一个目录,关闭文件并通过整体校验后才替换目标路径。

文中的恢复函数已用这份真实的 131 MB 产物重新验证,得到相同的整体 SHA-256;错误基准、片段损坏、来源截断和段顺序错误也分别验证过,失败时保留原有候选文件并清理临时文件。

这里的 os.replace 只负责一个文件的路径切换,不负责服务重启,也不代表 JAR、环境变量和代理配置之间具备跨文件事务。运行中的应用包不应直接作为这个函数的输出目标。

6. 可复现构建能让增量更稳定

如果同一份源代码每次构建都会改变大量时间戳和归档内容,能够复用的字节就会减少。项目中固定了归档输出时间:

<properties>
    <project.build.outputTimestamp>2026-01-01T00:00:00Z</project.build.outputTimestamp>
</properties>

这是 Maven 提供给相关构建插件的配置入口,插件本身也要支持可复现输出。它不是“一行配置就保证所有环境产物相同”:JDK、插件、依赖、生成文件和构建输入仍可能影响结果。可按 Maven 官方指南检查实际构建差异。

这次发布还从固定提交的干净源码归档构建,避免把工作目录里后续产生的修改混进候选包。增量量是否理想,最终仍然由真实生成的字节决定。

7. 文件重建成功之后,还没有结束

增量恢复通过整体摘要检查,只能说明“拿到了正确的文件”。接下来仍然走原有部署过程:保留现役版本备份、切换候选包、启动服务,失败时恢复。

本站这次发版还检查了实际聚合包里的依赖初始化、各应用的启动与代理链,以及页面是否完整返回。特别是最终 JAR 的运行时检查,不能完全由某个子模块的测试代替:聚合打包时,依赖版本可能重新参与选择。

这部分验证不会因为上传量从 131 MB 降到约 1 MB 就省略。

8. 适用范围

这套方法适合依赖和大资源变化较少、仍需要交付单个可执行 JAR、上传链路比较慢的场景。如果一次升级替换了大部分依赖或资源,增量接近全量很正常,完整上传反而更省事。

如果部署端已经有 rsync,先测量它的实际增量效果;容器部署也可以优先利用镜像分层。没有必要为了相近的收益再维护一套归档解析工具。

本文的索引思路针对已知构建链路产生的普通 ZIP/JAR。ZIP64、加密或分卷归档需要另外处理,不能把简化的偏移解析直接套上去。只对确认使用外层 STORED 的业务 JAR 继续拆分,其他变化区域完整传输,是一个容易验证的边界。

这次优化保留了单 JAR 的交付方式,只把传输单位从“整个文件”细化到“已验证的字节段”。完整产物仍然保留,增量效果和发布正确性也分别有了可以核对的结果。

0 条评论

如果你觉得文章对你有帮助,那就请作者喝杯咖啡吧

评论区 0

头像
 0 条评论