分布式ID
分布式 ID 介绍
我们都知道 ID 是什么内容,也知道需要使用 ID 做什么。
无非就是对系统中的各种数据使用一个唯一编号 ID 来进行唯一标识,这样就能在系统中对数据进行操作。而且 ID 具有独立性,就是说你产生了一条数据需要被持久化,就应该给它一个ID,比如用户 ID 对应且仅对应一个人,商品 ID 对应且仅对应一件商品,订单 ID 对应且仅对应一个订单。
我们现实生活中也有各种 ID,比如身份证 ID 对应且仅对应一个人、地址 ID 对应且仅对应一个地址。
简单来说,ID 就是数据的唯一标识。
那么,单机情况下,我们使用的 ID 为什么和分布式下的情况不一样呢?
平时写单体应用、本地程序、单库单表时,用的一般是数据库自增 ID,经过处理的时间戳,UUID 这种,为什么只需要保持唯一性就可以直接使用?
因为只有一个人在发号,只有一台机器,只有一个数据库,只有一个进程在生成ID,不会出现两个多个人同时生成同一个ID的情况,所以说,这种情况下保持简单,自增,唯一就可以
分布式环境下的ID就不能这样思考了,一旦变成分布式,就会有多台机器,多个服务,多个数据库,多个进程同时生成 ID,自增啊计数器啊都会失去唯一性,所以说,分布式 ID 必须满足
- 全集群全局唯一
- 高可用,高并发
- 尽量短的情况下趋势递增
所以才会出现 UUID,Snowflake 雪花算法,百度 uid-generator,美团 Leaf,数据库号段模式,Redis 自增 等等给分布式下用的 ID
什么是分布式 ID?
https://javaguide.cn/distributed-system/distributed-id.html
分布式 ID 是分布式系统下的 ID。
我司的一个项目,使用的是单机 MySQL 。但是,没想到的是,项目上线一个月之后,随着使用人数越来越多,整个系统的数据量将越来越大。单机 MySQL 已经没办法支撑了,需要进行分库分表
在分库之后, 数据遍布在不同服务器上的数据库,例如数据库的自增主键已经没办法满足生成的主键唯一了。我们如何为不同的数据节点生成全局的唯一主键呢?
这个时候就需要生成分布式 ID了。
分布式 ID 最基本除了上面提到的,全局唯一,高性能,高可用,尽可能短且方便使用,一个比较好的分布式 ID 还应保证,安全,递增,有具体的业务含义,独立部署
数据库层次下的分布式ID解决方案
很少有人使用这些思路解决
数据库自增主键
这种方式就比较简单直白了,就是通过关系型数据库的自增主键产生来唯一的 ID。
但是,明明分布式下不可靠的方案,是如何改造适配分布式的情况下的
那么,就是变成了单库单点发号,放弃让业务库自增,专门搭建一个独立的 ID 服务库,所有业务节点都向这个库请求自增 ID。
建立一张专门生成 ID 的表
1
2
3
4
5
6
7CREATE TABLE `id_generator` (
`id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '全局唯一ID',
`biz_type` varchar(32) NOT NULL COMMENT '业务类型(区分不同业务的ID)',
`create_time` datetime DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_biz_type` (`biz_type`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;业务侧获取 ID 的逻辑:
- 向这个库插入一条对应
biz_type的空记录(或更新),拿到自增的id; - 把这个
id作为业务表的主键使用。
- 向这个库插入一条对应
很明显,这种思路简单但是违背分布式的初衷,ID 库挂了,整个系统无法生成 ID,直接报废,那你还做分布式干什么,而且所有请求都走这一个库,并发高了扛不住,根本就是假的分布式
数据库号段模式
数据库主键自增这种模式,每次获取 ID 都要访问一次数据库,ID 需求比较大的时候,肯定是不行的。
如果我们可以批量获取,然后存在在内存里面,需要用到的时候,直接从内存里面拿就可以了,这也就是我们说的 基于数据库的号段模式来生成分布式 ID。
也叫分段自增,它们是对 单点自增 进行再一步的优化,也是美团 Leaf、百度 UID-generator 等框架的核心思路之一,解决了单点性能瓶颈问题。
因此这样不再每次生成 ID 都请求数据库,而是从数据库中批量获取一个号段,业务节点本地用完这个号段后,再去数据库取下一个号段,请求次数少了很多
也即是说,之前是一个个拿,这次一下拿一批,用完了再去拿,然后加一个乐观锁,保证同一时刻只有一个节点能更新号段
数据库的号段模式也是目前比较主流的一种分布式 ID 生成方式。像滴滴开源的Tinyid 就是基于这种方式来做的。不过,TinyId 使用了双号段缓存、增加多 db 支持等方式来进一步优化。
创建一个数据库表,用于管理 ID 号段
1
2
3
4
5
6
7
8CREATE TABLE `sequence_id_generator` (
`id` int(10) NOT NULL,
`current_max_id` bigint(20) NOT NULL COMMENT '当前最大id',
`step` int(10) NOT NULL COMMENT '号段的长度',
`version` int(20) NOT NULL COMMENT '版本号',
`biz_type` int(20) NOT NULL COMMENT '业务类型',
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;current_max_id字段和step字段主要用于获取批量 ID,获取的批量 id 为:current_max_id ~ current_max_id+step。
version字段主要用于解决并发问题(乐观锁),biz_type主要用于表示业务类型。那么,我们是如何使用的呢?
初始化,业务节点 A 向数据库请求
biz_tag=order的号段,数据库返回current_max_id=1000、step=1001
2
3
4SELECT current_max_id, step, version
FROM sequence_id_generator
WHERE biz_type = 1; -- 查订单业务的号段
-- 返回结果:current_max_id=1000, step=100, version=1并把库中的
current_max_id更新为 1100;(注意这里有个乐观锁,只有当version与查询时一致时,才更新current_max_id(加 step),并把version加 1)1
2
3
4
5
6
7UPDATE sequence_id_generator
SET
current_max_id = current_max_id + step, -- 1000+100=1100
version = version + 1 -- 版本号从1→2,防止其他节点重复更新
WHERE
biz_type = 1
AND version = 1; -- 乐观锁:只有版本号匹配才更新节点 A 本地生成 1001~1100 的 ID,全程不用再请求数据库;
节点 A 发现 1001~1100 用完了,再去数据库取 1101~1200 的号段
1
2
3
4
5
6
7UPDATE sequence_id_generator
SET
current_max_id = current_max_id + step, -- 1100+100=1200
version = version + 1 -- 版本号从2→3
WHERE
biz_type = 1
AND version = 2; -- 匹配最新版本号假如节点 B 也需要使用,节点 B 同时请求时,如果 A 还没取,数据库会返回 1101~1200,天然不重复。
1
2
3
4
5
6
7UPDATE sequence_id_generator
SET
current_max_id = 1200,
version = 3
WHERE
biz_type = 1
AND version = 2;
为了避免 号段用完时请求数据库 的这个延迟,我们采取双号段缓存的策略,本地缓存两个号段,用第一个号段时,异步去取第二个号段,这样就能做到无缝切换。
但是很明显,这样的话 ID 可能就不是连续的,因为号段之间有间隔,而且不太好配置,step 太小频繁请求库,太大浪费多,最后如果节点宕机,该节点未用完的号段会浪费
自增偏移的简单分布式 ID
如果你的业务已经做了分库分表,可直接基于分表规则生成 ID,避免独立的 ID 服务。
核心思路就是给每个分表设置不同的自增起始值和步长,比如:
- 分表 1(table_0):ID 自增步长 4,起始值 1 → 生成 1,5,9,13…
- 分表 2(table_1):ID 自增步长 4,起始值 2 → 生成 2,6,10,14…
- 分表 3(table_2):ID 自增步长 4,起始值 3 → 生成 3,7,11,15…
- 分表 4(table_3):ID 自增步长 4,起始值 4 → 生成 4,8,12,16…
这样所有分表的 ID 全局唯一,且趋势递增。
但是,仅供参考,因为步长固定等于分表数,后续扩容分表会很麻烦,而且 ID 分布不均匀;
Redis方案
一般情况下,NoSQL 方案使用 Redis 多一些。我们通过 Redis 的
incr 命令即可实现对 id 原子顺序递增
利用 Redis 的单线程特性和原子操作,生成全局唯一 ID,性能比数据库方案更高
Redis 的 INCR/INCRBY
命令是原子的,可直接作为全局自增计数器:
首先为不同的业务设置独立的 key,比如
order_id_counter,user_id_counter每次生成 ID 时,执行
INCR order_id_counter,返回的结果就是唯一 ID;1
2
3
4
5
6127.0.0.1:6379> set sequence_id_biz_type 1
OK
127.0.0.1:6379> incr sequence_id_biz_type
(integer) 2
127.0.0.1:6379> get sequence_id_biz_type
"2"
当然,Redis 也可以做到批量生成来实现类似数据库号段模式的情况,例如用
INCRBY order_id_counter 100 一次性获取 100 个
ID,本地使用
只不过,别忘了开启 Redis 持久化,建议是混合持久化,保证计数器的值在 Redis 出了点啥问题之后不丢失,
为了提高可用性和并发,我们可以使用 Redis Cluster。Redis Cluster 是 Redis 官方提供的 Redis 集群解决方案(3.0+版本)。而且除了 Redis Cluster 之外,你也可以使用开源的 Redis 集群方案Codis
除了 Redis 之外,MongoDB ObjectId 经常也会被拿来当做分布式 ID 的解决方案。
MongoDB官方文档 ObjectID可以算作是和snowflake类似方法,通过“时间+机器码+pid+inc”共12个字节,通过4+3+2+3的方式最终标识成一个24长度的十六进制字符。
MongoDB ObjectId 一共需要 12 个字节存储:
- 0~3:时间戳
- 3~6:代表机器 ID
- 7~8:机器进程 ID
- 9~11:自增值
算法角度下的分布式ID解决方案
UUID
在Java的世界里,想要得到一个具有唯一性的ID,首先被想到可能就是UUID,毕竟它有着全球唯一的特性。‘
UUID按照标准方法生成时,在实际应用中具有唯一性,且不依赖中央机构的注册和分配。
UUID 是 Universally Unique Identifier(通用唯一标识符) 的缩写。UUID 包含 32 个 16 进制数字(8-4-4-4-12)。
那么UUID可以做分布式ID吗?答案是可以的,但是并不推荐,它更适合做单机情况下的 ID
JDK 就提供了现成的生成 UUID 的方法,一行代码就行了。
1 | public static void main(String[] args) { |
虽然 UUID 使用起来很简单,而且生成速度极快,几乎不耗时,但 UUID 却并不适用于实际的业务需求,例如订单号用 UUID 这样的字符串就毫无意义,而对于数据库来说用作业务主键ID,它不仅是太长还是字符串,存储性能差查询也很耗时,所以不推荐用作分布式ID。
比较重要的还有,UUID 是无顺序的,InnoDB 引擎下,数据库主键的无序性会严重影响数据库性能。
UUID 的生成有八种版本
- 版本1:日期时间和MAC地址,基于时间戳和设备的MAC地址生成。当包含 MAC 地址时,可以保证全球唯一性,但也因此存在隐私泄露的风险。
- 版本2:日期时间和MAC地址,DCE安全版本,与版本 1 类似,但额外包含了本地标识符
- 版本3:基于命名空间和名称的 MD5 哈希,通过哈希命名空间标识符和名称生成,版本3使用 MD5 作为散列算法,版本5则使用 SHA1,将命名空间标识符和名称字符串组合计算得到。他能做到相同的命名空间和名称总是生成相同的 UUID
- 版本4:基于随机数,几乎完全基于随机数生成,通常使用伪随机数生成器(PRNG)或加密安全随机数生成器(CSPRNG)来生成。 虽然理论上存在碰撞的可能性,但理论上碰撞概率极低
- 版本5:基于命名空间和名称的 SHA-1 哈希,只是对版本3改了一个 SHA-1 哈希算法。
- 版本6:基于时间戳、计数器和节点 ID,改进了版本 1,将时间戳放在最高有效位,使得 UUID 可以直接按时间排序。
- 版本7:基于时间戳和随机数据,基于 Unix 时间戳和随机数据生成。 由于时间戳位于最高有效位,因此支持按时间排序。并且,不依赖 MAC 地址或节点 ID,避免了隐私问题。
- 版本8:自定义,允许用户根据自己的需求定义 UUID 的生成方式。其结构和内容由用户决定,提供更大的灵活性。
JDK 中通过 UUID 的 randomUUID() 方法生成的
UUID 的版本默认为 4。
1 | UUID uuid = UUID.randomUUID(); |
Snowflake
大名鼎鼎的雪花算法
Snowflake 是 Twitter 开源的分布式 ID 生成算法。Snowflake 由 64 bit 的二进制数字组成,这 64bit 的二进制被分成了几部分,每一部分存储的数据都有特定的含义:
- sign:占1bit,符号位,始终为 0,代表生成的 ID 为正数。
- timestamp:占41位,用来表示时间戳,单位是毫秒,可以支撑 2 ^41 毫秒,大约70年不到
- datacenter id + worker id:每个分别占5位,一般来说,前 5 位表示机房 ID,后 5 位表示机器 ID。这样就可以区分不同集群和机房的节点。
- sequence:占12位,序列号,为自增值,代表单台机器每毫秒能够产生的最大 ID 数,也就是说单台机器每毫秒最多可以生成 4096 个 唯一 ID。
在实际项目中,我们一般也会对 Snowflake 算法进行改造,最常见的就是在 Snowflake 算法生成的 ID 中加入业务类型信息,或者砍短雪花 ID 来无损前端 JS 传输
我们再来看看 Snowflake 算法的优缺点
- 优点:生成速度比较快、生成的 ID 有序递增、比较灵活,因为易于改造
- 缺点:需要解决重复 ID 问题(ID 生成依赖时间,在获取时间的时候,可能会出现时间回拨的问题,也就是服务器上的时间突然倒退到之前的时间,进而导致会产生重复 ID)、依赖机器 ID 对分布式环境不友好(当需要自动启停或增减机器时,固定的机器 ID 可能不够灵活)。
如果你想要使用 Snowflake 算法的话,一般不需要你自己再造轮子。有很多基于 Snowflake 算法的开源实现比如美团 的 Leaf、百度的 UidGenerator,并且这些开源实现对原有的 Snowflake 算法进行了优化,性能更优秀,还解决了 Snowflake 算法的时间回拨问题和依赖机器 ID 的问题。
并且,Seata 还提出了改良版雪花算法,针对原版雪花算法进行了一定的优化改良,解决了时间回拨问题,大幅提高的 QPS。
开源框架下的分布式ID解决方案
百度UidGenerator
UidGenerator 是百度开源的一款基于 Snowflake(雪花算法)的唯一 ID 生成器。
uid-generator是基于Snowflake算法实现的,与原始的snowflake算法不同在于,uid-generator支持自定义时间戳、工作机器ID和
序列号等各部分的位数,而且uid-generator中采用用户自定义workId的生成策略。
不过,UidGenerator 对 Snowflake 进行了改进,生成的唯一 ID 组成如下:
- sign(1bit):符号位(标识正负),始终为 0,代表生成的 ID 为正数。
- delta seconds (28 bits):当前时间,相对于时间基点”2016-05-20”的增量值,单位:秒,最多可支持约 8.7 年
- worker id (22 bits):机器 id,最多可支持约 420w 次机器启动。内置实现为在启动时由数据库分配,默认分配策略为用后即弃,后续可提供复用策略。
- sequence (13 bits):每秒下的并发序列,13 bits 可支持每秒 8192 个并发。
而且uid-generator需要与数据库配合使用,需要新增一个WORKER_NODE表。当应用启动时会向数据库表中去插入一条数据,插入成功后返回的自增ID就是该机器的workId数据,由host,port组成。
自 18 年后,UidGenerator 就基本没有再维护了,也不知道百度是不是彻底不要它了
美团Leaf
Leaf 是美团开源的一个分布式 ID 解决方案 。这个项目的名字 Leaf 树叶,起源于德国哲学家、数学家莱布尼茨的一句话:“There are no two identical leaves in the world”(世界上没有两片相同的树叶)
Leaf同时支持号段模式和snowflake算法模式,可以切换使用。
并且,它支持双号段,还解决了雪花 ID 系统时钟回拨问题。对于双号段,还是提前获取下一个号段,避免请求号段时候的延迟,和我上面提到的一样。对于雪花 ID 的时钟问题,它的解决需要弱依赖于 Zookeeper(它使用了 Zookeeper 作为注册中心,通过在特定路径下读取和创建子节点来管理 workId)
Leaf 的诞生主要是为了解决美团各个业务线生成分布式 ID 的方法多种多样以及不可靠的问题。
官方文章:《Leaf——美团点评分布式 ID 生成系统》)。
滴滴Tinyid
Tinyid 是滴滴开源的一款基于数据库号段模式的唯一 ID 生成器。
数据库号段模式的原理我们在上面已经介绍过了。Tinyid 有哪些亮点呢?
可以看到,基础的底层逻辑是一样的,Tinyid 完全使用了数据库号段的底层逻辑
如上我们已经完成了号段生成逻辑,那么我们的id生成服务架构可能是这样的
ID生成系统向外提供服务,请求经过我们的负载均衡 router,到达其中一台 tinyid-server,从事先加载好的号段中获取一个 ID,如果号段还没有加载,或者已经用完,则向db再申请一个新的可用号段,多台server之间因为号段生成算法的原子性,而保证每台server上的可用号段不重,从而使id生成不重。
可以看到如果 tinyid-server 如果重启了,那么机器手上的号段不管用了还是没用,就都作废了,会浪费一部分id
同时 id 也不会连续,由于负载均衡 Router 的存在,每次请求可能会打到不同的机器上,所以说 id 也不是单调递增的,而是趋势递增的,不过这对于大部分业务都是可接受的。
这种方案有什么问题呢?
- 获取新号段的情况下,db更新也可能存在 version 冲突,此时程序获取唯一 ID 的速度比较慢。
- ID 的 DB 是一个单点,而且需要保证 DB 高可用,这个是比较麻烦且耗费资源的。
- HTTP 调用存在网络开销。
Tinyid 方案主要做了下面这些优化
- 双号段缓存:还是一样,异步加载下一个号段,保证内存中始终有可用号段。
- 增加多 db 支持:支持多个 DB,并且,每个 DB 都能生成唯一 ID,提高了可用性
- 增加 tinyid-client:纯本地操作,无 HTTP 请求消耗,性能和可用性都有很大提升。
IdGenerator
IdGenerator 也是一款基于 Snowflake 的唯一 ID 生成器。它是我朋友推荐给我使用的一个好用的雪花 ID 生成器
IdGenerator 有如下特点:
生成的唯一 ID 更短
兼容所有雪花算法
原生支持多语言,并提供多线程安全调用动态库
不依赖外部存储系统
而且解决了时间回拨问题,支持手工插入新 ID
IdGenerator 生成的唯一 ID 组成如下:
- timestamp (位数不固定):时间差,是生成 ID 时的系统时间减去 BaseTime(基础时间,也称基点时间、原点时间、纪元时间,默认值为 2020 年) 的总时间差(毫秒单位)。初始为 5bits,随着运行时间而增加。如果觉得默认值太老,你可以重新设置,不过要注意,这个值以后最好不变。
- worker id (默认 6 bits):机器
id,机器码,最重要参数,是区分不同机器或不同应用的唯一 ID,最大值由
WorkerIdBitLength(默认 6)限定。如果一台服务器部署多个独立服务,需要为每个服务指定不同的 WorkerId。 - sequence (默认 6
bits):序列数,是每毫秒下的序列数,由参数中的
SeqBitLength(默认 6)限定。增加SeqBitLength会让性能更高,但生成的 ID 也会更长。
使用起来很简单
依赖
1
2
3
4
5<dependency>
<groupId>com.github.yitter</groupId>
<artifactId>yitter-idgenerator</artifactId>
<version>1.0.6</version>
</dependency>初始化
1
2
3
4
5
6
7// 创建 IdGeneratorOptions 对象,可在构造函数中输入 WorkerId:
IdGeneratorOptions options = new IdGeneratorOptions(Your_Unique_Worker_Id);
// options.WorkerIdBitLength = 10; // 默认值6,限定 WorkerId 最大值为2^6-1,即默认最多支持64个节点。
// options.SeqBitLength = 6; // 默认值6,限制每毫秒生成的ID个数。若生成速度超过5万个/秒,建议加大 SeqBitLength 到 10。
// options.BaseTime = Your_Base_Time; // 如果要兼容老系统的雪花算法,此处应设置为老系统的BaseTime。
// ...... 其它参数参考 IdGeneratorOptions 定义。https://github.com/yitter/IdGenerator/blob/master/README.md使用
1
2// 初始化后,在任何需要生成ID的地方,调用以下方法:
long newId = YitIdHelper.nextId();
如何根据你的业务设计分布式ID
这部分主要是来探讨业务场景中对 ID 在不同的情况,有哪些具体的要求
一般考虑的事情就这些
- 这个业务对 ID 的唯一性要求是 绝对唯一 还是 业务内唯一?
- 生成 ID 能接受多高的延迟和 QPS
- ID 的格式需要反映出业务吗
深耕业务还会有区分通用券和单独券的情况,分别具备以下特点,技术实现需要因地制宜地思考。
- 通用券:多个玩家都可以输入兑换,然后有总量限制,期限限制。
- 单独券:运营同学可以在后台设置兑换码的奖励物品、期限、个数,然后由后台生成兑换码的列表,兑换之后核销
订单系统
https://javaguide.cn/distributed-system/distributed-id-design.html
我们在商场买东西一码付二维码,下单生成的订单号,使用到的优惠券码,联合商品兑换券码,这些是在网上购物经常使用到的单号,那么为什么有些单号那么长,有些只有几位数?有些单号一看就知道年月日的信息,有些却看不出任何意义?
订单号
订单号在实际的业务过程中作为一个订单的唯一标识码存在,一般实现以下业务场景
- 用户订单遇到问题,需要找客服进行协助;
- 对订单进行操作,如线下收款,订单核销;
- 下单,改单,成单,退单,售后等系统内部的订单流程处理和跟进。
很多时候搜索订单相关信息的时候都是以订单 ID 作为唯一标识符,这是由于订单号的生成规则的唯一性决定的。从技术角度看,除了 ID 服务必要的特性之外,在订单号的设计上需要体现几个特性:
信息安全
订单号要安全,比如不能用纯自增数字,因为它不能透露公司的运营情况,比如日销、公司流水号等信息,以及商业信息和用户手机号,身份证等隐私信息。所以,参与生成订单号 ID 不能使用这些信息来操作。
所以说,订单号 ID 不能有明显的整体规律,任意修改一个字符就能查询到另一个订单信息,这也是不允许的。
类比于我们高考时候的考生编号的生成规则,一定不能是连号的,否则只需要根据顺序往下查询就能搜索到别的考生的成绩,这是绝对不允许的。
部分可读
部分可读要求能够从订单号中简单的读取到一些业务相关的信息,而且对于订单号位数要便于操作,因此要求订单号的位数适中,且局部有规律。
过长的订单号或易读性差的订单号会导致客服输入困难且易错率较高,影响用户体验的售后体验。因此在实际的业务场景中,订单号的设计通常都会适当携带一些允许公开的对使用场景有帮助的信息,如时间,星期,类型等等,这个主要根据所涉及的编号对应的使用场景来。
而且像时间、星期这些自增长的属于作为订单号的设计的一部分元素,不会泄露过度信息,而且有助于解决业务累积而导致的订单号重复的问题。
查询效率
也就是高性能,因为订单通常涉及到秒杀,而且订单表通常分库分表,所以 ID 需全局唯一趋势递增即可,允许少量浪费,因为要保持高并发
订单号通常涉及到比较多的查询,要求不能太长且最好是纯数字,int 类型相对 varchar 类型的查询效率更高,对在线业务更加友好。
很多情况下,我们在这种场景下使用美团 Leaf
支付号
这种业务的特点是涉及资金、对账、审计,ID 必须可追溯、无断层、无重复;
虽然它没那么大的并发量,你要是一秒卖几千笔还是挺好的了,但是它对数据的一致性要求极高,不仅要全局绝对唯一,而且通常需要严格递增、连续无间隔(这种ID千万不能公开),而且为了安全性,要求 ID 不能泄露业务量,比如不能用纯自增数字,而且必须高可靠,因为你不能因为生成 ID 失败就付不了钱
这种情况下,我们可以使用数据库号段,或者干脆单独给支付服务分配一个数据库单库自增的服务,这种情况下我们通常要假如业务中需要处理的内容,包含业务日期等等
不要用 UUID、不要用纯 Redis 自增
券码
优惠券、兑换券是运营推广最常用的促销工具之一,合理使用它们,可以让买家得到实惠,商家提升商品销量。常见场景有:
- 在平台购买会员兑换卡这种产品。支付成功后会得到平台会员的兑换码
- 部分平台发放的消费券;
- 瓶装饮料经常会出现输入优惠编码兑换奖品。
即时生成
从技术角度看,有些场景适合 ID 即时生成,比如电商平台购物领取的优惠券,只需要在用户领取时或者下单时分配优惠券信息即可。
它的要求就是生成快、全局唯一、不可预测,无需提前存储。
所以它最重要的要求就是低延迟,领取时即时生成,用户无感知,而且不能是连续数字 / 字母这种可以被预测的场景,所以说,这种情况下,结合随机数加业务前缀的分布式ID 比较常见,可以针对 UUID 的版本4进行再一步的改造,我同学貌似使用的是雪花算法 + 字符编码
对于雪花算法 + 字符编码,我认为这个方式很好
- 先用雪花算法生成 64 位整数 ID
- 然后将雪花 ID 转为 Base62 编码,再拼接业务前缀
最后生成的就是这样的东西,雪花 ID
17568923456789→ Base62 编码8A7B9C→ 拼接前缀TMALL2602118A7B9C
对于不用提前存储,因为它生成后直接绑定用户、有效期、使用规则,用户领到了就入库,用户用完了或者到期了就删,根本不用提前存储起来
预先生成
而有些线上线下结合的场景,比如疫情优惠券,瓶盖开奖,京东卡,超市卡这种,则需要提前批量生成
那么它对于唯一性,不可预测的要求性更高,而且要求可存储可核验,这样才能支持线下分发。
对于唯一性没啥好说的,但是对于不可预测性,这里一般是采用纯随机字符组合,而且生成后需存储到数据库 / 缓存,支持快速校验券码是否有效
设计思路上,需要设计一种有效的兑换码生成策略,支持预先生成,支持校验,内容简洁,生成的兑换码都具有唯一性,那么这种策略就是一种特殊的编解码策略,按照约定的编解码规则支撑上述需求。
既然是一种编解码规则,那么需要约定编码空间,也就是用户看到的组成兑换码的字符,编码空间由字符 a-z,A-Z,数字 0-9 组成,为了增强兑换码的可识别度,剔除大写字母 O 以及 I,可用字符如下所示,共 60 个字符:
abcdefghijklmnopqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXZY0123456789
之前说过,兑换码要求尽可能简洁,那么设计时就需要考虑兑换码的字符数,假设上限为 12 位,而字符空间有 60 位,那么可以表示的空间范围为 60^12=130606940160000000000000,也就是可以 12 位的兑换码可以生成天量,应该够运营同学挥霍了,转换成 2 进制:
1001000100000000101110011001101101110011000000000000000000000(61 位)
兑换码可以预先生成,并且不需要额外的存储空间保存这些信息,每一个优惠方案都有独立的一组兑换码(指运营同学组织的每一场运营活动都有不同的兑换码,不能混合使用, 例如双 11 兑换码不能使用在双 12 活动上),每个兑换码有自己的编号,防止重复,为了保证兑换码的有效性,对兑换码的数据需要进行校验,那么当前兑换码的数据组成就如下所示了:
优惠方案 ID + 兑换码序列号 i + 校验码
- 优惠方案 ID,代表当前优惠方案的 ID 号,优惠方案的空间范围决定了可以组织的优惠活动次数
- 兑换码序列号 i,代表当前兑换码是当前活动中第 i 个兑换码,兑换码序列号的空间范围决定了优惠活动可以发行的兑换码数目
- 校验码,校验兑换码是否有效,主要为了快捷的校验兑换码信息的是否正确,其次可以起到填充数据的目的,增强数据的散列性
可追踪的系统
日志追踪
在分布式服务架构下,一个 Web 请求从网关流入,有可能会调用多个服务对请求进行处理,拿到最终结果。这个过程中每个服务之间的通信又是单独的网络请求,无论请求经过的哪个服务出了故障或者处理过慢都会对前端造成影响。
处理一个 Web 请求要调用的多个服务,为了能更方便的查询哪个环节的服务出现了问题,现在常用的解决方案是为整个系统引入分布式链路跟踪。
在分布式链路跟踪中有两个重要的概念:跟踪(trace)和 跨度( span)。trace 是请求在分布式系统中的整个链路视图,span 则代表整个链路中对于每个不同服务的内部的视图,span 组合在一起就是整个 trace 的视图。
在整个请求的调用链中,请求会一直携带 traceid 往下游服务传递,每个服务内部也会生成自己的 spanid 用于生成自己的内部调用视图,并和 traceid 一起传递给下游服务
TraceId
TraceId是全局唯一的链路根 ID,他肯定是全局唯一的,标识一次完整的用户请求,而且它的格式需要简洁,通常情况下,它要求本地生成,而且一般包含时间戳
因为如果每个 trace 中的 ID 都需要请求公共的 ID 服务生成,纯纯的浪费网络带宽资源,所以说 TraceId 需要具备接入层的服务器实例自主生成的能力,且会阻塞用户请求向下游传递,响应耗时上升,增加了没必要的风险
所以需要服务器实例最好可以自行计算 tracid,spanid,避免依赖外部服务。
所以,我们可以使用版本4下的 UUID 和缩短位数的雪花算法
而且这种场景下,生成的 ID 除了要求唯一之外,还要求生成的效率高、吞吐量大。
可以指定这样的一个产生规则:服务器 IP + ID 产生的时间 + 自增序列 + 当前进程号
例如,0ad1348f1403169275002100356696
前 8 位 0ad1348f 即产生 TraceId 的机器的 IP,这是一个十六进制的数字,每两位代表 IP 中的一段,我们把这个数字,按每两位转成 10 进制即可得到常见的 IP 地址表示方式 10.209.52.143,您也可以根据这个规律来查找到请求经过的第一个服务器。
后面的 13 位 1403169275002 是产生 TraceId 的时间。之后的 4 位 1003 是一个自增的序列,从 1000 涨到 9000,到达 9000 后回到 1000 再开始往上涨。最后的 5 位 56696 是当前的进程 ID,为了防止单机多进程出现 TraceId 冲突的情况,所以在 TraceId 末尾添加了当前的进程 ID
SpanId
SpanId 应该就是层级可追溯,它随调用链路动态生成
span 是层的意思,比如在第一个实例算是第一层, 请求代理或者分流到下一个实例处理,就是第二层,以此类推。通过层,SpanId 代表本次调用在整个调用链路树中的位置。
假设一个 服务器实例 A 接收了一次用户请求,代表是整个调用的根节点,那么 A 层处理这次请求产生的非服务调用日志记录 spanid 的值都是 0,A 层需要通过 RPC 依次调用 B、C、D 三个服务器实例,那么在 A 的日志中,SpanId 分别是 0.1,0.2 和 0.3,在 B、C、D 中,SpanId 也分别是 0.1,0.2 和 0.3;如果 C 系统在处理请求的时候又调用了 E,F 两个服务器实例,那么 C 系统中对应的 spanid 是 0.2.1 和 0.2.2,E、F 两个系统对应的日志也是 0.2.1 和 0.2.2。
根据上面的描述可以知道,如果把一次调用中所有的 SpanId 收集起来,可以组成一棵完整的链路树。
spanid 的生成本质:在跨层传递透传的同时,控制大小版本号的自增来实现的
短网址
短网址主要功能包括网址缩短与还原两大功能。相对于长网址,短网址可以更方便地在电子邮件,社交网络,微博和手机上传播,例如原来很长的网址通过短网址服务即可生成相应的短网址,避免折行或超出字符限制。
例如,我现在使用的 Hexo 博客,就可以假如一个插件,来给文章生成一共短的唯一的 ID,用于分享和更好的访问
短网址服务把客户的长网址转换成短网址,常用的 ID 生成服务比如:MySQL ID 自增、 Redis 键自增、号段模式,生成的 ID 都是一串数字,然后,我们还可以进行进一步的压缩,例如转换成更高的进制的方式压缩长度。
它的重点比较清晰,就是,短,唯一就可以了,这个短要求六到八位,所以说,一般情况下只使用数字其实要考虑一下够不够,通常要加入字符






