订单号应该如何设计

订单号的基本原则

订单号作为交易的重要标识,一个好的订单号应该满足以下要求:

  1. 唯一性。确保订单号的生成不会出现重复。
  2. 安全性。订单号不会泄露用户信息、业务运营状况等敏感信息。
  3. 可读性。好的订单号应该被易于阅读,客户反馈时降低传达错误概率。
  4. 按需包含日常运维信息。

基于以上原则设计的唯一标识在生活中有一个很好的例子——身份证号,其具体的设计可查看我的另一篇文章你的身份证号码都包含了哪些信息?。在身份证号中,18 位数字包含了地区、生日、派出所编码、性别、校验等信息,如果我们在订单号中加入一些非敏感的日常运维信息,将会极大提升工作效率。

电商巨头订单号

目前,淘宝订单号为 18 位,末6位为部分用户 ID, 2009 年 7 月的淘宝订单号为 10 位;京东订单号为 12 位,2015 年 1 月时订单为 10 位。可见订单号也是随着业务不断变化的,重要的是如何更好的服务于业务。

美团技术团队直接开源Leaf 订单号生成器,这是为数不多的大数据量生成订单解决方案的开源,我们从中能得到很多的借鉴。

常见设计

  1. 年月日时分秒 + 用户 ID
  2. 年月日时分秒 + 随机数 + 校验码
  3. 随机订单号
  4. 根据业务信息加密处理生成
  5. 根据业务运营情况生成

在设计订单号时,有些具备唯一性的 ID 是很好的防冲突手段,如用户 ID、时间戳等,但直接加入又会暴露敏感信息,因此可以混入取模、移位、异或、校验位等加密敏感信息,同时防止订单号被构造。

最后,没有银弹,根据业务的实际情况按需设计,合适的才是最好的。

— 2020.04.24 更 —

基于以上的订单设计原则,我们假设现在有这样一个订单场景:用户 ID 为 10000000 起始的自增 ID,任务 ID 为 1000 起始的自增 ID,一个任务只能被每个用户领取一次,我们对订单号有以下要求:

  1. 唯一性
  2. 订单号中体现下单日期
  3. 订单号不能泄露业务信息,如每日订单量、用户 ID 等
  4. 具备校验防伪功能
  5. 订单号为纯数字
  6. 在满足业务需求的前提下尽可能简短,长度在 10 ~ 14 位

根据订单号的要求,我们设计出以下格式的订单号:

1
YYMMDD(6)/uid(2)/tid(2)/checksum(1)

订单号长度共 11 位,前 6 位为下单日期(年份用两位表示已经足够),用户 ID 与任务 ID 取模运算结果各 2 位,校验位 1 位(用于防止订单号的伪造)。每天的订单容量为 10 万,如果业务增长很快,可以按需扩容。实现代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
class OrderNumber
{
/**
* 订单号非校验位权重
* @var array
*/
const WEIGHT = [5, 1, 7, 3, 8, 2, 6, 9, 4, 0];

/**
* 校验位映射关系
* @var array
*/
const CHECK_SUM_MOD_MAP = [6, 8, 9, 7, 3, 2, 1, 0, 4, 5, 3];

/**
* 用户 ID 取模数
* 为了保证取模结果为两位数,因此取模数选取 100 以内的质数
*/
const USER_ID_MOD_NUM = 97;

/**
* 任务 ID 取模数
*/
const TASK_ID_MOD_NUM = 53;

/**
* 校验位取模数
*/
const CHECK_SUM_MOD_NUM = 11;

/**
* 生成订单号
*
* @param $userId string|int 用户 ID
* @param $taskId string|int 任务 ID
* @return string 订单号
*/
public function generate($userId, $taskId): string
{
$date = date('ymd');
$user = str_pad($userId % self::USER_ID_MOD_NUM, 2, '0', STR_PAD_LEFT);
$task = str_pad($taskId % self::TASK_ID_MOD_NUM, 2, '0', STR_PAD_LEFT);

$checksum = $this->getCheckSum($date . $user . $task);

return $date . $user . $task . $checksum;
}

/**
* 获取订单号校验位
*
* @param $orderNumber string 订单号
* @return int
*/
private function getCheckSum($orderNumber): int
{
$sum = 0;
for ($index = 0; $index < 10; $index++) {
$sum += $orderNumber[$index] * self::WEIGHT[$index];
}

return self::CHECK_SUM_MOD_MAP[$sum % self::CHECK_SUM_MOD_NUM];
}

/**
* 校验订单号
*
* @param $orderNumber string
* @return bool
*/
public function check($orderNumber): bool
{
if (!is_numeric($orderNumber) or 11 !== strlen($orderNumber)) {
throw new InvalidArgumentException('Order number invalid.', 1001);
}

// 校验日期是否非法
[$year, $month, $day] = str_split(substr($orderNumber, 0, 6), 2);
if (!checkdate($month, $day, '20' . $year)) {
return false;
}

return $orderNumber[10] == $this->getCheckSum(substr($orderNumber, 0, 10));
}
}

当运行了一段时间后,我们发现以上代码会有订单冲突的现象,比如 generate(10001500, 2678)generate(10001597, 2625) 会得到相同的订单号,排查生成规则后发现,两个用户 ID 取模结果相同、任务 ID 取模结果也相同,所以导致了最终的订单号相同。进一步思考后发现,只要 ID 差值为模值,就会在取模后得到相同的结果,因此对取模结果加入异或随机数的运算,相关代码变更如下:

1
2
3
4
5
6
7
8
9
10
public function generate($userId, $taskId): string
{
$date = date('ymd');
$user = mt_rand(0, 99);
$task = mt_rand(0, 99);

$checksum = $this->getCheckSum($date . $user . $task);

return $date . $user . $task . $checksum;
}

参考资料

  1. Laravel – 实战篇 UUID (唯一识别码)
  2. 电子商务网站中订单号设计有什么规则和依据吗?
  3. 互联网业务中用户、商家、订单号等 id 如何生成
  4. 京东的订单号为什么那么短?
  5. 电商订单号设计思考
  6. Leaf—— 美团点评分布式 ID 生成系统
  7. 分布式系统全局唯一 ID 生成方案
因为热爱,所以执着。