分布式唯一 ID

分布式唯一 ID

业务系统对ID的要求:

1:全局唯一性,基本要求

2:趋势递增,单调递增。保证下一个id永远大于上一个id

3:信息安全,如果id时连续的可以通过id值推算出系统信息。

业务系统对ID生成系统的要求

1:高可用

2:高性能

3:高并发

UUID

由32个16进制数字组成 + 4个连接符,可以本地生成。

优点:

速度快,直接本地生成,没有任何网络消耗

缺点:

UUID长度太长,由36个字符组成。不易于存储。mysql主键越短越好。

信息不安全:基于mac地址生成,可能造成mac地址泄漏

无序:作为MySQL主键时,对B+树不友好,无序性会导致b+树频繁变动,严重影响性能。

雪花算法 snowflake

snowflake 生成的ID长度占用64bit。包含时间、机器、序列号等相信。

第0位:始终为0

1-41位:表示时间戳共41位,单位毫秒。可以支撑2的41次方时间(约69年)

42-52位:10位,前5位表示机房位置,后五位表示机器信息。可调整

53-64位:12位表示序列号,自增,单台机器每秒可产生2的12次方个ID。

优点:

ID自增:序列号在低位上自增

不依赖第三方系统,可独立部署。

性能高

可自定义分配bit位

缺点:

强依赖机器时间,存在时钟回拨问题,导致ID重复或服务不可用。

数据库生成唯一ID

通过数据库每次插入时自动递增获取ID

优点: 简单有序对聚簇索引友好,天然有序且唯一,

缺点:

不安全:通过id可计算出业务量

并发量低,存在单点问题,id没有业务含义,

每次获取id都需要请求数据库。

性能问题解决方案:部署多台mysql服务器,每台机器初始设置不同初始值,通过设置步长(与机器数相同)方式增加数据库性能。

缺点:系统水平扩展困难,服务器上数量时,扩展方案复杂难以实现。 ID没有单调递增只有趋势递增。

Redis生成唯一ID

通过 incr命令实现id的原子顺序递增

优点: 有序递增,速度快,配合集群高可用

缺点: 即使开启AOF,RDB还是混合持久化,都会丢失数据,早晨ID重复

美团 Leaf 方案实现

1:Leaf-segment 数据库方案

Leaf-segment 核心思想:

传统的数据库方案是每次请求数据库获取一个ID,Leaf-segment 则是一次性从数据库拿到一段ID(如:1~1000),存储在业务系统本地。使用时从本地以自增方式获取ID,当本地号码段使用完后,请求数据库获取下一段ID。业务系统作为调用方仅通过网络向Leaf服务索取ID。

sql 复制代码
-- Leaf-segment核心数据库表设计
CREATE TABLE `leaf_alloc` (
  `biz_tag`   VARCHAR(128) NOT NULL COMMENT '业务标识(用于隔离不同业务)',
  `max_id`    BIGINT(20)   NOT NULL DEFAULT '1' COMMENT '当前已分配的最大ID',
  `step`      INT(11)      NOT NULL COMMENT '号段步长(即每次获取的ID数量)',
  `update_time` TIMESTAMP  NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  PRIMARY KEY (`biz_tag`)
);

biz_tag:业务字段,不同业务使用此字段隔离。

max_id:当前业务已经分配的最大ID。

step:每次从数据库获取多少个ID。

例如:当三台业务系统,每个系统获取1000个ID(step),第一个系统获取区间1-1000,此时max_id=1000,第二个系统获取1001-2000,此时max_id=2000,第三个系统获取2001-3000,此时max_id=3000,当第一个系统ID用完则重新请求数据库获取3001- 4000的ID。

每次号段更新时需要使用事务保证原子操作。如果不用事务,可能两个请求读到相同的max_id

sql 复制代码
begin;
update  leaf_alloc  set max_id = max_id + step where biz_tag = "业务标识";
select  biz_tag,max_id,step  form leaf_alloc   where biz_tag = "业务标识";
commit;

优点:

leaf节点扩展方便:随性能要求可增加Leaf节点数量,但是要注意,数据库连接资源、Leaf节点负载均衡等。

趋势递增: 64位ID,且趋势递增。对DB索引树友好。只是比uuid友好。业务操作时插入数据时id乱序。

容灾性高:left节点内部缓存号码段,即使db宕机,短时间仍然可以提供服务。

配置灵活:灵活配置step。

缺点:

1:ID可计算,通过ID可计算出业务量

2:对DB依赖高,如果DB宕机,服务将不可用。 (db高可用)

3:TP999尖刺问题:当本地号码段用完后,下一次请求必须同步 获取,IO请求会导致该请求的延迟显著增高。(双buffer优化)

4:全局趋势递增当没有严格递增,如leftA号码段1,1000,节点B号码段1001,2000,但因网络延迟节点b的ID1001先于节点a的900被使用,1001先入库,900后入库。

双 buffer 优化

left节点内部设有两个号段缓冲区,当前号段可用率不足10%时,如果下一个号段未更新,启动一个更新线程去更新下一个号段。如果当前号段下发完毕后,下个号段准备好了,直接切换下一个号段。循环往复。

每次请求都会判断下个号段状态,更新号段。偶尔网络抖动不影响下个号段的更新。

通常segment长度设置为服务高峰期发号QPS的600倍(10 分钟),即使db宕机,leaf节点仍然保持10-20分钟的发号。

db高可用

一主两从模式,分机房部署,采用半同步方式。可能在一定情况下退化为异步方式,极端情况下造成数据不一致。

如果要保证强一致性,使用mysql group raplication 组模式,或集群模式,事务只有在集群内半数节点成功接收并写入日志后再算成功

2:Leaf-snowflake 方案

Leaf-segment方案是生成趋于递增的ID,ID是可计算的,不适用订单ID生成场景。如两天中午12点同时创建点订单,通过订单ID相减就能大致算出一天订单量,这是不能忍受的。

Leaf-snowflake方案使用snowflake的bit位设计,但是对于workerId的设置,集群节点量小时,可以手动设置。集权节点量大时可以使用zookeeper持久顺寻节点特性自动对snowflake排位置workerid。

Leaf-snowflake 启动过程:

1:启动Leaf-snowflake服务,连接zookeeper,在leaf_forever 父节点下检查自己是否已经注册过(是否有该顺序子节点)

2:如果有注册直接使用zk顺序节点生成的id代替workerID。

3:如果没有注册过就在该父节点下创建持久顺序节点获取ID,当自己的workerID。启动服务

弱依赖 ZooKeeper :当zookeeper宕机,leaft节点重启时通过本地缓存的workerID文件保证正常启动。

时钟问题:

如果时间回拨,会发生重复ID问题。

新节点启动时,通过zk获取系统中所有leaf节点的ip与port,请求获取所有节点系统时间。计算时间平均值 sum(time) / 节点数,本机时间如果与平均时间是否在允许阈值之内,在,启动服务。不在,启动失败报警。

老节点启动:比较自身与zk上 节点曾经记录的时间,及所有运行中节点的时间,不准确,启动失败报警

NTR同步问题:

服务运行时NTR同步也会造成时间回退,由于强依赖时间,对时间要求敏感。

解决:

1:关闭NTR同步

2:时钟回拨时不提供服务,直至时钟追上

3:重试,上报报警系统

4:发现时钟回拨,自动摘除本身系统,报警。

对于订单ID这种对外展示的号码,那种Leaf方案都不能直接使用。只能作为数据库主键或内部使用。对外订单号需要设计一个不可预测不可推算的订单号。

Hashids

将内部自增id(如leaf:123456) 通过加盐hash和字母映射 变成随机字符串。性能极高纯cpu计算。

java 复制代码
Hashids hashids = new Hashids("your-salt-here", 10); // 盐值,长度10
String orderNo = hashids.encode(123456L); // 输出例如 "Rk9zLm2NqP"
// 解码(用于内部反查)
long internalId = hashids.decode(orderNo)[0];

对称加密,非对称加密

数据库读写分离

mysql InnoDB Cluste 模式,组复制,主从结构都可以实现读写分离;

程序访问数据库时将读写请求分离开,写请求访问主节点,读请求访问从节点。

如使用:ShardingSphere 实现读写分离

主库执行业务系统的更新数据请求,然后将数据同步到从库中。主从数据一致,多个从库分担读请求。并发数量可提升几倍到几十倍。读写请求越来越多时优先考虑扩容方案。

主库数据如何同步至从库的?

如何保证数据的强一致性?

读写分离的数据不⼀致问题

主从同步时需要经过IO操作,会存在时间差。正常时主从同步延迟非常小,毫秒级。但也会导致某时刻主从不一致问题。

解决:

1:强制读主库 ---- 强一致性要求(例如扣款后查询余额)

对于写后立即读场景,可以将读请求发送到主库。缺点:增加主库压力,浪费从库能力

2:业务兜底 ---- 可容忍短暂不一致(例如商品浏览量)

对于能接收短时间不一致场景,可以通过业务设计降低数据不一致营销。

如⽀付完成后不会⾃动跳到到订单⻚,⽽是进入⼀个⽀付完成⻚⾯,这个⻚⾯其实没有任何新的有效信息,就是告诉你⽀付成功的信

息。如果想再查看⼀下刚刚⽀付完成的订单,需要⼿动选择,这样就能很好地规避主从同步延迟的问题

3:版本号或时间戳 ---- 对延迟敏感但不强求绝对一致(例如最新评论列表)

写操作时将当前数据的版本号或者时间戳写入到redis,读数据时如果从库数据与版本号或时间戳不一致,则等待(sleep)后重新读取,或者去主库读取

4:ShardingSphere

支持写之后的读请求自动使用主库

5:数据库层面减小延迟

主从半同步复制改为异步复制,提升数据同步性能,但也会存在延迟

组复制使用强一致性写入,但写性能下降。

6: 缓存层 + 异步回写 ----适合高并发、需要极致吞吐

数据主从同步使用异步方式

写入时同时写缓存,读请求优先读取缓存,缓存过期后读取从库。存在缓存与数据库的双写一致性问题;

读写分离的数据不一致没有万能解,核心思路是:识别业务对一致性的要求,对强一致操作强制读主库,对弱一致操作接受从库延迟并辅以重试/校验

分库分表

分表:将数据拆分到一个数据库多张表里面

分库:将数据拆分到不同的数据库实例中。

**原则:**能不拆就不拆。数据拆分越分散,并发维护就越麻烦,系统出现问题概率越大。先使用缓存、读写分离、索引优化、数据归档等方案

什么时候分库什么时候分表

分表:解决单表数据量太大,查询缓慢问题;查询指事务中查询,非事务查询使用读写分离。

分库:解决单库负载高,数据库性能成为瓶颈,如cpu、内存、磁盘、链接数达到上限。

场景A:订单表 2000 万行,查询慢,但 CPU 30% → 先分表。

场景B:订单表 2000 万行,CPU 85%,连接数经常爆满 → 直接分库(比如按用户ID哈希到4个库)。

场景C:日志表每日千万级,保留30天,总行数超3亿 → 用分表(按天分区)+ 历史归档,无需分库。

分片键作用:分片键决定每行数据应该落到哪个分片(库\表)上。

分片算法:决定如何从分片键计算出目标编号。

步骤:

1:预估并发量及数据量,计算拆分成多少个库多少张表。

2:选择分片键与分片算法

分片键选取原则:

1:分片键应是高频查询条件,多条件查询时应带上分片键,否则会变成全分片扫描

2:分片键对应的值应该足够离散,能够将数据均匀分布,避免数据倾斜

3:新写入的数据应该均匀落入各个分片,防止分片热点。

4:分片键一旦确定就不能修改。否则数据要迁移

5:分片键的取值要远远大于分片数

6:避免符合分片键,否者查询时要包括所有分片字段

分片算法选择:

1:hash -> shard = hash(key) % N , 分布均匀,不支持范围查询。建议n设为2的n次幂。

2:范围,给每个分片划定区间,范围查询友好、扩容简单直接加新区间,但可能数据倾斜(热区间),存在写入热点新数据都会写入最后一个分片上。如按用户ID区间分片,shard0: userId 1, 1000000,shard1: userId ∈ 1000001, 2000000,shard2: userId ∈ 2000001, 3000000

3:一致性哈希,将 key 映射到环上,顺时针找最近的虚拟节点。扩容时只需要迁移少量数据,但是实现复杂,适合频繁增加节点的场景;假设哈希空间 0~99,节点 A 哈希 20,节点 B 哈希 60。key :user123 哈希值 45 → 顺时针找到节点 B(60),归属 B。增加节点 C,哈希 50。那么 key 哈希在 (20,50] 范围内的数据(原来归属 B)迁移到 C,其他不变。

4:映射表:通过记录查询条件与分片的映射关系,查询灵活,可动态调整,但需多进行一次查询。

5:多个字段组合hash ->hash(user_id + order_id) % N ,支持多个字段同时查询,但查询时必须提供全部分片键,否则全分片查询。

分片键选高频、均匀、稳定、高基数 的字段;分片算法优先取模/哈希(简单高效) ,若需频繁扩容 则用一致性哈希 ,若查询多为范围则用范围算法 。绝对不要用低基数、自增连续、可变更的字段做分片键。

历史数据归档(冷热分离)

根据业务需求讲历史数据归档在其他数据库MongoDB、ES等。

迁移之前⼀定要做好备份

迁移过程从mysql迁移到MongoDB。先从mysql获取指定批量的数据,写入MongoDB,再从mysql删除已迁移的数据。

写入MongoDB时要同时写入迁入数据的最大ID,使用事务操作。在mysql迁出数据时,总是从mongoDB获取上次处理的最大ID,作为本次迁移的开始。

迁移过程中需要删除mysql的数据,删除时最好通过主键ID删除,并且要控制每次删除的行数,大量行一起删除容易遇到错误。

使用主键删除好处:表数据结构就是按照主键组织的B+树,本身有序,查找快,不需要进行额外排序。

大批量删除数据,执行删除语句后,会影响B+树的页面分裂与合并,这时mysql本身负载很大,可以删除数据时停顿一小会,先让mysql负载更均衡。