当前位置:首页 > 技术文章 > 正文内容

如何设计一个订单号生成服务?_订单号生成策略

嘿,朋友!今天咱们来聊个实际工作中特别常见的问题:怎么设计一个靠谱的订单号生成服务。你可能会说,不就是生成个唯一编号吗?有啥难的?但真要做到高并发、不重复、还好扩展,这里面门道可不少。

咱们先从最基本的需求说起。一个合格的订单号,首先得保证全局唯一,总不能两个订单用一个号吧?其次,现在业务增长快,得支持高并发,万一搞个秒杀活动,每秒几万单,这生成服务可不能掉链子。还有,最好能有点可读性,比如包含时间信息,出问题了好排查。当然,也不能太好猜,不然别人随便改个数字就能查别人的订单,那就麻烦了。

那具体用什么方案呢?咱们来聊聊常见的几种:

最简单的就是数据库自增ID,配置个自增主键就行。但这玩意儿有个大问题,高并发下数据库容易成瓶颈,而且订单号一眼就能看出你卖了多少单,不太安全。

UUID呢?确实能保证唯一,也不用依赖中心节点,但太长了,还无序,查数据库的时候索引效率不高,可读性也差。

我个人比较推荐的是改进版的Snowflake算法,也就是雪花算法。这东西性能好,生成的ID还能保持趋势递增,里面还能嵌点时间信息,可读性也不错。

咱们来具体设计一下这个订单号。我想把它分成这么几个部分:

  1. 时间戳:用14位,精确到秒,比如20231109141930,一看就知道什么时候生成的
  2. 业务标识:2位,区分不同业务线,比如01是普通订单,02是秒杀订单
  3. 机器ID:3位,分布式环境下每个节点一个唯一标识
  4. 随机序列:8位,同一秒内的序列号加上随机数,防止别人猜测
  5. 校验位:1位,用来检查输入错误的

这样下来,总共是28位,不长不短,刚刚好。

咱们来看看核心代码怎么实现。首先,得有个生成序列号的类:

public  class  SequenceGenerator {
// 上次生成ID的时间戳
private long lastTimestamp = -1L;
// 当前序列号
private long sequence = 0L;
// 序列号的位数,这里设12位,最多支持4096个/毫秒
private static final int SEQUENCE_BITS = 12;
// 最大序列号
private static final long MAX_SEQUENCE = (1L << SEQUENCE_BITS) - 1;

// 机器ID
private final long workerId;
// 机器ID的位数
private static final int WORKER_ID_BITS = 10;
// 最大机器ID
private static final long MAX_WORKER_ID = (1L << WORKER_ID_BITS) - 1;

public SequenceGenerator(long workerId) {
// 校验机器ID是否合法
if (workerId < 0 || workerId > MAX_WORKER_ID) {
throw new IllegalArgumentException("Worker ID超出范围");
}
this.workerId = workerId;
}

public synchronized long nextId() {
// 获取当前时间戳(毫秒)
long timestamp = System.currentTimeMillis();

// 处理时钟回拨问题
if (timestamp < lastTimestamp) {
// 如果时间回拨小于100ms,等待到上次时间
if (lastTimestamp - timestamp < 100) {
try {
Thread.sleep(lastTimestamp - timestamp);
timestamp = lastTimestamp;
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new RuntimeException("生成ID时发生中断", e);
}
} else {
// 时间回拨过大,抛出异常
throw new RuntimeException("时钟回拨异常");
}
}

// 如果是同一时间戳,序列号加1
if (timestamp == lastTimestamp) {
sequence = (sequence + 1) & MAX_SEQUENCE;
// 如果序列号用完了,等待下一毫秒
if (sequence == 0) {
timestamp = waitNextMillis(lastTimestamp);
}
} else {
// 新的时间戳,序列号重置
sequence = 0L;
}

// 更新上次时间戳
lastTimestamp = timestamp;

// 组合ID:时间戳 << (机器ID位数 + 序列号位数) | 机器ID << 序列号位数 | 序列号
return (timestamp << (WORKER_ID_BITS + SEQUENCE_BITS))
| (workerId << SEQUENCE_BITS)
| sequence;
}

// 等待到下一毫秒
private long waitNextMillis(long lastTimestamp) {
long timestamp = System.currentTimeMillis();
while (timestamp <= lastTimestamp) {
timestamp = System.currentTimeMillis();
}
return timestamp;
}
}

这段代码的核心逻辑是:

  1. 每次生成ID时先获取当前时间戳
  2. 处理可能的时钟回拨问题,避免生成重复ID
  3. 同一毫秒内,序列号自增
  4. 不同毫秒,序列号重置为0
  5. 最后通过位运算把时间戳、机器ID和序列号组合起来

接下来,咱们需要把这个基础ID转换成咱们想要的订单号格式:

public  class  OrderIdGenerator {
private final SequenceGenerator sequenceGenerator;
// 业务编码
private final String businessCode;

public OrderIdGenerator(long workerId, String businessCode) {
this.sequenceGenerator = new SequenceGenerator(workerId);
this.businessCode = businessCode;
}

public String generateOrderId() {
// 获取基础ID
long baseId = sequenceGenerator.nextId();

// 提取时间戳并格式化(这里简化处理,实际需要转换为yyyyMMddHHmmss格式)
long timestamp = baseId >>> (10 + 12); // 右移机器ID位数+序列号位数
String timeStr = formatTimestamp(timestamp);

// 组合订单号各部分
StringBuilder orderId = new StringBuilder();
orderId.append(timeStr) // 时间戳(14位)
.append(businessCode) // 业务标识(2位)
.append(String.format("%03d", sequenceGenerator.getWorkerId() % 1000)) // 机器ID(3位)
.append(String.format("%08d", baseId % 100000000)); // 随机序列(8位)

// 添加校验位
orderId.append(generateCheckDigit(orderId.toString()));

return orderId.toString();
}

// 格式化时间戳为yyyyMMddHHmmss
private String formatTimestamp timestamp(long timestamp) {
// 实际应用中需要将毫秒时间戳转换为指定格式的字符串
// 这里简化处理,实际应使用SimpleDateFormat等工具
return String.valueOf(timestamp).substring(0, 14);
}

// 生成校验位
private char generateCheckDigit(String str) {
int sum = 0;
for (int i = 0; i < str.length(); i++) {
int digit = Character.getNumericValue(str.charAt(i));
// 偶数位乘2,奇数位不变
if (i % 2 == 0) {
digit *= 2;
// 若乘2后大于9,减去9
if (digit > 9) {
digit -= 9;
}
}
sum += digit;
}
// 计算校验位
int checkDigit = (10 - (sum % 10)) % 10;
return Character.forDigit(checkDigit, 10);
}
}

这段代码主要做了这么几件事:

  1. 调用SequenceGenerator获取基础ID
  2. 从基础ID中提取时间戳并格式化
  3. 把时间戳、业务编码、机器ID、随机序列组合起来
  4. 生成并添加校验位

这里的校验位使用了Luhn算法的变种,能有效检测输入时的单 digit 错误或相邻数字交换错误。

在分布式环境下,每个节点需要有唯一的机器ID。咱们可以用一个管理类来获取机器ID:

public  class  WorkerIdManager {
private static int workerId;

// 初始化机器ID
public static synchronized int initWorkerId() {
// 实际应用中,这里应该从配置中心或数据库获取唯一ID
// 可以用ZooKeeper的临时节点来实现分布式锁,确保ID唯一
if (workerId == 0) {
workerId = fetchWorkerIdFromConfigCenter();
}
return workerId;
}

// 从配置中心获取机器ID
private static int fetchWorkerIdFromConfigCenter() {
// 这里是伪代码,实际实现需要连接配置中心
// 大致逻辑:尝试创建临时节点,节点名包含自增ID
// 成功则返回该ID,失败则重试
return 1; // 示例返回1
}

public static int getWorkerId() {
if (workerId == 0) {
throw new IllegalStateException("机器ID未初始化");
}
return workerId;
}
}

这个类的作用是确保每个节点都能获取到唯一的机器ID,避免分布式环境下的ID冲突。

为了提高性能,咱们还可以加个本地缓存,预先生成一批ID:

public  class  OrderIdCache {
private final Queue idCache = new ConcurrentLinkedQueue<>();
private final OrderIdGenerator generator;
// 缓存大小
private static final int CACHE_SIZE = 1000;
// 触发填充缓存的阈值
private static final int REFILL_THRESHOLD = 200;

public OrderIdCache(OrderIdGenerator generator) {
this.generator = generator;
// 初始化缓存
refillCache();
}

// 获取订单号
public String getOrderId() {
// 如果缓存不足,异步填充
if (idCache.size() < REFILL_THRESHOLD) {
new Thread(this::refillCache).start();
}
return idCache.poll();
}

// 填充缓存
private void refillCache() {
synchronized (this) {
int need = CACHE_SIZE - idCache.size();
for (int i = 0; i < need; i++) {
idCache.offer(generator.generateOrderId());
}
}
}
}

这样一来,大部分情况下都能直接从缓存获取ID,减少实时生成的压力,提高响应速度。

最后,咱们来看看怎么使用这些类:

public  class  Main {
public static void main(String[] args) {
// 初始化机器ID
WorkerIdManager.initWorkerId();

// 创建订单号生成器,业务编码"01"表示普通订单
OrderIdGenerator generator = new OrderIdGenerator(
WorkerIdManager.getWorkerId(), "01");

// 创建缓存
OrderIdCache cache = new OrderIdCache(generator);

// 生成10个订单号
for (int i = 0; i < 10; i++) {
System.out.println(cache.getOrderId());
}
}
}

这样一套下来,基本上就能满足大部分场景的需求了。当然,实际应用中还要考虑高可用,比如部署多个生成节点,做负载均衡,还有完善的监控告警机制,及时发现时钟同步问题或者序列号耗尽等异常情况。

你觉得这个方案怎么样?有没有什么需要改进的地方?


相关文章

据说是可以替代 Windows 的 5个 Linux 发行版

现如今有数以千计的 Linux 发行版可供您使用,然而人们却无法选择一个完美的操作系统来替代 Windows。 使用 Windows 时,傻瓜都能操作自如,同样的方法却不适用于 Linux。在这里,您...

Vue3 中,父子组件如何传递参数?(vue父子组件传递数据方法)

在 Vue3 中,组件化开发是非常重要的特征,那么组件之间传值就是开发中常见的需求了。组件之间的传值三种方式:父传子、子传父、非父子组件传值。一、父传子( defineProps )父组件主要通过使用...

2024前端面试真题之—VUE篇(前端面试题vuex)

添加图片注释,不超过 140 字(可选)1.vue的生命周期有哪些及每个生命周期做了什么? beforeCreate是new Vue()之后触发的第一个钩子,在当前阶段data、methods、com...

10分钟搞定gitlab-ci自动化部署(gitlab ci 配置)

gitlab-ci 是持续集成工具/自动化部署工具,类似 jenkins。持续集成 是将代码集成到共享存储库并尽可能早地自动构建/测试每个更改的实践 - 通常一天几次。概述在编码完成时都会进行打包发布...

Python 实现 | 通过 Gitlab API 获取项目工程、分支、commit 提交记录

前提在 gitlab 中你的工程创建 Access Token然后你会得到一个 21 位 access token,代码中需要用到。代码''' 说明: 1.登录gitlab的r...

程序员开发必会之git常用命令,git配置、拉取、提交、分支管理

整理日常开发过程中经常使用的git命令!git配置SSH刚进入项目开发中,我们首先需要配置git的config、配置SSH方式拉取代码,以后就免输入账号密码了!# 按顺序执行 git config -...