一、引言
在单体系统盛行的时期,程序通常部署于单个物理机,数据也存储在单一数据库内。此时,利用数据库的自增ID即可轻松达成ID的全局唯一性。
然而,随着时代发展,系统逐步从单体架构向分布式系统演进。面对业务量与数据量的持续攀升,分库分表成为常见选择。与此同时,微服务理念的广泛传播与应用,使得系统中的服务数量不断增多。
在复杂的分布式环境下,对海量数据进行唯一标识依旧是关键需求。但显而易见,数据库自增ID已难以契合新的应用场景,故而需借助其他方式来生成全局唯一ID。
实际上,实现分布式全局唯一ID的方案众多,诸如基于Redis的分布式ID方案、UUID、数据库号段模式、雪花算法等。而本文聚焦于深入探讨如何通过号段模式实现分布式ID,以及为何在众多方案中选择“号段模式”。要解答这个问题,首先得明晰业务系统对分布式ID的具体要求。
依笔者之见,业务系统对分布式ID的要求主要涵盖以下四个方面:全局唯一性、趋势递增、单调递增以及信息安全。下面,将对这四点逐一展开分析。
- 全局唯一性:确保ID在全局范围内具有唯一性,这无疑是分布式ID最基础且核心的要求。
- 趋势递增:所谓趋势递增,是指分布式ID总体呈现增长态势,不过其序列并非紧密相连。以MySQL的InnoDB引擎为例,它采用的是聚集索引,底层数据结构为B+树,使用有序主键能够保障数据写入的高效性。这也正是不建议采用UUID(Universally Unique Identifier,通用唯一识别码)作为ID的缘由。UUID的无序特性,会致使新增数据时顺序紊乱,进而频繁引发页分裂问题,对系统性能产生严重负面影响。
- 单调递增:除了要求ID增长具备有序性,还需保证其单调递增。即下一个新增的ID必定大于上一个已存在的ID,如此方能满足事务版本号、排序等特定业务场景的需求。
- 信息安全:在部分应用场景中,ID需具备不规则性,以增加被猜测的难度。例如订单号,若订单号呈现顺序递增规律,竞争对手便极易据此推测出每日的订单量,这显然不利于商业信息安全的维护。
号段模式能够满足全局唯一性、趋势递增以及单调递增这三个关键要求,因此成为本文的研究对象。而对于信息安全要求较高的场景,如订单号生成,通常会选用雪花算法。那么,究竟该如何通过号段模式实现分布式ID呢?
二、如何运用号段模式实现分布式ID?
设想一下,我们在数据库里构建一张全局ID序列表。比如,这张表名为common_sequence
,它包含id
、name
、value
、gmt_modified
四个字段。值得留意的是,不同业务通过name
字段加以区分,各个name
对应的ID获取操作相互独立、互不干扰 。
CREATE TABLE `common_sequence` (
`id` int(11) unsigned NOT NULL AUTO_INCREMENT,
`name` varchar(64) NOT NULL DEFAULT '',
`value` bigint(20) NOT NULL,
`gmt_modified` date NOT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `name` (`name`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
当需要为某张表生成主键ID时,便从该序列表中分配全局主键ID。
举例而言,若要新增一个客服工单并使其ID自增。此时,在全局ID序列表中,插入一条name
为task
的记录,其value
值设为1,这意味着该业务表自增ID的当前值为1。
然而,倘若每次获取ID都对数据库进行一次读写操作,将会给数据库带来较大压力。那么,有没有行之有效的优化办法呢?
实际上,我们能够实施一项简单的优化策略:每次从全局ID序列表中一次性获取 一批ID,随后存入JVM本地缓存,以供后续逐步使用;当这批ID耗尽时,再向全局ID序列表重新发起一次读写请求。这里从全局ID序列表申请到的这一批可用ID,我们称作 ID号段。
ID分段之后,让我们来审视一下整体流程。
以新增客服工单为例,此时需要向全局ID序列表申请可用号段。假设预申请5000个ID。首先,客服工单服务会查询全局ID序列表,获取当前name
等于task
的记录的最新值。在此例中,最新值为1。
紧接着,全局ID序列表会更新相应记录的值。将最新值加5000,得到5001,并将其存储起来。
随后,客服工单服务将可用号段[1, 5000]存储到JVM本地缓存中。此后,客服工单服务便在区间[1, 5000]内依次获取ID。
一旦客服工单服务将该区间的值用尽,便会再次请求全局ID序列表,进而获取可用的[5001, 10000]区间的ID。
通过这一方案,在号段使用完毕后才去数据库获取新号段,能够极大地降低对数据库的依赖程度,减轻数据库所承受的压力。
总结来说, 号段模式是每次向全局ID序列表获取一批可用的ID号段,然后存入JVM本地缓存。
这里我们预申请5000个ID中的“5000”,被称为 步长。当这批号段消耗殆尽后,我们再向全局ID序列表重新发起一次读写请求。如此一来,只有在5000个ID全部用完后,才会再次读写数据库。所以,读写数据库的频率从1次降低至1/5000次。
号段模式 不仅显著提升了数据库的读写性能,而且在横向线性扩展方面也极为便捷。
例如,若部署3台客服工单服务,它们可分别申请可用的[5001, 10000]、[10001, 15000]、[15001, 20000]号段。之后,全局ID序列表会将该业务的自增ID可用值更新为20001。多台客服工单服务借助号段生成算法的原子性,能够确保每台服务上的可用号段不会重复,进而保证ID在全局范围内的唯一性。
三、使用号段模式实现分布式ID,常见问题有哪些?
思考一下,这种实现流程是否潜藏着一些问题呢?实际上,确实存在。
服务重启导致可用号段浪费
我们面临的首要问题是,若某台客服工单服务重启,该服务所占用的号段就会失效。因此,在配置步长时需 格外谨慎,尽可能降低可用ID的浪费情况。
然而,减小步长会间接增大数据库的性能压力,因为数据库读写频率与步长成反比,即:
数据库读写频率 = 1 / 步长
所以,步长的配置需要采取一种折中的策略。我们可以通过观测日常业务峰值以及大促期间的业务峰值,来动态地配置步长。此外,由于服务重启致使可用ID浪费,还会导致ID不连续,但对于大多数业务场景而言,这是可以接受的。
并发安全:多台服务同时获取ID区间段
我们遇到的第二个问题是,当多台服务同时获取号段时,可能会引发竞争问题。
其实,我们可以 借助悲观锁来解决这一问题。最易于实现的方案是利用数据库自身的行锁机制。数据库行锁在数据处理过程中,会将数据锁定,以此确保数据访问的排他性。
若考虑到数据库悲观锁会导致阻塞等待的情况,我们也可以考虑为全局ID序列表添加一个版本号,通过乐观锁的方式加以实现。即每次更新操作都带上版本号,从而保证并发更新的正确性。
监控大盘出现毛刺:线程阻塞等待
我们遇到的第三个问题是,当服务耗尽号段并向全局ID序列表重新发起读写请求时,在这个关键节点可能会出现线程阻塞在从数据库取回号段的等待过程中,其外在表现为监控大盘上偶尔出现的毛刺现象。
针对这一问题,业界提出了 双号段缓存
方案。在一些开源框架中,如美团的leaf和滴滴的tinyid,都对 双号段缓存
方案提供了支持。
双号段缓存方案 的核心思路是,在号段即将用尽时,异步加载下一个可用号段,确保JVM本地缓存中始终有可用号段。这样一来,就无需等到号段完全用尽才更新号段,进而避免了性能波动。
实际上,在双号段缓存方案中,服务内部的缓存区设有两个号段:号段A和号段B。当当前号段A使用到一定程度时,如果下一个号段B尚未更新,服务便会开启一个线程异步更新下一个号段B。
当当前号段A全部耗尽,且下一个号段B准备就绪时,便将缓存区中的号段A与号段B进行切换,即当前可用号段A变为号段B,如此循环往复。
单点故障
我们遇到的第四个问题是,当数据库仅存在一个实例时,会存在单点故障风险。也就是说,一旦数据库不可用,获取号段的操作也将无法进行。因此,我们还需要支持多数据库实例。
此时,我们需要引入两个新的概念,即 外步长和内步长:
- 外步长,主要用于服务向全局ID序列表申请号段;
- 内步长,主要用于在多个数据库实例之间分配序列,以避免重复分配。
这里有一个用于计算新值的公式,该新值用于计算号段的生成区间:
新值=(新值 - 新值 % 外步长)+外步长+数据库第i个实例*内步长;
举个例子,假设有两个数据库实例,我们将外步长设置为1000,内步长也设置为1000。客服工单服务向数据库1申请可用的[1, 1000]号段。
当这1000个ID耗尽,再次进行数据库读写操作时,恰好路由到数据库2,随后数据库2分配可用的[1001, 2000]号段,并依据计算公式将自身的值更新为2001。
四、总结
在分布式系统中,我们探讨了如何运用号段模式实现分布式ID,该模式能够满足全局唯一性、趋势递增、单调递增这三个关键要求。
号段模式的核心机制是,每次从全局ID序列表获取一批可用的号段,将其存入JVM本地缓存中使用。当这批号段使用完毕后,再向全局ID序列表重新发起一次读写请求,获取新的号段。
不过,在实际运用号段模式的过程中,存在4个潜在问题需要关注:
- 服务重启可能会致使可用号段被浪费。虽然可以通过减小步长来缓解这一问题,但步长的减小会增加数据库的读写频率,因此需要在两者之间找到平衡。
- 多台服务同时获取ID区间段时,会面临并发安全问题。针对这一情况,我们可以采用悲观锁,利用数据库行锁保证数据访问的排他性;或者采用乐观锁,给全局ID序列表添加版本号,以此确保并发更新的正确性。
- 线程在等待从数据库取回号段时,可能会出现阻塞,进而导致监控大盘上出现毛刺现象。为优化性能,我们可以采用双号段缓存方案,在号段快用完时异步加载下一个号段,保证JVM本地缓存始终有可用号段。
- 若数据库只有一个实例,存在单点故障风险,这可能导致无法获取有效的号段。为规避该问题,我们可以引入多数据库实例,并借助外步长和内步长的概念,合理分配序列,确保ID生成的准确性与稳定性。
版权声明:本文内容由互联网用户自发贡献,该文观点仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌侵权/违法违规的内容, 请发送邮件举报,一经查实,本站将立刻删除。
文章由技术书栈整理,本文链接:https://study.disign.me/article/202513/5.segment-id-distribution.md
发布时间: 2025-03-26