代码层走进“百万级”分布式 ID 设计
1. 前言
面对互联网系统的三高(高可用,高性能,高并发),数据库方面我们多会采用分库分表策略,如此必然会面临另一个问题,分库分表策略下如何生成数据库主键?那么今天针对此问题,我们就聊聊如何设计一款“百万级”的分布式 ID 生成器。
2. 项目背景
由于业务拓展单量剧增,为满足现有业务发展,遂决定对当前业务进行分库分表改造。分库分表形式下如何保证逻辑表在不同库、不同表下主键的唯一性成为了首要解决的问题,之初考虑仍采用数据库方式生成主键,但考虑数据库系统瓶颈、系统性能等问题,故调研后决定开发部署一套可支持百万级的分布式 ID 生成器,以用来支持现有业务,并逐步为后续其它业务做支撑。
3. 技术选型
明确项目背景之后,就是技术选型了。
之后比对了 uuid 方式、Redis 计数器、数据库号段、雪花算法、美团 Leaf 等多种 ID 生成器的方式。由于 uuid 的随机无序性,易导致 B+Tree 索引的分裂,不适合做 MySQL 的数据索引;Redis 计数器需要考虑其持久化方式,宕机情况下可能会导致号段重复等问题,故暂不考虑以上 2 种方式。之后又对其它的方式如数据库号段、雪花算法等的优缺点、是否引入新的技术依赖、复杂度等进行分析,最终决定采用类似美团 Leaf 的方式生成分布式主键 ID。
4. 架构设计
4.1 总体架构
总体上采用双缓存架构,业务 key 对应的号段持久化在数据库之中,每次从数据库加载指定步长号段保存到本地缓存,业务请求优先从本地缓存获取 ID。
执行步骤如下:
STEP1:服务启动或首次请求时-从数据库加载当前业务 key,根据业务 key 配置步长,加载号段到本地。
STEP2:业务 key 调用时,优先从本地缓存 A 获取 ID。
step 2.1:如当前“本地缓存 A”的使用率超过 15%(可动态调整),将异步从数据库加载号段到本地缓存 B;
step 2.2:如当前“本地缓存 A”号段已使用完,切换缓存为“本地缓存 B”,继续提供服务。
STEP3:返回请求结果(极端情况缓存 A 号段耗尽,缓存 B 号段未加载完成,重试一定次数后失败)。
4.2 详细设计
如何支持百万的 QPS,如何保障业务的高可用?为满足高并发、高可用分布式号段的数据结构又该如何设计的呢?接下来我们从表结构、缓存结构两个方面看下分布式号段的详细设计,逐步揭开其神秘的面纱:
4.2.1 表结构设计
表核心字段如下:id:主键 biz_key:业务 keymax_id:当前业务 key 号段使用的 MAX 值 step:步长(每次加载 step 步长到本地缓存)
4.2.2 缓存结构设计
了解完表结构之后,大家肯定还会有疑问,如仅仅采用数据库的方式实现分布式 ID,其可支持的 QPS、系统的稳定性多数都得不到保障,那又是采用什么样的数据方式保障系统的高并发、高可用呢?接下来我们从“缓存结构”设计中找答案:
Buffer(缓存管理器)
bizKey:业务 keysegments:数组存储双缓存 currentIndex:游标,指向 segments 中当前正在使用的缓存 segmentModifyStatus:CAS 方式更新此号段状态 readWritelock 读写锁:号段的读取、更新采用加锁方式采用读写锁(此场景读多写少)
segment(实际操作缓存)
bizKey:业务 keymaxId:当前缓存支持最大值 step:数据库加载时业务 key 的步长 current:当前号段已用值 threshold:更新下一个缓存阀值
4.3 关键流程链路
当清楚前面提到的“表结构”和“缓存结构”后,接下来我们来看下关键流程链路,更加清晰的了解到以上介绍的“表”和“缓存”在业务中的应用,详细信息如下:
服务初始化加载业务 bizKey
根据业务 bizKey 获取 ID
双缓存-预加载(提前加载下一个缓存)
双缓存-缓存切换
相信大家可以从上图看出关键信息,充分了解到关键业务及其实现细节,下面是从业务和技术上做简单的概述。
(1)业务概述
服务初始化加载号段:为了不影响服务发布后的 t,遂采用饿汉式模式,服务启动时加载指定步长的号段到本地缓存;
业务 key 维护:新增或下线的业务 key 通过 JOB 定时维护,新增 bizKey 添加到本地缓存,失效 bizKey 从本地缓存移除(前期业务 key 比较少全表扫描,后期 bizKey 较多时可采用通知或扫描指定时间变更的增量 bizKey);
预加载:当前缓存使用超阈值后,异步加载另一个缓存;为了尽量保障业务的稳定性,一般设置当前缓存使用到 15%左右(可动态调整),开始执行预加载;
缓存切换:当前缓存号段耗尽,切换到下一个缓存并继续提供服务;
(2)关键技术
ReadWriteLock 锁应用:此业务场景是典型的读多写少场景,故采用读写锁模式。
读锁:获取分布式 ID;
写锁:预加载下一个缓存、缓存切换。
CAS 原子操作:预加载下一个缓存时,为了避免单机多线程同时操作,采用 CAS 方式更新 Buffer 的状态标识,更新成功的线程才可以进行异步预加载操作。
volatile:保障数据的可见性,确保共享变量能被准确和一致地更新保障。
5. 总结 &展望
项目完成之后进行压测,在步长设置合理的时候,单机可支持近 10 万 QPS,压测过程中其 TP 正常,TP99、TP999 基本维持在 5 毫秒以内,整体上已满足现阶段业务需求。
虽然现阶段的设计已满足当前业务需求,但是可以优化的空间还很大,我们还有很长的路要走,比如下面的号段浪费、动态规划步长等。
(1)号段浪费
应用启动时加载号段,如遇服务重启、发版等情况会浪费掉部分号段。
针对此问题可以:
服务启动时初始化 10%步长的号段,尽量减少首次初始化号段数量
服务关闭时添加钩子,保存号段使用情况到 Redis,服务启动后可优化从 Redis 号段池加载到本地缓存。
(2)动态规划步长
目前步长是手工配置,后期可根据号段的更新频率,匹配一定的规则,动态调整业务 key 对应的号段(可以在申请时配置:步长动态调整规则)。
(3)数据库分库分表
现阶段 bizKey 较少,后期有需求可根据 bizKey 分库分表。
(4)持久化方式优化
目前仅采用 MySQL 持久化号段信息,根据业务可以添加多级缓存,可引入 Redis,数据库预加载号段到 Redis,本地缓存优先从 Redis 获取号段加载到本地。
(5)监控告警
结合公司组件,目前对单个接口以及单个 bizKey 的 QPS、可用率、TP 进行了监控。可在此基础上增加:号段更新频率、号段单机分布情况(已分布号段、已使用号段)等进行监控。
6. 结语
以上内容简单的总结了该项目的背景、选型、设计等内容,总体方案上或许并不是最优解,还有许多待改进点。也是秉着先有后优,逐步拓展、迭代的思想,选择了分期、分需实现,在满足当前业务的情况下,快速、稳定、持续落地!
感谢大家的支持,希望通过这篇文章可以让你了解到,原来部分百万业务量的设计也并不复杂,原来仅需上十台服务器也可以轻轻松松支撑百万 QPS 的业务!
*文/袁向飞
关注得物技术 @得物技术公众号
版权声明: 本文为 InfoQ 作者【得物技术】的原创文章。
原文链接:【http://xie.infoq.cn/article/fee707b573054f486a27ceec1】。
本文遵守【CC-BY 4.0】协议,转载请保留原文出处及本版权声明。
评论