行业惯例

语义化版本控制规范(SemVer)是一种常见的软件版本命名方式。

SemVer的主要规则为:

版本格式:主版本号.次版本号.修订号,版本号递增规则如下:
主版本号:当你做了不兼容的 API 修改,
次版本号:当你做了向下兼容的功能性新增,
修订号:当你做了向下兼容的问题修正。
先行版本号及版本编译信息可以加到“主版本号.次版本号.修订号”的后面,作为延伸。

这段话里面有2个关键概念:向下兼容、API。怎么理解“向下兼容”?我认为不能将“向下兼容”理解为“任何可能的输入对应的输出都不会变”,这样的话就没有软件更新的必要了。因此,原文将主版本号的“不兼容”限定在了“API修改”范围内,次版本号和修订号只需要保证API层面的兼容性。

以依赖包为例,从兼容性角度看,个人理解的SemVer规则是:

  • 修订号变更:只包含bug修复,无功能新增或修改,绝大多数调用方可以无感升级;
  • 次版本号变更:包含功能新增或修改,但已有接口的签名不变;绝大多数调用方的程序在升级后依然可以正常编译,但运行时的逻辑可能存在变化;
  • 主版本号变更:包含已有接口的删除或修改,相当一部分调用方的程序在升级后编译报错。

实践经验

修订版本可以无感升级吗?

一种常见的看法是,修订版本不存在破坏性变更,因此可以无感升级。例如,从x.y.2版本升级到x.y.12版本,虽然升了10个小版本,但因为只是修订版本的变更,所以也不需要专门测试。
软件包严格遵循SemVer规则的前提下,上述看法是合理的。但是,很多软件包往往自定义一套版本号规则,或者干脆不按规则发版,导致“修订”版本也可能引入破坏性变更。
以JDK为例,从JDK 10开始,JDK版本号遵循一套自定义的版本号规则。这套规则的格式为$FEATURE.$INTERIM.$UPDATE.$PATCH.,具体来说:

  • $FEATURE:功能版本号,可能包含不兼容变更。JDK每6个月发布一个功能更新,因此功能号每6个月递增一次。
  • $INTERIM:中期版本号。在JDK现行的每6个月发布一个功能版本的模式下,不存在中期版本,因此中期版本号总是0。
  • $UPDATE:更新版本号,包含安全问题修复、回退(regression)、bug修复等。每3个月递增一次。
  • $PATCH:补丁版本号,包含紧急发布补丁。

例如,自2021年9月发布至今,JDK 17一共发布了14个更新版本(包含2个补丁版本),最新版本号为17.0.12

基于JDK版本号的格式,我们可以得出一个貌似正确的结论:JDK将SemVer定义的“次版本号”并入“主版本号”并称为“功能版本号”,并且在每个功能版本内只发布较小的修订版本。因此,将JDK由17.0.2升级到17.0.12似乎不需要经过全面测试就可以被发布上线。

为了弄清楚“JDK修订版本能否无感升级”,我们来看下JDK的修订版本具体更新了哪些内容。JDK从17.0.217.0.12发生的变更很多,这里只举三方依赖库的例子:

三方依赖库 JDK 17.0.2 JDK 17.0.12
FreeType 2.10.4 2.13.2
HarfBuzz 2.8 8.2.2
libpng 1.6.37 1.6.40
LCMS 2.12 2.16
ICU4J 67.1 67.1
Unicode 13.0.0 13.0.0

可见JDK的“修订”版本会更新三方依赖库的版本,并且三方依赖库的更新包含了次版本号更新甚至主版本号更新(例如HarfBuzz)。那么如果三方依赖库在中间某个版本引入了破坏性变更,JDK的“修订”版本自然也会包含这个变更,导致线上故障。
有同学会说,FreeType、HarfBuzz这些库都是在java.desktop路径下,服务端程序不会被这些依赖库的更新所影响,其实不然。比如在生成报表时我们往往需要在服务端渲染PDF,而itextpdf渲染PDF的时候就会调用FreeType实现文字的渲染。因此,FreeType的版本更新可能会影响报表的渲染效果,造成线上故障。

总结:修订版本能否无感升级,取决于具体每个软件包的版本号规则和规则执行情况。以JDK为例,它的修订版本是可能引入破坏性变更的,显然不能无感升级。比较稳妥的做法是,根据软件包的提交记录而非版本号来判断能否无感升级;任何依赖包版本的变更(包括修订版本变更),都需要经过完整的测试才能上线

修复安全漏洞一定要升级到最新版吗?

观察到这样两种现象:

  1. 在修复依赖包安全漏洞时,安全部门的同事往往会指定一个特定的并且较高的软件版本,要求所有线上服务升级到这个版本。例如,为了解决fastjson的安全漏洞,将线上服务的fastjson版本从1.2.4x升级到1.2.83,过程中需要解决很多额外的代码兼容性问题,影响漏洞修复速度和服务稳定性。
  2. 随日常迭代滚动更新依赖包,使得依赖包始终保持最新。这种做法在业务开发团队应该很少见😂,但JDK的确是这么做的。例如,JDK 17.0.9将FreeType升级到2.13.0,JDK 17.0.11又将FreeType升级到2.13.2,尽管FreeType的2.13.x版本并未修复任何安全漏洞或者增加大的功能。我理解JDK这么做可能是为了避免在遇到安全漏洞时跨版本升级。

不评价上述两种做法的好坏,只介绍另一种解决方案:backport补丁。

举例,在不同版本的Rocky Linux系统上执行命令yum list installed | grep freetype,得到的版本号是不一样的:

三方依赖库 Rocky Linux 8.5 Rocky Linux 8.10
FreeType 2.9.1-4.el8_3.1 2.9.1-9.el8

FreeType本身的版本号都是2.9.1,但后缀不同,后缀代表的就是backport补丁版本。补丁版本是由RHEL引入的,Rocky Linux作为RHEL的下游自然包含相同的软件包。对于RHEL 8.5,FreeType包的补丁版本为4;对于RHEL 8.10,FreeType包的补丁版本为9
补丁版本是RPM包管理器的一项功能,具体可以参考官方文档。简单来说,RPM支持在原始版本代码的基础上,通过git diff的方式引入代码变更,从而修复漏洞或bug。
通过下载RPM包的源码,我们可以很清楚地看到补丁功能的原理。

在Rocky Linux 8.10上执行以下命令,下载并解压RPM源码包:

yum install yum-utils
yumdownloader --source freetype.x86_64
rpm2cpio freetype-2.9.1-9.el8.src.rpm | cpio -ivdm

当前目录下会新增这些文件:

-rw-r--r-- 1 root root  618 Sep 30  2022 freetype-2.2.1-enable-valid.patch
-rw-r--r-- 1 root root  618 Sep 30  2022 freetype-2.3.0-enable-spr.patch
-rw-r--r-- 1 root root  460 Sep 30  2022 freetype-2.5.2-more-demos.patch
-rw-r--r-- 1 root root  340 Sep 30  2022 freetype-2.6.5-libtool.patch
-rw-r--r-- 1 root root 2.0K Sep 30  2022 freetype-2.8-multilib.patch
-rw-r--r-- 1 root root  876 Sep 30  2022 freetype-2.9.1-avoid-invalid-face-index.patch
-rw-r--r-- 1 root root 5.1K Sep 30  2022 freetype-2.9.1-covscan.patch
-rw-r--r-- 1 root root  754 Sep 30  2022 freetype-2.9.1-guard-face-size.patch
-rw-r--r-- 1 root root 1.4K Sep 30  2022 freetype-2.9.1-png-bitmap-size.patch
-rw-r--r-- 1 root root  789 Sep 30  2022 freetype-2.9.1-png-memory-leak.patch
-rw-r--r-- 1 root root 1.2K Sep 30  2022 freetype-2.9.1-properly-guard-face-index.patch
-rw-rw-r-- 1 root root 1.9M Sep 30  2022 freetype-2.9.1.tar.bz2
-rw-r--r-- 1 root root  516 Sep 30  2022 freetype-2.9.1-windres.patch
-rw-r--r-- 1 root root 2.9K Sep 30  2022 freetype-2.9-ftsmooth.patch
-rw-rw-r-- 1 root root 2.1M Sep 30  2022 freetype-doc-2.9.1.tar.bz2
-rw-r--r-- 1 root root  35K Sep 30  2022 freetype.spec
-rw-rw-r-- 1 root root 228K Sep 30  2022 ft2demos-2.9.1.tar.bz2
-rw-r--r-- 1 root root  257 Sep 30  2022 ftconfig.h

可见RPM源码包freetype-2.9.1-9.el8.src.rpm主要包含3个部分:

  • FreeType 2.9.1官方发布的源码freetype-2.9.1.tar.bz2
  • 若干后缀为.patch的补丁文件
  • 配置文件freetype.spec

对于补丁文件,以freetype-2.9.1-guard-face-size.patch为例,它的内容为:

From 0c2bdb01a2e1d24a3e592377a6d0822856e10df2 Mon Sep 17 00:00:00 2001
From: Werner Lemberg <wl@gnu.org>
Date: Sat, 19 Mar 2022 09:37:28 +0100
Subject: [PATCH] * src/base/ftobjs.c (FT_Request_Size): Guard `face->size`.

Fixes #1140.
---
 src/base/ftobjs.c | 3 +++
 1 file changed, 3 insertions(+)

diff --git a/src/base/ftobjs.c b/src/base/ftobjs.c
index 6492a1517..282c9121a 100644
--- a/src/base/ftobjs.c
+++ b/src/base/ftobjs.c
@@ -3409,6 +3409,9 @@
     if ( !face )
       return FT_THROW( Invalid_Face_Handle );
 
+    if ( !face->size )
+      return FT_THROW( Invalid_Size_Handle );
+
     if ( !req || req->width < 0 || req->height < 0 ||
          req->type >= FT_SIZE_REQUEST_TYPE_MAX )
       return FT_THROW( Invalid_Argument );
-- 
2.35.1

通过补丁文件内容可以发现:

  • RPM补丁文件以git diff格式保存代码变更,变更内容就是一个git commit,甚至在GitHub上能找到原始提交
  • RPM补丁功能能够将新提交合入老版本。FreeType 2.9.1发布于2018年5月,而这个补丁提交于2022年3月19日。如果没有补丁功能,用户只能通过将FreeType升级到2.12.0才能获得这个bugfix;得益于补丁功能,用户只需要升级到2.9.1-9这个补丁版本,避免其它功能受到影响

此外,freetype.spec文件也包含很多信息,例如所有补丁版本的发布说明,这里不赘述。

回过头来看RHEL,可以发现RHEL保证了在同一个大版本(例如RHEL 8,或者说CentOS 8、Rocky Linux 8)内的三方依赖库版本的一致性。例如从RHEL 8.5到8.10,FreeType一直是2.9.1版没有变,只是通过补丁机制将新版本中重要的bugfix合入2.9.1版的代码。这或许是一种值得借鉴的做法。

Java生态内其实也有类似的做法。例如在2022年,fastjson就发布了很多老版本的noneautotype包,在修复漏洞的同时保证了代码逻辑的兼容性。

总结:修复安全漏洞一定要升级到最新版吗?不一定,通过backport修复代码到历史版本也同样能解决问题。