![]()
运营一个几百个服务的大型平台,数据库迁移从来不是"把数据搬过去"这么简单。它会像推倒多米诺骨牌一样,波及各个团队、代码库,以及那些你以为早就没人用的祖传代码——尤其是序列(Sequence)这个角落里的老古董。
序列是数据库里的隐形管家:一个默默计数的对象,每次你要插入新数据、需要主键时,它就递过来下一个数字,不重复、不冲突。工程师们通常只有在被迫换掉它的时候,才惊觉自己有多依赖这个"自动发号机"。
Coupang 从关系型数据库迁往 NoSQL 时,就在序列这个环节撞上了一堵墙。
一百多个团队依赖数据库原生序列生成主键。有人拿它排序,有人拿它满足下游系统对"数字必须越来越大"的执念。这些序列本身不复杂,但数量惊人:整个组织里散落着近万个计数器。
以 DynamoDB 为代表的 NoSQL 不提供原生序列。UUID 能解决问题,但会破坏原有排序逻辑,得改一堆服务。雪花算法(Snowflake)又带来不想承担的运维负担。团队想要的是一个"即插即用"的替代方案——各团队不用重写应用,就能平滑迁出关系型数据库。
作为全面淘汰遗留数据库、转向云原生基础设施的一部分,这个新系统得兼容源数据库序列的所有能力:起始值、自定义步长、升序降序,还要保证完全向后兼容,让各团队按自己的节奏迁移,不影响现有系统运行。
订单团队最终做到了:零停机,三周,十二个服务,代码修改不到五十行。
分布式序列生成听起来像是个需要复杂协调的难题。共识协议、向量时钟、分布式锁——论文里满是白板推导时很优雅的方案。
但复杂的系统会以复杂的方式失效。每多一层协调,就多一分延迟、多一种故障场景、多一层凌晨三点被告警叫醒时的运维负担。而对于序列服务,实际需求其实挺朴素:
唯一性:不同调用方永远不会拿到相同的值
热路径零网络调用:本地完成生成,不跨网络
注意这个清单里没有的东西:消费者之间的严格全局有序、无间断序列、实时一致性。大多数团队并不需要这些。而那些声称需要的,经过几次坦诚沟通后,往往也会发现其实可以舍弃。
"零网络调用"这个约束至关重要。传统数据库序列每生成一个值就要一次网络往返,高吞吐场景下这就是延迟瓶颈。团队想要的性能表现,是"像递增一个本地变量"——而对绝大多数请求来说,实际运行逻辑也正是如此。
评估现有方案时,没有一个能满足全部约束。
UUID 最直接,但已有几十个服务用 BIGINT 主键。改列类型会连锁影响表结构、API、报表系统——相当于在迁移之上再叠一层迁移。UUID 还会让 B-tree 索引的插入变得离散,降低高吞吐表的写入性能。更何况有团队依赖 ID 有序性做分页,UUID 完全无法满足。
Snowflake ID 解决了排序问题,也适配 BIGINT,但管理工作节点 ID 在自动扩缩容环境里本身就是个分布式协调问题;它还依赖同步时钟,时钟偏移会导致序号乱序甚至冲突。更麻烦的是无法即插即用:原本 1001、1002 的序列会变成 1578323451234567890 这种数值。
单一协调器的数据库序列理论上最简单,但会产生想规避的瓶颈:单点故障、逐个生成值的延迟、随规模扩大的锁竞争。
基于时间戳的方案问题更多:同一毫秒内请求冲突、分布式节点时钟偏移导致排序不可靠、无法支持自定义起始值与步长。
没有现成方案能满足全部约束:与原数据库序列完全对等、无需改表结构、亚毫秒级延迟、运维简单。于是团队自研了一个专用方案——它的简洁是刻意设计的结果。
序列服务成了 0 级核心服务(内部对关键路径基础设施的叫法,这类服务一故障,订单、支付等核心业务直接停摆)。多个一级服务依赖它生成主键。整体分三层:DynamoDB 作为可信数据源,上层是服务端缓存,再上层是带本地缓存的厚客户端。
请求在到达 DynamoDB 之前流经两个缓存层,DynamoDB 处理的序列需求不到 0.1%。
客户端请求序列时,有三种场景:客户端缓存命中,请求全程不离开应用进程;客户端缓存不足,序列服务后台补充;两层缓存均耗尽,才触发 DynamoDB 查询。
每个序列对应一个独立的 DynamoDB 项:计数器名称作键,当前数值作值。服务需要更多序列时,通过 DynamoDB 的条件更新实现原子自增。条件更新失败说明已有其他实例获取该段序列,服务会用新值重试——无需分布式锁就实现了安全唯一性。
每次只获取一个序列会导致频繁请求 DynamoDB。团队改为批量获取 500 到 1000 个,一次写入支撑数百次缓存级请求。这降低了 DynamoDB 成本、提升了延迟、增强了可用性——服务可承受 DynamoDB 短暂故障。
代价是序列间隙。服务器缓存中还有 400 个未使用序列时崩溃,这些数值就永久丢失了。但对业务场景而言,完全可以接受。
序列服务为每个计数器维护预获取序列的内存缓存。多实例同时运行,每个实例持有从 DynamoDB 分配的、互不重叠的序列段。跨实例生成的 ID 并非严格全局单调递增,但保证唯一;单个实例内始终单调递增。如果需要所有调用方之间严格全局有序,这个设计不适用。
特意选用内存缓存而非 Redis、Valkey 这类共享外部缓存——外部缓存会引入网络开销和新的故障依赖,而这正是想避免的。每个服务实例从 DynamoDB 原子分配专属序列段,实例之间无需共享缓存状态。代价是实例重启时未使用序列形成间隙,对场景而言可接受。
服务端也有可配置的填充阈值,控制每个计数器在缓存中保留的最大序列数量。客户端请求时,若缓存不足或为空,触发后台任务从 DynamoDB 重新填充。每个计数器可根据预期流量配置缓存参数。
缓存越大,对 DynamoDB 调用越少,但流量被高估或服务器重启时会浪费更多序列。团队根据实际流量模式和成本容忍度调整。对序列间隙敏感的业务方,在服务端内存与 DynamoDB 之间增加了可选的 Berkeley DB 层,提供本地持久化而无需网络往返。
运维中最关键的环节不是序列生成本身,而是判断何时重填缓存。时机错了,整个系统都可能出问题。重填过早,多余 DynamoDB 调用与容量浪费;重填过晚,缓存未命中、延迟增加、潜在故障。
团队用滑动窗口算法持续估算消费速率,在合适时机触发异步重填——不等缓存空了才动手。滑动窗口预测何时启动后台重填,确保新序列在现有缓存耗尽前就位。
滑动窗口运行在厚客户端内部(直接嵌入应用进程的库),不涉及独立进程或服务调用。客户端跟踪自身序列消费速率,在本地缓存耗尽前决定何时向序列服务请求重填。这让用户请求几乎不会被网络调用或 DynamoDB 写入阻塞。
针对每个计数器,服务在 60 秒滚动窗口内跟踪序列分配情况,计算当前消费速率。重填阈值动态变化:
refill_threshold = current_rate × buffer_seconds
缓存中的序列数量低于该阈值时,启动后台重填。
滑动窗口自动适配流量模式:流量上升,消费速率提升、阈值升高,服务提前获取新数据块维持缓冲区;流量下降,速度放缓、阈值降低,服务主动停止预取;突发流量时,短时突增使速率临时飙升、阈值提高并触发重填,若突发消退,阈值逐步恢复正常。
min_threshold 防止低流量计数器将阈值设得太低——每分钟仅一个请求的计数器,不应等到序列耗尽才重填。
服务器端缓存减轻 DynamoDB 负载,客户端缓存则让绝大多数请求不再需要网络调用——这是核心设计目标。
厚客户端是直接嵌入应用的 SDK,维护自身本地序列缓存,仅在需要重新填充时才与序列服务通信,而通过合理调优滑动窗口,这种通信频率并不高。
每秒处理上万订单的服务无法承受为每个序列都进行一次网络往返,厚客户端彻底消除了这一瓶颈。
遗留数据库的序列其实也有缓存,团队不会为每个值都发起数据库调用——那种方案本身就不可行。但现有缓存实现方式在各团队间不统一,通常为自研,与连接池行为绑定,在缓存块大小、重填策略、故障处理上均有差异。新系统对缓存进行了标准化,采用优化的双层架构。
厚客户端能处理:本地缓存管理、后台预填充、速率计算与预测、服务不可用时降级、进程内单调性保障。客户端不负责配置逻辑——对序列增量、起始值、生成方向一无所知,仅根据序列名称申请序列块,按接收顺序分配序号。所有复杂逻辑都在服务器端。
服务器向客户端建议初始填充速率,客户端通过滑动窗口持续监控实际消耗并自动调整。部署后几分钟内,即便初始配置不佳,也能收敛到适配实际负载的最优填充速率。
客户端与服务器端参数的不对称是有意设计:服务端每次从 DynamoDB 获取 500 至 1000 个序列的块,客户端缓存上限则根据应用需求设为 50 至 500。客户端内存更受限,因崩溃造成的序列浪费也更常见(应用频繁重启)。服务端处理多客户端请求,使用更大块合理;较小客户端缓存在缩容或重新部署时减少浪费。
双层缓存不仅能提升性能,还能针对中断提供分层保护。
客户端缓存避免服务中断影响:序列服务不可用时,客户端可继续从本地缓存获取。缓存 500 个序列、每秒消费 10 个,就能承受 50 秒服务中断而不影响业务。
服务器缓存避免 DynamoDB 中断影响:DynamoDB 分区中断或限流时,序列服务可继续基于内存缓存提供服务。每个计数器缓存数千个序列,可继续处理数分钟流量。
双重保护成倍提升系统弹性。DynamoDB 中断不会立即影响客户端,服务器缓存承接了影响;序列服务中断不会立即影响应用,客户端缓存承接了影响。组合式缓存让系统有效可用性高于任意单个组件,任意一层短暂中断对终端用户无感知。
不会有两个调用方获取到相同序列值,这个保证全局成立。通过 DynamoDB 条件写入确保每个数据块仅被分配一次,即便在崩溃、重试、网络分区等故障场景下,唯一性依然得到保证,最坏情况只会出现序号间隙,而非重复。
单个客户端实例内部序号严格递增(递减序列则严格递减)。跨客户端时,序号通常随时间递增,但整体可能无序——客户端 A 的 1050 可能在客户端 B 的 1100 之后被使用。
间隙是设计中固然存在的。服务器或客户端崩溃导致缓存中有未使用序列,或数据块已分配但未产生实际流量时,就会出现间隙。对大多数使用场景而言,间隙无关紧要。主键无需连续,审计日志也不要求序号必须保持顺序。真正需要无间隙序列的只有那些对外部连续性有强依赖的系统,而这类系统远比工程师最初设想的要少。
双层缓存与滑动窗口速率计算带来显著效率提升。
峰值每秒生成五万个序列的系统中:约 49500 个直接从客户端内存即时提供,约 450 个需要调用服务端(仍可通过服务端缓存快速响应),仅约 50 个触发 DynamoDB 写入。
滑动窗口的异步重填至关重要。由于重填在缓存耗尽前触发,几乎没有用户请求需要等待网络调用。无论后台发生何种情况,用户都能感受到一致的亚毫秒级延迟。
所有计数器峰值吞吐量超过每秒五万个序列,常规每秒一万至两万。流量最高的计数器单实例峰值约每秒五千个。绝大部分吞吐量并未真正请求序列服务,实际服务流量仅约为序列总消耗量的百分之一。
一个每秒提供 50000 个序列的系统,正常负载下每秒仅产生 10 至 20 次 DynamoDB 写入。1000:1 的比例得益于批量获取与预测重填的协同。
正常情况下,超过 99% 的请求命中客户端缓存。服务器调用极少,DynamoDB 调用更少。
成本方面:配置了最小写入容量且存储占用极低的 DynamoDB 每月约 50 美元;用于小规模集群、CPU 使用率较低的序列服务计算成本约 500 美元;网络成本可忽略,厚客户端模式有效减少跨服务流量。总月成本低于 1000 美元,支撑每秒数万序列的服务能力。
构建系统只是工作的一部分,让上百个团队真正落地使用是更难的部分。订单团队在三周内完成十二个服务迁移,峰值每秒 8000 个序列,全程零停机。
客户端 API 比遗留数据库更简单:
// 遗留数据库 (之前)
long id = connection.getNextSequenceValue("orders_seq");
// 新系统 (之后)
long id = sequenceClient.next("orders_seq");
对大多数团队,迁移只需修改一行代码并更新依赖。无需额外配置、初始化或参数设置,传入序列名称即可。所有复杂逻辑(步长、生成方向、块大小)均在接入时在服务器端处理。
序列配置完全在服务器端,保存在动态配置存储中。团队接入时使用与源数据库兼容的参数注册序列:序列名称、起始值、增量大小、最小值、最大值、生成方向(升序或降序)、块大小。服务器处理所有复杂性,客户端保持轻量。
迁移期间,团队可同时运行新旧系统,用特性开关控制切换。这种渐进式方法让团队能验证行为、监控性能、在完全提交前回滚。
序列服务现已运行超过一年,服务数百个生产服务,生成数十亿个序列,零计划外停机。架构的简洁性——刻意为之的简洁——是可靠性的关键。
最复杂的方案很少是最好的。分布式序列生成的文献充满 elegant 的协调机制,而实际需要的只是一个带缓存的计数器、一个滑动窗口,以及对"足够好"的坦然接受。
订单团队迁移完成后,一位工程师在复盘文档里写了一句:"以前我们以为序列是数据库的事,现在发现它其实是缓存的艺术。"
特别声明:以上内容(如有图片或视频亦包括在内)为自媒体平台“网易号”用户上传并发布,本平台仅提供信息存储服务。
Notice: The content above (including the pictures and videos if any) is uploaded and posted by a user of NetEase Hao, which is a social media platform and only provides information storage services.