分布式唯一 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负载更均衡。