最近几周在读神书 DDIA — 《数据密集型应用系统设计》,收获颇多。有趣的是,截图文章封面时,才发现六年前曾标记想读这本书,有种与六年前的自己对话的奇妙感觉。在阅读的过程中,过去经历的多个真实线上故障,突然跃入脑中,并有了全新的体会。

本文尝试站在 SRE 视角,分享在蚂蚁工作期间,两个故障的一点粗浅新感悟。

故障1

背景:当外部商户/用户的一笔请求到达机房后,依次经过 证书卸载(七层) -> 负载均衡后(四层) -> 网关应用 后,内部服务调用全部走 RPC。由于微服务的流行或“泛滥”,一笔下单支付可能流转上百个服务,i.e. 上百次 RPC 调用。

问题:在一次会员系统的灰度发布过程中,线上逐步出现支付失败报错,并呈现上升趋势。查看时间线大概率怀疑由这次代码发布引入,立即无脑回滚变更。

幸运的是,变更回滚完成后,报错也随之消失。支付业务完全恢复后,进一步根据 traceid 排查造成问题的根因,最后竟然在中间件日志中发现 rpc 的反序列化报错??聪明的你猜一猜有可能是什么原因导致的?应该如何避免?

答案解析

在 DDIA 第四章 - 数据编码与演化 中,我们学习了不同序列化方式进行通信的优缺点。例如 python 的 pickle 虽然很方便,但存在安全和效率的问题;JSON/XML/CSV 虽然简单,但存在很多模糊地带,例如大数字的表示、CSV 的分隔符等,并且存在冗余,例如自身格式与 HTTP 协议 headers。

支付宝中一笔下单支付请求可能涉及超过 30+ 应用,上百个微服务的交互,所以不难理解公司内部使用 RPC 作为数据交换的协议。

以 protobuf 举例,RPC 是如何通过巧妙编码,实现空间的高效利用。

message Person {
    required string user_name       = 1;
    optional int64  favorite_number = 2;
    repeated string interests       = 3;
}

从下图中不难看出 Person 数据结构每个字段被分为 field tag | type | length | value,然后编码为二进制进行传递。值得注意的一个细节是,编码的过程中并没有包含字段的名称,而是通过 field tag 序号表示。

通过 RPC 编码,相比于其他途径,除了极致的性能,编译阶段的静态检查,另一个最大的优势在于天生支持版本号。但对应的 tradeoff 是使用者在每次变更时需要谨慎地考虑向前/向后兼容

这次故障的原因就与兼容性相关:应用 B 在数据结构中新增了一个枚举,虽然 A 做了对应适配,但 B 先发布了,最终导致了 Serialization Exception

    A (旧) ---RPC---> B (新,返回新枚举)
       ^
       |
    反序列化失败,不认识新枚举值
    SerializationException

虽然设置严格的发布顺利可以避免该问题,但更优雅的兼容策略:1)枚举反序列化遇到未知值时不要直接抛系统异常,而是 fallback 到 UNKNOWN 2)一开始设计就避免使用枚举?系统之间通过字符串交互,系统内部转化为枚举。

故障2

背景:用户的一笔网购可分为多个环节,包含 支付咨询、交易创建、支付申请、支付推进、渠道异步的支付通知回调。其中「支付申请」步骤可简化为以下流程(每个节点代表一个应用):

[外部商户] -> 网关 -> 收单 -> 收银 -> 支付 -> 金融交换 -> [外部渠道]
              |
             会员

问题:某个周五的上午,突然收到电话告警:“Aliexpress 的下单支付的成功量下跌超过 20%”。幸运的是故障在一分钟下跌后,业务自行恢复了。故障根因依稀记得是由于 OceanBase 数据库内部的一个 bug,在将内存中的增量数据合并回硬盘时,hang 住了 30s。

后续故障复盘会议上,所有人的焦点都在探讨这个 bug 的原因和修复方案。但作为一名专业的 SRE,敏锐的我发现这个是会员系统的数据库节点,即使出现问题,99% 的读请求应该由缓存处理,为什么会导致支付请求失败?

好奇心驱动下,花了几天阅读会员系统源码后,我发现受到影响的 get_or_create_user 服务,只有在缓存失效时需要查询数据库,理论上确实不需要强依赖数据库。然而,代码一开始在准备 thread local 上下文时,直接请求数据库获取当前时间,方便后续创建用户使用。。。这个非必要的强依赖,但最终导致了这次故障。。。

虽然后续通过主动提交代码,修改为“懒加载”的模式,彻底解决这个问题。但心中一直有一个小困惑:为什么不直接在数据库表中使用 now() 来表示当前时间呢?

答案解析

阅读书籍后,答案逐渐浮出水面,在开始前,先简单介绍一些背景知识。

书中将分布数据库分为三类:

  1. Single leader:传统 MySQL 一主多从结构。
  2. Multi leader:个人理解苹果的 Note 软件就像个 Multi leader Database。每个设备就是一个数据库,发生冲突时,可以简单以最新的版本为准,或让用户自行处理。
  3. Leaderless:去中心化的数据库,例如 AWS DynamoDB。

以 Single leader 为例,多节点的模式虽然提高了分布式系统读的效率,但在数据的一致性上迎来了新的挑战:多个节点之间的数据同步(replication)。通常采用「同步复制」或「异步复制」,而 OceanBase 采用的是 基于 Paxos 协议的「多副本同步」机制 — 只要超过半数的副本写入,并返回确认,主节点就会认为这笔事务已经提交。

Explicit is better than implicit.

回到刚刚的故障,由于节点间数据同步机制的存在,虽然相信 OceanBase 数据本身可以保证时间的正确性和副本件同步的一致性,但不使用数据库 now(),而在应用层显性传入时间,猜测其中一部分原因,更多是遵循“确定性&一致性”的一种最佳实践。