写点什么

大规模替换数据库序列,保障百余个服务平稳运行

作者:Saumya Tyagi
  • 2026-04-09
    北京
  • 本文字数:8751 字

    阅读完需:约 29 分钟

引言:没人提前规划的序列问题

当你运营一个拥有数百个服务的大规模平台时,数据库迁移从来都不是孤立进行的。它会波及各个团队、代码库以及多年来形成的固有设计逻辑,尤其是在序列(Sequence)相关的处理上。

序列是数据库中平时极少被关注的功能,往往直到失效才会引起重视。本质上,它们是由数据库管理的计数器对象,可按需分配唯一且单调递增的数值。

每次插入数据并需要主键时,数据库就会对计数器进行递增并返回下一个值,不会产生冲突,应用层无需编写协调逻辑,也无需额外的处理。这种计数器可靠又无感,以至于大多数工程师只有在被迫替换它时才意识到自己对它有多依赖。

Coupang,我们从关系型数据库迁移到 NoSQL 时,就在替换序列这个环节撞上了一堵意想不到的墙。

超过一百个团队依赖数据库原生序列来生成主键。部分团队用它来保证排序,另一些则用它来满足下游系统对单调递增标识符的向后兼容性需求。这些序列本身并不复杂,却无处不在:整个组织内分布着近万个不同的计数器。

以 DynamoDB 为代表的 NoSQL 存储并不提供原生序列支持。UUID 是一种可选方案,但会破坏原有的排序逻辑,需要在多个服务中进行同步修改。雪花算法(Snowflake)风格的 ID 则会带来我们不愿承担的运维复杂度。我们需要一种更简单的方案。

目标很明确:构建一个即插即用的替代方案,让各团队无需重写应用就能从关系型数据库顺利迁移出来。

作为公司全面淘汰遗留数据库、转向云原生基础设施工作的一部分,我们需要兼容源数据库序列提供的所有能力:包括起始值、自定义步长、升序与降序,并保证完全向后兼容,使各团队能够按自身节奏进行迁移,而不影响现有的系统运行。

作为此次迁移的一部分,我们的订单团队在零停机的前提下仅用三周时间就完成了十二个服务的迁移,代码修改量不足五十行。以下便是我们这个系统的构建思路与实践过程。

序列设计:简洁胜于精巧

分布式序列生成听起来像是一个需要复杂协调的问题。共识协议、向量时钟、分布式锁——相关文献中充斥着各种在白板推导时看似优雅的解决方案。

我们避开了这条路。

复杂的系统会以复杂的方式失效。每增加一层协调逻辑,都会带来更高的延迟、更多的故障场景以及更重的运维负担——这些问题在凌晨三点被告警叫醒时,体会尤为深刻。而对于序列服务而言,实际需求其实并不苛刻:

  • 唯一性:确保不同调用方永远不会获取到相同的值

  • 单调性:序列值随时间递增(或递减)

  • 可用性:系统可在故障时保持稳定运行

  • 低延迟:序列生成不会成为性能瓶颈

  • 热路径零网络调用:在本地完成序列生成,无需网络往返

需要留意这个清单里并未包含的特性:消费者之间的严格全局有序性、无间断序列,以及实时一致性。大多数团队并不需要这些属性。而那些声称自己需要的团队,在经过几次坦诚沟通后,往往也会发现其实可以舍弃。

网络调用这一约束至关重要。传统数据库序列需要为每个生成的值执行一次网络往返。在高吞吐场景下,这种往返会导致延迟并形成中心化瓶颈。我们希望序列生成的性能表现如同递增一个本地变量,而对绝大多数请求而言,实际运行逻辑也正是如此。

这一认识塑造了我们的设计原则:

  • 最小化协调:尽量避免使用分布式锁与共识机制

  • 允许存在间隙:未使用的序列间隙可以接受

  • 缓存前置:通过服务端与客户端的主动缓存减少网络往返

  • 架构简洁易懂:确保值班人员在凌晨三点也能理解其运行逻辑

  • 保持向后兼容:现有模式、接口及调用方无需修改

现有方案及其局限性

在开始构建之前,我们评估了现有的解决方案。它们各有优势,但没有一个能满足我们的约束条件。

最直接的方案是使用 UUID,但已有数十个服务使用 BIGINT 类型主键。修改列的类型会连锁影响数据表结构、API 接口和报表系统——这相当于在现有迁移工作之上再额外增加一轮迁移。UUID 还会导致插入操作在 B-tree 索引中离散分布,降低高吞吐量表的写入性能。此外,有多个团队依赖 ID 有序性实现分页功能,而 UUID 完全无法满足这一需求。

我们深入研究了 Snowflake ID。它们解决了排序问题,且适配 BIGINT 类型,这点很有吸引力。但 Snowflake ID 需要管理工作节点 ID,在自动扩缩容环境中,这本身就是一个分布式协调问题;它还依赖同步时钟,时钟偏移会导致序号乱序甚至直接出现冲突。更麻烦的是,它无法做到即插即用替换:原本生成 1001、1002 这样的序列会变成 1578323451234567890 这类数值。

基于单一协调器的数据库序列在理论上是最简单的方案,但它会产生我们本想规避的瓶颈:单点故障、逐个生成值带来的延迟,以及随规模扩大而加剧的锁竞争问题。

基于时间戳的方案也存在问题。同一毫秒内的多个请求会产生冲突,分布式节点间的时钟偏移会导致排序不可靠,并且无法支持自定义起始值与步长。

没有一种方案能够满足我们的全部约束:与原数据库序列完全对等、无需修改表结构、亚毫秒级延迟以及运维简单性。因此我们自研了一个专用解决方案——它的简洁是刻意设计的结果,而非偶然。

序列服务的核心架构

序列服务成了我们的 0 级(Tier-0)核心服务(这是我们内部对关键路径基础设施的叫法,这类服务一旦发生故障,会直接导致订单、支付等核心业务停摆)。多个一级(Tier-1)服务都依赖它生成主键。它整体分为三层:以 DynamoDB 作为可信数据源,上层是服务端缓存层,再上层则是带本地缓存的重量级客户端。

图 1:请求在到达 DynamoDB 之前流经两个缓存层,DynamoDB 处理的序列需求不到 0.1%。

下图展示了客户端请求序列时各层的处理流程。具体分为三种场景,取决于请求在哪个层被处理:客户端缓存命中,请求全程不离开应用进程,客户端缓存不足时,由序列服务在后台进行补充,仅当两层缓存均耗尽时,才会触发 DynamoDB 查询。

图 2:序列处理顺序的三种场景。大多数请求不会离开应用进程。DynamoDB 只在两个缓存层都快耗尽时才被访问。

DynamoDB 作为可信数据源

每个序列对应一个独立的 DynamoDB 项:以计数器名称作为键,当前数值作为值(使用 DynamoDB 的 Number 类型存储,在应用中映射为 Long):

当服务需要更多序列时,会通过 DynamoDB 的条件更新操作实现原子自增:

更新表达式: SET #val = #val + :blockSize

条件表达式: #val = :expectedValue

如果条件更新失败,说明已有其他实例获取了该段序列。服务会使用新值重试。这种方式让我们在无需分布式锁的情况下实现了安全且无冲突的唯一性保障。

为什么批量获取很重要

每次只获取一个序列会导致频繁请求 DynamoDB。因此我们改为每次批量获取 500 到 1000 个序列。只需一次 DynamoDB 写入,就能支撑后续数百次缓存级序列请求。

这种批量方案既降低了 DynamoDB 成本(减少写入操作),又提升了延迟表现(绝大多数请求可命中缓存),同时增强了可用性(服务可承受 DynamoDB 发生短暂故障)。

相应的代价是会产生序列间隙。如果服务器在缓存中仍有 400 个未使用序列时发生崩溃,这些数值就会永久丢失。但对我们的业务场景而言,这是完全可以接受的。

服务器端缓存层

序列服务会为每个计数器维护一个预获取序列的内存缓存。服务可多实例同时运行,每个实例持有从 DynamoDB 分配的、互不重叠的序列段。因此,跨实例生成的 ID 并非严格全局单调递增,但能保证唯一。单个实例内的序列则始终单调递增。如果你的业务需要所有调用方之间严格全局有序,那这个设计并不适用。

我们特意选用了内存缓存,而非 Redis、Valkey 这类共享式外部缓存。外部缓存会引入网络开销和新的故障依赖,而这正是我们想要避免的。由于每个服务实例都会从 DynamoDB 原子分配专属序列段,实例之间无需共享缓存状态。对应的代价是:实例重启时,缓存中未使用的序列会形成间隙。对我们的场景而言,这完全可以接受。

与客户端类似,服务端也设有可配置的填充阈值,用于控制每个计数器在缓存中保留的最大序列数量。当客户端请求序列时,服务会执行以下操作:

  • 检查当前计数器的缓存中是否存在可用序列

  • 若有,则执行原子自增并返回下一个值

  • 若缓存不足或为空,则触发后台任务,从 DynamoDB 重新填充至配置上限

块分配策略

每个计数器可根据预期流量配置对应的缓存参数:

最大缓存大小用于控制服务端在内存中为对应计数器保留的序列总数,块大小则控制单次调用 DynamoDB 获取的序列数量。滑动窗口用于判定触发填充的时机,填充时会获取足量序列,将缓存恢复至最大容量。

缓存越大,对 DynamoDB 的调用次数就越少,但如果流量被高估或服务器重启,会浪费更多序列。我们会根据实际观测到的流量模式和成本容忍度调整这些参数。对于对序列间隙较为敏感的业务方,我们在服务端内存与 DynamoDB 之间增加了可选的 Berkeley DB(BDB)层,在无需网络往返的前提下提供本地持久化能力。

滑动窗口速率计算

系统运维中最关键的环节并非序列生成本身,而是判断何时重填缓存。一旦时机判断失误,整个系统都可能出现问题。缓存重填过早,会导致不必要的开销,例如多余的 DynamoDB 调用与容量浪费;重填过晚,则会导致缓存未命中、延迟增加,甚至引发潜在的故障。

我们使用滑动窗口算法持续估算消费速率,并在合适的时机触发缓存重填。关键在于,缓存重填是异步进行的,不会等到缓存空了才重填。滑动窗口会预测何时启动后台重填,确保新序列在现有缓存耗尽前就位。

滑动窗口运行在厚客户端(一个直接嵌入到应用进程里的库)内部,不涉及独立进程或服务调用。客户端会跟踪自身的序列消费速率,并在本地缓存耗尽前决定何时向序列服务请求重填。

图 3:滑动窗口在耗尽前预测重填——让用户请求远离关键路径。

这种异步方式使得用户请求几乎不会被网络调用或 DynamoDB 写入阻塞。重填操作在后台完成,剩余的缓冲区可用于处理请求。

针对每个计数器,服务会在 60 秒滚动窗口内跟踪序列分配情况,并计算当前消费速率。重填阈值是动态变化的:

refill_threshold = current_rate × buffer_seconds

当缓存中的序列数量低于该阈值时,服务会启动后台重填。

自适应

滑动窗口会自动适配流量模式,包括流量上升、下降以及突发流量。消费速率提升、重填阈值升高时,流量随之上升,服务会提前获取新的数据块以维持缓冲区。反之,消费速度放缓、阈值降低时,流量下降,服务会主动停止预取,减少不必要的 DynamoDB 调用。在处理突发流量时,短时突增会使速率临时飙升,阈值随之提高并触发重填;若突发流量消退,阈值会逐步恢复正常。

调优参数

min_threshold 用于防止低流量计数器将重填阈值设置得太低。对于每分钟仅处理一个请求的计数器,不应等到序列耗尽时才触发重填。

下面是一个伪代码实现。

// 每秒:滑动窗口,更新总量

// 当剩余数量 <(速率 × 缓冲秒数) 时执行填充

public synchronized int calculateRefillThreshold() {

    double ratePerSecond = (double) totalInWindow / windowSizeSeconds;

    int dynamicThreshold = (int) (ratePerSecond * bufferSeconds);

    return Math.max(dynamicThreshold, minThreshold);

}

厚客户端设计

服务器端缓存可减轻 DynamoDB 负载,而客户端缓存则能让绝大多数请求不再需要网络调用。

这个缓存方案是我们的核心设计目标:序列生成调用应尽可能不脱离应用进程。网络调用成本高昂,不仅会造成延迟,还会带来故障模式、连接池管理与运维复杂度等问题。数据库原生序列需要为每个值执行一次网络往返,而我们追求的是与之相反的东西。

厚客户端是直接嵌入到应用中的 SDK,它会维护自身的本地序列缓存,仅在缓存需要重新填充时才与序列服务通信,而通过合理调优滑动窗口,这种通信发生频率并不高。

为什么将缓存放到客户端

对于高吞吐服务而言,即便单次网络调用很快,数量累积后也会产生巨大开销。每秒处理上万订单的服务无法承受为每个序列都进行一次网络往返,而厚客户端彻底消除了这一瓶颈。

需要明确的是,我们已经为遗留数据库的序列实现了缓存。团队并不会为每个序列值都发起一次数据库调用——这种方案本身就不可行。但现有缓存方案存在局限性:实现方式在各团队间并不统一,通常为自研实现,且与连接池行为绑定。不同服务的实现方式各不相同,在缓存块大小、重填策略以及故障处理上均存在差异。

新系统对缓存进行了标准化,并采用了优化的双层架构。

客户端 SDK 的职责

厚客户端能够处理多个问题:通过本地缓存管理在内存中保存一段序列;通过后台预填充在缓存耗尽前获取新的序列块;通过速率计算使用独立滑动窗口预测预填充时机;通过故障处理在服务不可用时实现降级;最后通过进程内单调性保障本地序列的有序性。

需要注意的是,客户端不负责处理配置相关逻辑。客户端对序列的增量、起始值或生成方向一无所知,仅根据序列名称申请序列块,并按接收顺序分配序号。所有复杂的逻辑都在服务器端。

服务器会向客户端建议初始填充速率,而客户端则通过滑动窗口持续监控实际消耗情况并自动调整。部署后短短几分钟内,即便初始配置不佳的客户端,也能收敛到适配自身实际负载的最优填充速率。

核心客户端逻辑

public synchronized long next() {    rateCalculator.recordAllocations(1);    int remaining = cachedValues.length - cachePosition;    int threshold = rateCalculator.calculateRefillThreshold();    // 缓存耗尽前触发异步填充    if (remaining <= threshold && !refillInProgress) {        triggerBackgroundRefill();    }    if (cachePosition >= cachedValues.length) {        // 缓存已耗尽,必须阻塞等待填充        blockingRefill();    }       // 数值已由服务端完成自增    return cachedValues[cachePosition++];}
复制代码

客户端与服务器端参数

这种不对称设计是有意为之:服务端每次从 DynamoDB 获取 500 至 1000 个序列的块,而客户端缓存上限则根据应用需求设为 50 至 500 个序列。客户端内存相比服务端更为受限,因客户端崩溃造成的序列浪费也更常见(如应用频繁重启)。服务端需要处理来自多个客户端的请求,因此使用更大的块是合理的,而较小的客户端缓存可在应用缩容或重新部署时减少序列浪费。

容错和故障模式

双层缓存架构不仅能提升性能,还能针对中断提供分层保护。

客户端缓存可避免服务中断影响:当序列服务不可用时,客户端可继续从本地缓存获取服务序列。若客户端缓存了 500 个序列,且每秒消费 10 个,便可在不影响业务的情况下承受长达 50 秒的服务中断。

服务器缓存可避免因 DynamoDB 中断造成影响:当 DynamoDB 出现分区中断或限流时,序列服务可继续基于其内存缓存提供服务。每个计数器缓存数千个序列,服务器可继续处理数分钟的流量。

结合客户端与服务器缓存双重保护可成倍提升系统弹性。DynamoDB 中断不会立即影响客户端,因为服务器缓存承接了影响;序列服务中断不会立即影响应用,因为客户端缓存承接了影响。

这种组合式缓存让系统的有效可用性高于任意单个组件的水平,任意一层的短暂中断对终端用户均无感知。

故障影响总结

保证单调性和唯一性

我们需要明确系统所提供的保障机制。

唯一性:强保证

不会有两个调用方获取到相同的序列值。这个保证全局成立,跨越所有客户端和服务器。

工作原理:

  • 通过 DynamoDB 的条件写入,确保每个数据块仅被分配一次

  • 服务器缓存分配互不重叠的数据块

  • 客户端缓存从服务器获取互不重叠的序号区间

即便在崩溃、重试、网络分区等故障场景下,序列唯一性依然能够得到保证,最坏情况也只会出现序号间隙,而非重复。

单调性:每客户端保证

在单个客户端实例内部序号严格递增(若为递减序列则严格递减)。从该客户端视角来看,序号 N+1 始终大于 N。

跨客户端时,序号通常随时间递增,但整体可能呈现无序状态。例如客户端 A 的 1050 可能会在客户端 B 的 1100 之后被使用。

间隙:预期行为

间隙是设计中固然存在的。当服务器或客户端崩溃导致缓存中存在未使用的序列,或是数据块已分配但未产生实际流量时,就会出现间隙。

对于大多数使用场景而言,间隙无关紧要。主键无需连续,审计日志也不要求序号必须保持顺序。真正需要无间隙序列的只有那些对外部连续性存在强依赖的系统,而根据我们的经验,这类系统远比工程师最初设想的要少得多。

缓存效率

双层缓存与滑动窗口速率计算相结合可带来显著的效率提升:

在峰值每秒生成五万个序列的系统中,该架构实现了如下表现:约 49500 个序列可直接从客户端内存即时提供,约 450 个序列需要调用服务端(仍可通过服务端缓存快速响应),仅有约 50 个序列会触发 DynamoDB 写入操作。

滑动窗口的异步重填在这里变得至关重要。由于重填操作会在缓存耗尽前触发,几乎没有用户请求需要等待网络调用。无论后台发生何种情况,用户都能感受到一致的亚毫秒级延迟。

吞吐量

吞吐量指标显示,所有计数器的峰值吞吐量超过每秒五万个序列,常规吞吐量为每秒一万至两万个序列。流量最高的计数器单实例峰值约为每秒五千个序列。这部分吞吐量中的绝大部分并未真正请求序列服务,实际服务流量仅约为序列总消耗量的百分之一。

DynamoDB 模式

是的,事实确实如此。一个每秒提供 50000 个序列的系统在正常负载下每秒仅会产生 10 至 20 次 DynamoDB 写入。1000:1 的比例依然令人惊讶,这一效果要得益于批量获取与预测重填的协同作用。

延迟

正常情况下,超过 99% 的请求会命中客户端缓存。服务器调用极少,DynamoDB 调用则更少发生。

成本

相关成本较为合理。配置了最小写入容量且存储空间占用极低的 DynamoDB 每月费用约为 50 美元;用于小规模集群、CPU 使用率较低的序列服务计算成本约为 500 美元;网络成本则可以忽略不计,厚客户端模式有效减少了跨服务流量。总月成本低于 1000 美元,却可支撑每秒数万序列的服务能力。

迁移:协助团队接入

构建系统只是整个工作的一部分,另一部分工作是要让上百个团队真正落地使用,而且说实话,这是更难的部分。订单团队在三周内完成了十二个服务的迁移,峰值负载达到每秒 8000 个序列,全程零停机。

API 兼容性

客户端 API 比遗留数据库的 API 更简单:

// 遗留数据库 (之前)long id = connection.getNextSequenceValue("orders_seq");// 新系统 (之后)  long id = sequenceClient.next("orders_seq");
复制代码

对大多数团队而言,迁移只需修改一行代码并更新依赖即可。无需额外配置、初始化或参数设置,只需传入序列名称即可。所有复杂逻辑(步长、生成方向、块大小)均在接入时在服务器端处理。

计数器注册和配置

序列配置完全在服务器端,保存在动态配置存储中。团队在接入时会使用与源数据库兼容的参数注册序列:序列名称、起始值、增量大小、最小值、最大值以及生成方向(升序或降序)。

因为使用了动态配置存储,我们可以调整块大小、增量与缓存限制等参数,无需重新部署服务或修改客户端代码。当高流量序列在业务高峰期间需要更大数据块时,我们只需要更新配置,改动即可在几秒内生效。随着接入团队增多并逐步了解其真实流量模型,这种运维上的灵活性体现出了极高的价值。

这种分离设计是有意为之。配置应由平台团队统一管理,而非分散在各个应用中。当序列需要调优时(例如高流量场景下使用更大块,或新业务场景采用不同增量),我们可直接在服务器端修改,无需改动任何客户端代码。

经验教训

做得好的部分

双层缓存

客户端缓存将服务器端负载降低了 99%,服务器端缓存又将 DynamoDB 负载进一步降低 90% 以上。两者结合,使得我们提供的序列数与数据库写入次数之比达到了 1000:1。

中断隔离

缓存层提供了分层容错能力:客户端缓存可抵御服务中断,服务器缓存可抵御 DynamoDB 中断。任意一层的短时故障对应用均无感知,系统整体有效可用性高于任一单个组件。

异步重填

在缓存耗尽前主动触发预填充,使得正常情况下用户请求无需等待网络调用。这就是“大多数时候很快”与“始终很快”的区别,正如我们的 SRE 团队常说的,这完全是两码事。

自校正填充速率

系统可在几分钟内完成自动调优,无需进行流量预测。配置异常的客户端也能自行修复。

简单性

整个设计简洁直观,新工程师只需一场会议就能理解,值班人员排查问题时也毫不费力。

接受间隙

说服各团队接受允许间隙的设计是整个简洁方案得以落地的关键。这类沟通往往比工程实现更难;工程师对“正确性”有着强烈的直觉,有时需要温和地引导他们打破固有思维。

需要重新思考的东西

块大小调优

起初我们将块大小设置得过大,导致在低流量计数器上出现序列浪费。采用基于实际流量观测的动态块大小会获得更好的效果。

监控粒度

我们对聚合指标的监控做得很完善,但单计数器维度的可见性上线较晚。详细的监控面板本应从一开始就搭建好。我们到最后补上了实时监控面板,展示每个计数器的缓存深度、预填充速率和间隙频率,这对值班排查问题来说至关重要。

这带给我们关于分布式系统的启示

这个系统印证了一个我反复遇到的普遍模式:在分布式系统中,缓存不只是一种性能优化手段,更是实现高弹性与简洁架构的基础原语。其中的核心洞见并非技术层面,而是意识到大多数团队实际并不需要他们自以为必需的强保证。一旦我们帮他们放下这些约束,解决方案就会变得简单得近乎出人意料。

结论

数据库原生序列虽使用便捷,但在大规模场景下会成为瓶颈。即便多数团队都以某种方式实现了缓存,缺乏标准化与预取重填机制依然会导致性能不稳定、运维成本高昂。

我们的架构以 DynamoDB 作为可信数据源,配合双层缓存与滑动窗口预填充机制实现了与传统数据库序列能力完全一致的功能(如起始值、自定义增量、升序/降序),同时让上百个团队无需重写业务代码即可完成迁移。在常规负载下,99% 的序列生成完全在应用内存中完成,无网络调用、无远程服务、无数据库操作,仅需对数字做自增即可。

归根结底,最优秀的分布式系统是让分布式细节对用户完全无感的系统。

如果你正在规划类似的迁移,不妨先从思考实际需要哪些保证开始,而不是哪些保证“感觉上是正确的”。答案往往能帮你打开思路,得到比预想更简洁的设计。

【声明:本文由 InfoQ 翻译,未经许可禁止转载。】

查看英文原文:https://www.infoq.com/articles/replacing-database-sequences/