绝望之余,如果能重来,我不会用雪花算法生成订单 ID

对于名气很大的人或事,很多人本能地会认为它们是正确的,一部分人会产生一种向往之情,认为:"如果我也能做到这样,那一定是很棒(NB)。"

当你完全模仿后,会遇到很多未曾设想的问题。如果你没有进行充分调研,也没有在实施阶段(也就是模仿阶段)预见到可能出现的问题,等到项目上线一段时间后才意识到"我好像陷入了深坑",那时已经为时已晚。

如果你使用雪花算法生成订单ID,项目上线后可能会非常顺利。因为它在性能方面非常出色,也满足当时项目的使用场景。

然而,随着项目的复杂化,你可能会发现雪花算法并不是一个完美的解决方案。它能承载的业务属性非常有限,近似于零,你需要其他的存储系统扩展订单 id 到其他业务属性的映射。例如 订单 id 的 userId,订单的业务类型,订单所在的部署单元............等等。

当别人劝你:"重构吧,重新定义订单id规则"。你根本不想回复他,因为你知道订单 id 是 64 位整型,你能改变的事情也极其有限。你开始后悔,如果订单 id 设计成 String 字符串类型,扩展性该有多强,当初我 TM 为什么选择雪花算法,又为什么用 64 位整型?

网上比较盛行使用雪花算法生成分布式 ID,雪花算法可以生成 64 位 long 类型整数,它能保证生成递增的序列,也具备安全性,可以避免友商猜测出订单量。同时在性能上也非常优异。理论上单机能支持 4096/秒的并发度,能最大扩展到 1024 台机器。

雪花算法

  • 最高 1 位固定值 0, 确保是正整数。如果为 1,则为负数。
  • 41 位存储毫秒级时间戳,2^41 毫秒 大约 69年。
  • 10 位存储机器码,包括 5 位 机器 id 和 5 位服务 id。最多可以部署 2^10=1024 台机器。
  • 12 位存储序列号。同一毫秒时间戳时,通过这个递增的序列号来区分。即对于同一台机器而言,同一毫秒时间戳下,可以生成 2^12=4096 个不重复 id。

订单 id 的要求

订单 id 不仅仅需要满足递增、性能和防猜等基本要求。随着业务迭代,订单 id 一定需要承载足够多的业务属性,尤其是一些关键属性。如果使用 64 位 2 进制 long 型很难承载如此多的业务属性,所以先假设使用纯数字的 String 类型承载 OrderId。

接下来,我列举一些潜在的业务属性。

时间属性

订单 id 中一定要包括时间,这是因为订单的数量相比公司其他类型的数据,应该在第一梯队。主流互联网公司每天的订单量基本都超过了 1千万,甚至订单量每天超过 1 个亿也成为常态。为了保证订单表的性能和可扩展性,除分库分表外,订单数据往往需要冷热分离。1 年以上的订单数据的查询频率和更新频率都极低,如果长期在 MySQL 中,将造成极大的资源浪费和性能浪费。

当上游业务系统查询订单详情时,订单系统如何快速判断这个订单是历史订单还是近期订单 非常关键。最差的方案是:先查询近期订单,如果没查到,则查询历史订单,如果再查不到,则认为订单 Id 无效。

如果订单中包括时间,则可以根据订单时间计算出该订单是否已超过归档时间,如果超过归档时间则去历史订单查找。

订单中应包含:距离某个时间点的间隔时间。可以选择选择项目上线时间作为起始时间。例如 2020.1.1,1577808000作为起始时间。

雪花算法中初始的 41 位是毫秒级时间戳,大约 69 年。除了毫秒时间戳,也可以使用秒级时间戳。

此外还可以选择 240114 当前日期, 六位 10 进制存储在订单中。但是这样方式要注意避免被友商猜测出 当天的订单量。因为如果使用 240114 放在订单中,其他部分如果是顺序递增,每天在凌晨下一单,极容易猜测出每天的订单量。

即使其他部分不是顺序递增,例如使用 userId%10000 + 顺序递增 N 位,这样的方式也极容易猜测出订单量,因为 userId一般是随机的。使用10000取模以后,可以近似认为,当天的订单量平均被切割为1万份,从而猜测除订单量。

如果不使用userId取模,而是使用userId+顺序递增N位,则不存在这个问题,因为每个用户的下单量是不均匀的,无法准确猜测出总量。同样地,如果使用机器ID+顺序递增N位,也很容易被猜测出订单总量,因为机器请求量会负载均衡,每个机器的订单量都是均匀的。只要知道机器的总数(找几个人就能问出来,或者多下单测试几次),就可以知道订单的总量。这两种方式具有极大的危险性。

如果将时间属性细分到秒或毫秒,即使后缀是顺序递增的,也无法准确地推测出订单量。因为每一天不同时间段的下单量是不同的,即使我们知道某一秒某台机器的订单量,也无法推测出当天的订单总量。此外,当时间精度提高到秒级别时,统计误差也会增大,导致推测的准确度非常低。只有时间属性是天的时候,需要倍加小心,防止被猜测出订单量。

我推荐大家使用 秒。 按照 60* 60 * 24 * 365 * 100 年 = 3153600000,为 10 位 十进制数字。将以上时间放在订单中可保证订单 Id 秒级递增。

但是10 位数字中的前几位可能长期为 0,所以需要在时间属性前添加其他业务属性,确保订单 Id 为非 0 值开头。

业务属性

业务属性一般是订单的业务场景,例如交易订单、配送单、履约单,或者标识出不同的产品线。例如外卖订单、电商订单、会员订单等等。

往往各个大公司都有订单中台,能满足多个产品线、多种复杂场景的交易能力。订单 id 中如果可以区分出产品线,那么在查询订单详情之前,可以做很多差异化的处理逻辑。

再或者订单系统也并非一成不变,往往订单系统也需要升级改造,甚至出现重大升级,迭代出完全独立的新订单系统。如何保证给上游业务提供统一的 API 呢?这就需要订单 Id 上能有效区分出 它属于 新订单还是旧订单系统, 然后系统去不同的存储表中查询订单详情, 系统分别调用新订单系统或旧订单系统履约订单。

所以一般情况下,业务属性有如下几类:产品线、订单场景类型(订单、履约、配送、虚拟订单等)、版本号(区分新旧系统),等等。

长度大致为:产品线 4 位10 进制;订单场景类型:2 为 10 进制,版本号:2 位 10 进制。 一共 8 位。

总之如果你使用 String 类型承载 orderId,随意整活,扩展性很强。

{版本号} + {秒级时间戳} + {产品线} + {场景类型} + {userId} + {随机数}

升级改造的压力

订单 ID 中如果承载了非常多的业务属性,升级改造时,会遇到极大的业务阻力。你需要梳理完全哪些场景和哪些代码解析了订单 ID,获取了业务属性。升级改造时要推动大家一起升级,推动他人做一件对自己没有收益的事情,难度非常大,这个过程非常痛苦。

所以不要让外部团队解析订单 ID,无论何时都不要让外部团队以任何理由解析订单 ID,只能由订单系统在必要的时候解析订单 ID获取业务属性。

用户 UserId

如果订单ID中包含 userId,那将非常方便。在仅有订单ID的查询情况下,我们可以解析出 userId,以便根据userId 进行分库分表以获取订单的详细信息。相反地,如果在没有 userId的查询情况下,我们只能创建一张订单 id 到 userId 的映射表,先查询订单归属的 userId,然后再使用 userId 和订单ID进行查询订单详情。有了订单ID中包含userId这个特性,我们可以避免设计一张额外的表。

用户 id 一般是 10 位 10 进制,大约 100 亿

随机数后缀

前面已经提到 订单 ID 已经包括如下内容,此时共 28 位,假设凑个整数 32 位,大约还有 4 位。

因为有了秒级时间戳 和 userId,所以后续的 4 位可以随机生成即可。因为一个用户在 1秒内很难有 1 次以上的有效订单。使用随机数,可以有效保证后续 4 位差异性极大,订单 id 的标识性会很高。

性能问题

因为秒级时间+userId +随机时间, 订单 Id 的计算过程可以完全在本地执行,无需调用分布式 ID服务,订单 Id 生成的并发量理论上很高。

非完全单调递增

那么很难保证订单ID的生成过程是严格递增的。虽然在精确到秒级别的时间戳上是递增的,但是在同一秒内不同的用户ID下单时,并不能保证较小的用户ID先生成订单ID。此外,使用雪花算法也无法完全保证严格递增,因为在不同的机器之间,同一毫秒内的请求很难保证机器ID较小的先生成订单ID。

实际上,只需要保证近似递增即可,完全严格递增并不是业务上或技术上的强诉求。

考虑到订单 id 的前后可读性,最终可以这样设计

例如 userId: 5040190644 在 2024-01-01 13:33:28 下的订单。

11012627920800010050401906449605

如果觉得太长怎么办?

以上 32 位的订单 id,可能有人觉得太长。可读性较差!

  1. 版本号可以干掉,减少 2 位。

  2. 秒级时间戳换为天数,例如举例 2020.1.1 号有多少天。 5 位即可,最大代表 99999 天,共270年。可减少 5 位

  3. 产品线砍掉,或者降为 2 位。减少 2 位或 4 位

  4. 场景类型砍掉,减少 2 位,

  5. userId 和随机数不变。

砍掉以上并非非常重要的字段后,大约剩余 21 位。

然而,有一个问题需要注意,每个用户每天最多能生成1万个订单,如果使用随机数,订单Id冲突的可能性大大增加,需要考虑其他方法来避免冲突。例如,可以使用Redis记录序号,为每个用户每天保留当前的购买订单数,以递增方式生成序列号。(1天后 Key 过期)

如果不想带 userId 怎么办?

订单中常包含用户信息和商家信息的情况比较常见,但将完全的用户ID放入订单ID的情况并不常见。若想要通过订单ID找到用户维度分库分表的数据,可以将用户ID的分库分表部分保留在订单中。假设用户ID的后三位进行分库分表,则可将用户ID的后三位保留在订单ID中。

订单 Id计算方式变为: {10位秒级时间戳} + {userId后三位} + {3位机器数} +{序列号4位} 共 20 位

这样的计算方式已经与雪花算法非常相似。

综上所述,订单ID的计算方式并不完全使用雪花算法,应根据各自的应用场景指定合适的方案。如果不在意订单ID的长度,可以冗余更多必要的业务属性。如果对长度敏感,例如要在20位以内,可以只保留部分用户ID,使用适配版的雪花算法。

比如高德地图的打车订单ID是32位,拼多多订单是28位,美团的订单是19位,支付宝订单是20位,京东订单是12位。各个公司的订单长度各不相同,也没有统一的标准。适合自己业务的就是最好的。

相关推荐
小马爱打代码8 分钟前
Spring Boot:将应用部署到Kubernetes的完整指南
spring boot·后端·kubernetes
卜锦元22 分钟前
Go中使用wire进行统一依赖注入管理
开发语言·后端·golang
SoniaChen332 小时前
Rust基础-part3-函数
开发语言·后端·rust
全干engineer2 小时前
Flask 入门教程:用 Python 快速搭建你的第一个 Web 应用
后端·python·flask·web
William一直在路上2 小时前
SpringBoot 拦截器和过滤器的区别
hive·spring boot·后端
小马爱打代码3 小时前
Spring Boot 3.4 :@Fallback 注解 - 让微服务容错更简单
spring boot·后端·微服务
曾曜4 小时前
PostgreSQL逻辑复制的原理和实践
后端
豌豆花下猫4 小时前
Python 潮流周刊#110:JIT 编译器两年回顾,AI 智能体工具大爆发(摘要)
后端·python·ai
轻语呢喃4 小时前
JavaScript :事件循环机制的深度解析
javascript·后端
ezl1fe4 小时前
RAG 每日一技(四):让AI读懂你的话,初探RAG的“灵魂”——Embedding
后端