互联网业务中用户、商家、订单号等 id 如何生成 Posted by xmpace on 2019-09-10

id 在互联网企业的业务中无处不在,用户、商家、订单号、商品等都是用 id 来唯一标识的。

最常见的 id 生成策略便是数据库的自增主键,但用自增主键有一个致命的缺点:会暴露业务数据

竞争对手只要每天开始和结束分别调下你的接口,就知道你当天的业务数据了。

于是,有些人用 UUID,UUID 无顺序,生成也是完全分布式的,没有单点问题,但它也有它的问题。

一是太长,会浪费空间,可能会影响到联合索引的建立,联合索引有长度限制,id 所占位数越多,留给其它字段的位数就越少。

二是它几乎是随机的,在插入数据库的时候会损失性能。

拿 MySQL 来说,索引的组织形式是 B+ 树,如果是完全顺序的插入,基本在后面追加新页就行了,反应到磁盘上就是大量的顺序写,速度很快。而如果是完全随机的插入,那么位于 B+ 树前面的那些页存不下的时候,就需要对页做分裂操作,这就会需要大量修改之前的页,反应到磁盘上就是大量的随机写,速度可以比顺序写慢一个甚至几个数量级。

当然我们平时一张表上会有多个索引,不是所有的索引都能按照一致的顺序来建立的,因此,大部分情况下实际性能损失并没有上面描述的那么夸张。

三是有些情况下 id 最好是纯数字,比如订单号,如果有客诉的话,有些上了年纪的人可能并不会读英文字母。

整型 id

有没有更紧凑的整型 id 方案?我们先列一下 id 需要满足的基本要求:

  1. 全局唯一
  2. 不会泄露业务数据(不可预测)

在满足这两个要求的基础上,id 的长度要越短越好,因为一是节省空间,二是有客诉的话客户报起来不容易出错。性能也要越高越好,最好系统可横向扩展,我们的目标是日单量过千万的系统。

说到不可预测,首先想到随机数。但它会受到第一条要求的约束。我拿到一个随机生成的 id,怎么知道这个 id 是唯一的呢?

除了反查数据库,似乎别无他法。而且,如果拿到的是个已经存在的 id,那就得继续随机,继续反查。当然这个策略可以做一些优化,比如每天预先跑一批不重复的 id 存起来,用的时候直接取。但这对于日单量过千万的系统来说并不是一个好的选择,一是仍然避免不了反查数据库,二是还得专门存这批预先跑出来的 id。

如果生成的 id 不需要反查数据库就可确定唯一性,无疑是更好的选择。

Snowflake

要满足以上这些要求,Snowflake 是一个非常好的选择。

Snowflake 原理很简单,每个 worker 以某一时间单位为粒度(一般用秒),在单位时间范围内,按顺序递增发号。程序逻辑为每次取当前时间,如果当前时间与上一次发号时间在同一单位时间内(比如在同一秒内),则递增序号,否则,序号置0。

因为每个 worker 分配的 workerId 不同,因此,只要 worker 的时间不出现回退,理论上就不会出现重复的 id。

所以,该方案的重点就是如何保证时间不出现回退。

workerId 的分配

workerId 应该如何分配?显然 workerId 最好不应该被交叉分配,比如原来分配给 A 的 workerId,之后某个时间点又被分配给 B,因为各个机器的本地时间不可能完全同步,这台机器已经发完号的时间,其他机器也许并没有发完,所以如果 workerId 出现交叉分配的情况,就可能出现 id 重复的情况。

因此 workerId 一旦被分配,就最好是固定的。

那么 workerId 应该按机器分配,还是按进程分配呢?实际上,开源实现里,两种方式都有,各有优缺点。

workerId 按机器分配

按机器分配即每台机器分配一个固定的 workerId,一般用机器的 IP 地址来标识机器,在运维实践中,机器分配的内网 IP 一般不会变。

由于机器数量一般情况下变化不大,因此可以用手动来配置,但手动配置有很大的缺点。比如,对于高峰时段非常集中的应用,为了提高资源的利用率,弹性扩缩容就非常必要,因此集群机器数量会频繁变化,这样一来,需要频繁的人肉配置,麻烦不说,还容易出错。

因此,大型应用一般会采用程序分配的方式。其实是手动分配还是程序分配对 id 的正确发放并没有多大影响,这里我想讨论的是 workerId 按机器分配时可能出现的问题。来看一个 case。

机器 8.8.8.8 分配的 workerId 为 1,发号时突然宕机了,最后发号的时间是 t1。机器重启后时间出现了回退,之后进程被重新启动,此时时间为 t2,但由于机器时间出现了回退,结果 t2 比 t1 还早,可想而知,这么继续发号极有可能出现重复。

美团的开源实现 Leaf 采用的正是按机器分配的方案,那么它是如何处理上面这种情况的呢?

Leaf 在程序正常运行过程中,每 3 秒上报一次本机器的系统时间,如果程序意外重启,它首先会去取最后一次上报的时间,如果系统时间比最后一次上报的时间还早,那显然是出问题了,启动失败报警,由人工介入处理。

前一步正常的话往下继续,通过 RPC 获取集群中所有机器的系统时间,判断这些机器的平均系统时间与本机器的系统时间的差距,如果差距较大,则说明本机器的系统时间有较大误差,启动失败报警,否则正常启动。

可以看到,美团的方案,理论上还是存在时间回退的可能的,只是概率非常非常小。

workerId 按进程分配

按进程分配即每个进程分配一个固定的 workerId,但进程用什么来做唯一标识呢?进程号也是会被操作系统复用的,可以说没有这样的唯一标识,因为进程是朝生夕死的,它的生命只有一次,跟人一样。所以一般只要进程启动就分配一个之前从未用过的 workerId,也就是一次性的 workerId。

百度的开源实现 UidGenerator 就是这么做的。这么做的好处显而易见,像按机器分配时出现的进程重启前后出现时间回退的情况,在这种方案中就不可能出现了,只需要考虑进程运行中出现的时间回退就行了。

而进程运行中的时间回退我们完全可以在程序中检测到,出现这种情况或者等待时间纠正,或者直接报错就行,只要不发出重复的 id 就是可以接受的。

这种方案缺点也很明显,每次进程重启就要消耗一个 workerId,workerId 位数有限,总有消耗完的时候。而且集群机器越多,进程重启次数越多,消耗就越快。

Snowflake 方案总结

如前所述,workerId 按机器分配这种方案,缺点是有出现重复 id 的概率(概率可以做到非常小),优点是 workerId 占用位数少,生成的 id 可以更短。workerId 按进程分配的方案,缺点是 workerId 消耗多,占用位数相对多,优点是不会出现重复发号的情况。

订单号的其它生成方案

以上是用 Snowflake 的方案,比较适合需要高并发,又不能泄露业务数据的 id,比如订单号。

下面介绍另一种订单号的生成方案。

假如我是一个电商网站,用户多,商家相对少,商家 id 是一个 32 位整型,那么我可以这样来生成订单号:

维护商家每天的当日订单量,生成订单号时,高 32 位为商家 id,低 32 位用日期的数字组合当日该商家订单量,高 32 位与低 32 位拼起来做订单号。

由于日期 yyMMdd 占据了一定位数,32 位整型只剩下 10000 个数字的空间可使用,因此,该方案适合商家日单量小于 10000 的业务,比如外卖。

自增 id 就完全没用吗

自增 id 生成方便,又不用引入额外的依赖,插入性能又好,弃之实在是可惜,难道就真的不能用吗?

非也,要用还是可以用的,我们只需要在把自增 id 给出去时做个加密就行了。

最简单的加密方法,异或加密。

加密 cipherId = autoIncrementId ^ password;

解密 autoIncrementId = cipherId ^ password;

so easy!当然,你可能会嫌这种加密方式太弱,其实还有许多其它的加密方式,64 位数据的加密算法有:Blowfish、DES、XTEA、SkipJack等,32 位数据的加密算法有 Skip32 等。

这种方案需要注意的是,密钥如果是死的,那么就有被泄露的风险,比如离职员工跳槽去竞对…

在 id 里面拼一个密钥版本号是一个可行的方案,不同的版本号对应不同的密钥。但这种方案对用户体验有一定的影响,比如同一个订单,去年看和今年看订单号居然不一样。能否接受就看业务了。

64 位 id 与 JavaScript 交互问题

我们的 id 总是要给到前端的,这些年大前端概念流行,跨平台开发框架大行其道,JavaScript 还有很强的生命力。不同于其它语言,JavaScript 不区分整数值和浮点数值,所有数字均用 64 位浮点数值表示,因此会出现某些 64 位整型在 JavaScript 中无法表示的问题。因此,64 位 id 最好转成字符串形式再给到前端。

总结

互联网业务中的用户、商家、订单号等 id,为了兼顾性能及安全性,建议使用 Snowflake 的方案生成,该方案适合中大型企业。对于创业公司等小企业来说,自增 id 加密的方案也不失为一种好的实用方案,相较于 Snowflake 的方案,其性能更好,但安全性较弱。