首页
学习
活动
专区
圈层
工具
发布
社区首页 >专栏 >从零开发存储系统(6.1) 如何滚动升级

从零开发存储系统(6.1) 如何滚动升级

作者头像
早起的鸟儿有虫吃
发布2026-01-29 14:09:00
发布2026-01-29 14:09:00
1270
举报

一、对你有什么帮助: 需要发生的背景是什么

假如一个开发了网站,window修改,上传到云端服务器太麻烦了

先不考虑有状态服务,如果实现代码自动化一键部署修改,对创业公司及其重要 先不考虑有状态服务,如果实现代码自动化一键部署修改,对创业公司及其重要。

需要很多框架和平台

Jenkins自动化部署:从代码提交到自动上线的全流

Docker+GitLab实现全流程自动化

做到上面很厉害了。

推荐#ContextOS# 在设计之初解决了这个问题

ContextOS 通过集群化、模块化、自动化的方式, 只用一个 20M 大小的程序文件, 就可以在各种设备上,一键启动完整的云计算服务与云研发环境。

然后 在传统的电信业务,数据库 存储 都需要支持 滚动在线升级, 不影响业务 ,极其复杂。 反复不停测试

例如 版本4.0 升级版本5.0

教训1: 废弃字段

某 Ceph 分布式存储集群(3 副本,100+OSD 节点)升级 OSD 组件, 新版本为优化增量同步性能,废弃了原增量同步字段sync_flag(用于标记同步数据有效性), 开发未做废弃字段的兼容解析,直接忽略该字段

教训2: 脑裂

某 MongoDB 副本集(3 节点)升级,灰度升级 1 个节点后,新版本节点的心跳包格式与老版本不一致,老版本节点判定新版本节点不可用

副本集触发重新投票,因 2 个老版本节点投票互选,新版本节点独立形成小集群,出现脑裂

教训3:新增字段

某企业级自研分布式存储集群,管理 600 + 业务卷,运维执行 MetaDB 节点滚动升级。 新版本为优化池主选举稳定性,在PoolMasterElectionReq池升主请求结构体中新增必选字段master_retry_count,开发未做老版本兼容处理,此 时存储池节点仍为老版本(无法携带该字段);且池升主流程未配置原地重试机制,一次请求失败即终止

老版本消息---新版本

新版本--------老版本

二、滚动升级必须遇到三个问题

消息类型变化

新版本 消息发送到老版本,新版本新增消息,老版本不支持

老版本消息 发送新版本, 老版本支持,新版本废弃

业务不变,新版本发生变化。新老不兼容

消息结构体变化

新增字段 怎办?

废弃字段怎么办?

节点不可用变化

服务不可用,节点直接有依赖关系

HA等需要同步数据

三、解决方案 :消息类型变化

3.1 思路

3.2 举例

TiDB

TiDB 内部的节点间通信(如 PD 与 TiKV、TiDB 与 TiKV 的 RPC 交互)几乎都基于 Protobuf(Golang 生态最主流的序列化协议),这个问题转换Protobuf 怎么解决的。

TiDB

1

新版本→老版本:版本协商 + 消息裁剪 + 特性开关,主动降级消息格式,仅发送对方支持的内容;

2

老版本→新版本:保留废弃字段解析逻辑,解析后转换 / 隔离,不影响核心业务;

3

格式不兼容:引入兼容层做格式转换,双解析 / 双写,滚动升级完成后移除兼容逻辑。

3.3 Ceph序列化如何做到向后兼容 向前兼容

官方严格规定 Mon -> Mgr -> OSD -> MDS -> … 的滚动升级顺序 就是为了让核心仲裁和管理组件(Mon、Mgr)先升级, 从而能管理和协调后升级的数据面组件(如OSD)

ceph的序列化(ENCODE/DECODE)和升级协议兼容性考虑

https://github.com/ceph/ceph/blob/266b666e/doc/dev/encoding.rst#L55-L58

When a structure is sent over the network or written to disk, it is encoded into a string of bytes. Usually (but not always -- multiple serialization facilities coexist in Ceph) serializable structures have encode and decode methods that write and read from bufferlist objects representing byte strings.

Ceph使用ENCODE_STARTDECODE_START宏来管理版本:

版本管理通过struct_v变量在解码时自动处理

ENCODE_FINISH会自动跳过未解码的字节,确保向前兼容性

消息头包含版本信息,用于路由到正确的解码器

我们再次用AcmeClass的例子来说明:

代码语言:javascript
复制
class AcmeClass {
    int member1; // v1
    std::string member2; // v1
    std::vector<std::string> member3; // v2新增

    void encode(bufferlist &bl) const {
        ENCODE_START(2, 1, bl); // (当前版本, 兼容版本)
        ::encode(member1, bl);  // 始终编码
        ::encode(member2, bl);  // 始终编码
        ::encode(member3, bl);  // 作为v2的新字段,编码在最后
        ENCODE_FINISH(bl);
    }
};

1. 实现向后兼容(新读旧) 高版本(v2)

代码语言:javascript
复制
void decode(bufferlist::iterator &bl) {
    DECODE_START(2, bl);
    ::decode(member1, bl); // v1数据里有,正常读
    ::decode(member2, bl); // v1数据里有,正常读
    if (struct_v >= 2) {   // 关键判断!
        // 如果数据是v2格式写的,就读取member3
        ::decode(member3, bl);
    } else {
        // 如果数据是v1格式写的,member3保持默认值(空向量)
        // 业务逻辑需能处理这种情况
    }
    DECODE_FINISH(bl);
}

2. 实现向前兼容(旧读新) 低版本(v1)的解码器知识有限,其代码根本没有member3的概念

代码语言:javascript
复制
// 假设的 v1 解码器
void decode(bufferlist::iterator &bl) {
    DECODE_START(1, bl); // 它只声明自己懂v1
    ::decode(member1, bl); // 读取
    ::decode(member2, bl); // 读取
    // 注意:没有对member3的解码代码!
    DECODE_FINISH(bl); // 在此停止,member3的字节被安全“遗留”在缓冲区中
}

v2版本请求 发送到v1

v1 不会解析,不然core

💎 总结

Ceph通过一套组合拳保证升级平滑:

编码规则只在末尾追加新字段,这是实现向前兼容的铁律。

版本控制ENCODE_START中的双版本号,为解码提供了决策依据。

条件解码if (struct_v >= N) 实现了向后兼容。

特性协商:在通信前主动降级,避免不必要的数据传输和兼容性风险。

oecaebse

Ceph风格:在decode函数里到处是if (struct_v >= 2) { decode(field); },耦合在业务类中。

OceanBase风格:序列化/反序列化入口处一个版本判断,决定走哪一条完整的编码/解码路径。新旧格式的实现被隔离到不同的“数据盒”或成员列表中,结构更清晰,更易于维护和扩展(例如未来增加V3格式)。 向后兼容(新版本读取旧数据)

1

版本识别:新版本通过cluster_version_字段识别数据版本

2

兼容字节解析ObTxSerCompatByte::deserialize动态解析兼容字节 ob_tx_serialization.cpp:211-235

3

默认值填充:对于旧版本不存在的字段,使用默认值

向前兼容(旧版本读取新数据)

1

字段跳过:旧版本通过兼容字节跳过不认识的新字段

2

长度计算get_serialize_size正确计算序列化长度 ob_tx_serialization.cpp:198-209

3

安全忽略:未知字段被安全忽略而不影响解析

代码语言:javascript
复制
int CLZ::serialize(SERIAL_PARAMS) const // 1. 函数定义
{
    // 2. 序列化父类部分
    int ret = P_CLZ::serialize(buf, buf_len, pos);
    // 3. 判断父类序列化是否成功,且目标版本是否为旧版本
    if (OB_SUCC(ret) && cluster_version_ <= CLUSTER_VERSION_4_1_0_1) {
      // 4. 路径A:序列化旧版本成员列表
      LST_DO_CODE(OB_UNIS_ENCODE, CLZ ## _V1_MEMBERS);
    } else if (OB_SUCC(ret)) { // 5. 父类成功,但目标版本是新版本
      // 6. 路径B:创建“数据盒”并序列化全部成员
      CLZ##_box x(const_cast<CLZ&>(*this));
      ret = x.serialize(buf, buf_len, pos);
    }
    // 7. 返回序列化结果
    return ret;
}

高版本发送方在 serialize 时, 如果检测到 cluster_version_ 较低 会检查对方节点版本 这个是不是性能太慢了

1

握手时交换:在节点A和B建立网络连接时,作为握手协议的一部分,双方会交换各自支持的最高协议版本特性位图。这个过程可能发生在TCP连接建立后的第一个应用层握手包里。

2

上下文缓存:一旦握手完成,双方都会将对端的能力信息(最重要的就是那个 cluster_version_)缓存在本次连接的上下文(connection context)中。这是一个存放在内存里的数据结构。

3

高效读取:此后,在该连接的生命周期内,节点A每次要为节点B序列化消息时,它只需要从本地内存中读取这个缓存的 cluster_version_,然后进行简单的整数比较(if (cached_version <= CLUSTER_VERSION_4_1_0_1))。这个操作的成本只是一次内存访问和一次CPU比较指令,与访问一个成员变量无异,开销极低

四、解决方案 :消息结构体发生变化

4.1 思路

Protobuf

序列号 反序列化

预留字段 保证一个结构总大小不变。

4.2 举例 TiDB从使用到优化

TiDB 严格遵循 Protobuf 的向前兼容规范,Golang 解析 Protobuf 时天然支持 忽略未知字段

1. Protobuf 协议的兼容设计(核心)

TiDB 严格遵循 Protobuf 的向前兼容规范,

Golang 解析 Protobuf 时天然支持 “忽略未知字段”,这是兼容的底层保障:

代码语言:javascript
复制
// 以 TiDB 内部的 KV 操作请求消息为例(简化版)
syntax = "proto3";
package tikv;

// 原始版本(老版本)
message KvRequest {
  string key = 1;          // 必选字段
  string value = 2;        // 必选字段
  int64 ttl = 3;           // 可选字段(老版本已有)
}

// 新版本新增字段(向前兼容设计)
message KvRequest {
  string key = 1;          // 不修改已有字段的编号/类型(核心!)
  string value = 2;
  int64 ttl = 3;
  bool async = 4 [deprecated = false, default = false]; // 新增可选字段,带默认值
  string trace_id = 5 [deprecated = false];             // 新增可选字段
}

Golang 解析行为

老版本节点发送的 KvRequest 没有 async/trace_id 字段,新版本 Golang 代码解析时会自动忽略这两个未知字段,仅解析已有字段,不会报错;

新版本节点向老版本节点发送消息时,会主动不填充 async/trace_id(通过版本协商控制),确保老版本解析无异常。

疑问:怎么做到的呢?
TiDB 不是简单依赖Golang 会忽略未知字段,而是主动控制不发送未知字段,这提供了更强的兼容性保障

主动版本控制机制

TiDB 不直接使用生成的 Protobuf 结构体,而是通过 Builder 模式工厂函数 来控制字段填充

DDL 系统的版本检测

TiDB 的 DDL 系统会主动检测集群中所有 TiDB 实例的版本,并根据版本能力选择合适的作业版本:

代码语言:javascript
复制
// detect versions of all TiDB instances and choose a job version to use  
func (d *ddl) detectAndUpdateJobVersion() {
    // 检测所有 TiDB 实例版本  
    infos, err := infosync.GetAllServerInfo(d.ctx)
    // 根据版本能力决定使用 V1 还是 V2  
    if allSupportV2 {
        targetVer = model.JobVersion2  
    } else {
        targetVer = model.JobVersion1  
    }
}

1

BR 系统的版本兼容性检查

BR (Backup & Restore) 系统在执行前会进行严格的版本兼容性检查:

代码语言:javascript
复制
// CheckVersionForBR checks whether version of the cluster and BR itself is compatible  
func CheckVersionForBR(s *metapb.Store, tikvVersion *semver.Version) error {
    // 检查主版本兼容性  
    if BRVersion.Major < tikvVersion.Major || BRVersion.Major-tikvVersion.Major > 2 {
        return errors.Annotatef(berrors.ErrVersionMismatch, "major version mismatch")
    }
    // 检查特定不兼容版本  
    if tikvVersion.Major == 3 {
        if tikvVersion.Compare(*incompatibleTiKVMajor3) < 0 && BRVersion.Compare(*incompatibleTiKVMajor3) >= 0 {
            return errors.Annotatef(berrors.ErrVersionMismatch, "version mismatch")
        }
    }
}
4.3 预留字段

Kafka 的消息格式中,attributes 字段(1 字节)仅使用低 3 位表示压缩类型,高 5 位全部预留,用于后续新增消息属性(如时间戳类型、事务标记等)

https://deepwiki.com/tikv/tikv

https://deepwiki.com/pingcap/tidb

TiDB升级全流程与后问题深度排查:从平滑迁移到故障修复

从6.1.1升级到8.5.0建议

https://deepwiki.com/ceph/ceph

https://deepwiki.com/oceanbase/oceanbase

从零开发一个操作系统(1.1) ContextOS

从零开始写分布式存储系统(6)性能测试三板斧

从零开发分布式文件系统(5.4):如何优化线程模型以提升NVMe SSD性能

从零开发分布式文件系统(5.3):IO操作为什么占用cpu

从零开发分布式文件系统(5.2) IO模型的选择

推荐阅读:分布式系统架构经典资料

从零开发分布式文件系统(三) :JuiceFS|沧海|3FS 百万 OPS 答疑(2)(ceph 默认 5 千)

从零实现分布式文件系统(二) 如何在不升级硬件的前提下,小文件并发读写性能提升十倍

别想太多--只管去面

本文参与 腾讯云自媒体同步曝光计划,分享自微信公众号。
原始发表:2026-01-28,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 后端开发成长指南 微信公众号,前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体同步曝光计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 一、对你有什么帮助: 需要发生的背景是什么
    • 教训1: 废弃字段
    • 教训2: 脑裂
    • 教训3:新增字段
  • 二、滚动升级必须遇到三个问题
  • 消息类型变化
  • 消息结构体变化
  • 节点不可用变化
  • 三、解决方案 :消息类型变化
    • 3.1 思路
    • 3.2 举例
      • TiDB
      • 3.3 Ceph序列化如何做到向后兼容 向前兼容
    • 💎 总结
      • oecaebse
  • 四、解决方案 :消息结构体发生变化
    • 4.1 思路
    • 4.2 举例 TiDB从使用到优化
      • 1. Protobuf 协议的兼容设计(核心)
      • 疑问:怎么做到的呢?
      • TiDB 不是简单依赖Golang 会忽略未知字段,而是主动控制不发送未知字段,这提供了更强的兼容性保障
      • 4.3 预留字段
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档