分布式数据库的节点故障不是概率问题,而是时间问题。在一个由几十上百个节点构成的集群里,硬盘损坏、网络分区、内存故障、进程崩溃每天都在发生。如果你在业务代码里只用一次简单的 SQL 调用,不做任何重试和幂等处理,那么每一次节点故障都会直接暴露给用户,变成一次实打实的业务报错。真正成熟的分布式系统,必须把“自动重试”和“幂等性”这两件事做到骨子里,让故障发生时客户端几乎无感知,业务数据既不丢也不多。

节点故障时客户端看到的现象远比你想象的复杂

很多人以为节点故障就是连接超时,其实远不止这一种。当协调节点崩溃,客户端可能收到连接拒绝;当数据节点发生主从切换,正在执行的事务可能被回滚并返回一个“not leader”错误;当网络出现短暂分区,请求可能超时,但实际操作已经在远端执行成功。更隐蔽的是,某些数据库在故障恢复后会重放日志,导致一个已经被客户端认为失败的操作在后台悄悄提交。如果你只处理了超时重试,而不考虑这些已经“半成功”的请求,数据错乱几乎是必然的。

自动重试的核心不是重试本身,而是识别可重试错误

一个稳健的重试机制,第一步是建立错误分类。连接超时、网络不可达、leader 切换这类错误显然是可重试的;而 SQL 语法错误、约束冲突、权限不足则绝对不能重试,重试多少次都会失败。更棘手的是那些“未知状态”的错误,比如请求已发出但响应丢失。对于这类错误,重试策略必须和幂等设计配合,否则就可能造成重复写入。实际工程中,建议把错误码分为三类:可安全重试、不可重试、需幂等重试,并在客户端 SDK 或数据访问层统一封装这一逻辑。

重试策略的细节决定了系统在故障下的表现

最简单的重试是固定间隔,比如每次失败后等待 100 毫秒再试。但在大规模故障时,这种策略会加剧数据库的压力,形成“重试风暴”。更好的做法是采用指数退避加随机抖动,让重试间隔逐渐拉长,同时分散不同客户端的重试时间点。例如第一次重试等待 100 毫秒,第二次 200 毫秒,第三次 400 毫秒,每次再叠加一个随机的小幅偏移。重试次数也需要上限,通常 3 到 5 次足够覆盖短暂的网络抖动或 leader 选举。超过上限的失败应该向上层抛出异常,由更上层的补偿机制处理,而不是无限重试下去。

幂等性设计的本质是让操作可重复执行而不改变结果

幂等并不是一个模糊的概念,它有非常明确的数学含义:一个操作执行一次和执行多次,系统状态完全相同。在数据库语境下,这意味着重复的 INSERT 不会产生重复行,重复的 UPDATE 不会叠加效果,重复的 DELETE 不会报错。实现幂等的最常见手段是为每个业务操作分配一个全局唯一的幂等键,数据库在处理请求时先检查这个键是否已经存在,如果存在则直接返回之前的结果,不再执行实际操作。

唯一约束加幂等键是最直接的实现方式

在关系型数据库里,可以建一张幂等表,包含幂等键和对应的处理状态、返回结果。每次业务操作前,先将幂等键插入这张表,利用唯一约束保证只有一个请求能插入成功。插入成功的请求继续执行业务逻辑,执行完后更新状态和结果;插入失败的请求说明已经有其他请求在处理或已处理完成,直接查询返回结果即可。这个方案简单可靠,但需要注意幂等表的清理策略,否则数据会无限增长。通常可以按时间分区,定期删除超过保留期限的记录。

CREATE TABLE idempotency (
    idempotent_key VARCHAR(128) PRIMARY KEY,
    status VARCHAR(20) NOT NULL,
    result TEXT,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

-- 业务操作伪代码
INSERT INTO idempotency (idempotent_key, status)
VALUES ('order_12345', 'PROCESSING');
-- 如果插入成功,执行业务逻辑
-- 如果插入冲突,查询已有结果并返回
把幂等键嵌入业务表是更内聚的设计

为每个业务表增加一个幂等键字段,并设置唯一约束,可以把幂等控制和业务数据放在一起,减少额外的表关联。比如订单表增加一个 idempotent_key 字段,创建订单时带上这个键,重复请求会因为唯一约束冲突而失败,客户端捕获这个特定错误后查询已有订单即可。这种方式的优点是简单直接,缺点是并非所有业务操作都对应一张实体表,对于跨多表的复杂事务,需要在外层包装幂等逻辑。

事务性幂等要处理并发和部分失败

在分布式事务场景下,幂等的实现要复杂得多。一个请求可能涉及多个数据分片,其中一部分写入成功,另一部分失败。如果客户端重试,已经成功的分片需要识别出这是同一个请求的重复,而不是新的业务操作。这就要求幂等键能够跨分片传递,并且在每个分片上都能独立地进行幂等检查。一些分布式数据库在协议层内置了幂等能力,比如 TiDB 的事务重试机制,或者 CockroachDB 的自动重试,但即使有这些能力,业务层仍然需要处理那些数据库无法自动处理的场景,比如外部系统的调用。

“至少一次”语义下必须配合消费者端去重

在很多事件驱动架构中,数据库的变更会通过 CDC 或消息队列投递给下游消费者。节点故障导致的重试可能使同一条数据变更被投递多次。如果消费者不做去重,就会出现重复扣款、重复发货等严重问题。消费者端的去重同样依赖幂等键,通常使用事件 ID 或数据库的 change sequence number 作为去重依据,在消费端维护一个已处理事件集合,每次消费前先检查。这个集合可以用数据库表、Redis 或布隆过滤器实现,根据数据量和性能要求选择。

重试与幂等的组合策略要覆盖端到端链路

单独看,重试和幂等都不复杂,但真正难的是把它们贯穿整个调用链路。一个典型的请求路径是:客户端 -> API 网关 -> 应用服务 -> 分布式数据库。每一层都可能发生超时和重试,如果每一层都独立重试而不传递幂等键,最终到达数据库的可能是多个不同的请求副本。正确的做法是,在请求入口处生成幂等键,通过请求头或上下文一路传递到数据库操作层,所有层次的重试都复用同一个幂等键。这样即使 API 网关重试了 3 次,应用服务重试了 2 次,最终落到数据库上的操作仍然是幂等的。

超时时间的设置是容易被忽略的关键变量

重试策略里有一个隐藏的陷阱:如果客户端的超时时间小于数据库实际处理请求的时间,客户端超时后会发起重试,而数据库还在处理第一个请求。当第一个请求最终成功时,第二个请求也到达了,如果没有幂等保护,就会重复执行。更合理的设计是让客户端的超时时间明显大于数据库的典型处理时间,同时小于数据库的事务超时时间。这样在正常情况下不会误判超时,真正故障时也能及时触发重试。具体数值需要根据业务 P99 延迟来调优,通常建议客户端超时设置为 P99 的 2 到 3 倍。

读写分离架构下的重试要特别处理复制延迟

在主从架构中,写入操作在主节点完成后,数据同步到从节点需要一定时间。如果写入成功后立即在从节点读取,可能读不到刚写入的数据,这种现象叫“读己之写”不一致。当写入发生重试时,这个问题会更突出。解决方案有两种:一是在写入成功后的一段时间内,强制相关查询走主节点;二是让写入操作返回一个时间戳或版本号,查询时带上这个版本号,从节点如果没有达到这个版本就等待或转发到主节点。很多分布式数据库在内部已经处理了这个问题,但如果你用的是自己搭建的中间件方案,就需要在业务层或数据访问层做特殊处理。

批处理操作的重试和幂等需要更细粒度的控制

批量插入或更新时,一次请求可能包含成百上千条记录。如果处理到一半节点故障,已经处理的部分可能已经持久化,未处理的部分丢失。简单的整体重试会导致已处理部分重复。更好的做法是把批量操作拆分为更小的子批次,每个子批次有独立的幂等键,或者使用数据库的批量插入特性,在语句层面保证原子性。如果数据库支持带有幂等键的批量操作,可以直接在 SQL 中指定,让数据库在内部处理重复。如果不支持,就需要在应用层实现“游标式”的重试,记录每个子批次的完成状态,故障恢复后从未完成的位置继续。

监控和可观测性是重试幂等体系的最后一道防线

无论设计得多完善,生产环境总会出现意料之外的情况。你必须能实时看到重试率、重试成功率、幂等冲突率这些指标。重试率突然飙升通常意味着数据库出现了系统性问题;幂等冲突率异常可能意味着有客户端在非正常情况下大量重试,或者幂等键生成逻辑有缺陷。这些指标应该接入告警系统,设置合理的阈值。同时,每次重试和幂等命中都应该记录日志,包含幂等键、时间戳、调用链路信息,方便事后排查问题。没有这些可观测性手段,重试和幂等机制就像一个黑盒,出了问题你根本不知道是哪里出了错。

把自动重试和幂等性设计做好,不是为了应付技术评审,而是让你的系统在凌晨三点节点宕机时,不用把开发人员从床上叫起来。这两项能力是分布式数据库应用中最基础也最容易被低估的部分,投入足够精力把它们做扎实,整个系统的可靠性会有一个质的飞跃。