本文最后更新于:2 个月前
引言
在分布式系统中,为了保证订单、流水等业务单号的唯一性、高可用、高性能和有序递增,我们通常会选择分布式ID生成方案。本文将结合一个“业务单号”案例,演示如何使用RedisAtomicLong
来生成符合业务需求的可读单号,并与常见的Snowflake
算法做简单对比。
规则
在实际业务场景中,单号除了需要具备唯一性,还希望包含一些能够体现业务维度的信息,方便快速识别或手动对账。例如,一个典型的销售订单号可能会包含:
- 前缀:业务类型,如销售订单(Sales Order)可简写为
SO
- 业务参数:如店代码
DGDA010
- 日期:单号生成日期,常用
yyyyMMdd
格式,如20240101
- 序列号:一天内自增,如
00001
,长度可配
因此,一个完整的销售订单号示例会是SODGDA0102024010100001
SO
—— 销售订单前缀
DGDA010
—— 店铺代码
20240101
—— 订单日期
00001
—— 序列号(当天从 1 开始累加,左补零)
相比纯数字的全局 ID,这类“业务单号”更方便人工查询和业务审计。
特征
一个完善的分布式全局业务ID生成器,应当满足:
- 唯一性:不能产生重复的ID。
- 高可用:在分布式和高并发环境中保持稳定运行。
- 高性能:ID的生成过程不能成为性能瓶颈。
- 递增性:ID有序(如按时间或数值递增),方便做数据分区、排序或日志查询。
实现
在分布式环境中,保证序列号自增的常见做法有:
- 数据库自增主键:容易成为性能瓶颈和单点故障。
- Snowflake算法:能够快速生成高并发下的全局唯一 ID,但生成的结果通常是无业务含义的 64 位长整型。
- Redis自增:基于Redis的
INCR
命令或RedisAtomicLong
,既可以实现分布式场景下的原子性自增,又能灵活拼接业务前缀、参数与日期,生成“可读”的业务单号。
Snowflake
- 高性能:在内存中本地生成,不依赖外部服务;
- 分布式:可以将机器ID、时间戳等信息组合在一个64位长整型中,轻松实现跨节点唯一;
- 不含业务信息:生成的长整型ID难以让人直接看出其含义,通常需要额外查询或计算才能做分类、分表等。
RedisAtomicLong
Spring Data Redis
提供的一个便捷类,内置了以下优势:
- 原子性:底层基于Redis的
INCR
命令,多线程同时操作也能保持正确的计数。
- 易用性:提供了
incrementAndGet
、addAndGet
等简洁方法,且支持设置过期时间(expire
)。
- 可维护性:把计数器作为Key存在Redis,易于在运维层面做监控或清理。
- 可包含业务信息:能够在“纯数字”之外附带前缀、日期、参数等更多逻辑;
如果你仅仅需要一个纯数字型ID(如数据库表主键),不关心其“含义”或“格式”,Snowflake算法更轻便,且不需要额外的外部存储;如果你更关注让ID本身表达业务信息(业务单号),则基于RedisAtomicLong
的方式通常更简单直观。
代码示例
下方是一段使用Spring Boot + Spring Data Redis
示例代码,用于在分布式场景下生成业务单号。代码具备以下特征:
- 灵活可定制:可配置前缀、业务参数、日期、序列号长度、重试机制等。
- 自动过期:每天自动过期,确保不会冗余太多无用数据。
- 批量生成:可一次性生成多个ID,减少对Redis的交互次数。
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 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149
| @Slf4j public class BizIdUtil {
private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyyMMdd");
private static final String REDIS_BIZ_ID_LOCK = "redis_biz_id_lock:";
public static BizIdBuilder builder(StringRedisTemplate stringRedisTemplate) { return new BizIdBuilder(stringRedisTemplate); }
public static class BizIdBuilder {
private final StringRedisTemplate stringRedisTemplate;
public BizIdBuilder(StringRedisTemplate stringRedisTemplate) { this.stringRedisTemplate = stringRedisTemplate; }
private static final ConcurrentHashMap<String, RedisAtomicLong> counterMap = new ConcurrentHashMap<>();
private String prefix;
private List<String> params;
private String paramsStr;
private String dateStr = DATE_FORMATTER.format(LocalDate.now());
private Integer length = 5;
private Integer retryTimes = 1;
private Integer retryInterval = 100;
private TimeUnit retryUnit = TimeUnit.MILLISECONDS;
public BizIdBuilder prefix(String prefix) { this.prefix = prefix; return this; }
public BizIdBuilder params(List<String> params) { if (CollUtil.isNotEmpty(params)) { this.paramsStr = String.join("", params); } return this; }
public BizIdBuilder dateStr(String dateStr) { this.dateStr = dateStr; return this; }
public BizIdBuilder length(Integer length) { this.length = length; return this; }
public BizIdBuilder retryTimes(Integer retryTimes) { this.retryTimes = retryTimes; return this; }
public BizIdBuilder retryInterval(Integer retryInterval) { this.retryInterval = retryInterval; return this; }
public BizIdBuilder unit(TimeUnit unit) { this.retryUnit = unit; return this; }
public String build() { return buildBatch(1).get(0); }
public List<String> buildBatch(int count) { String counter = REDIS_BIZ_ID_LOCK + prefix + paramsStr + dateStr; do { try { RedisAtomicLong entityIdCounter = counterMap.computeIfAbsent(counter, k -> { RedisAtomicLong ra = new RedisAtomicLong(counter, Objects.requireNonNull(stringRedisTemplate.getConnectionFactory())); ra.expire(1, java.util.concurrent.TimeUnit.DAYS); return ra; }); long serialNum = entityIdCounter.addAndGet(count); StringBuilder maxSerialNum = new StringBuilder(); for (int i = 0; i < length; i++) { maxSerialNum.append("9"); } if (serialNum > Long.parseLong(maxSerialNum.toString())) { throw new RuntimeException("序列号已达最大值"); } List<String> result = CollUtil.newArrayList(); for (int i = 0; i < count; i++) { result.add(prefix + paramsStr + dateStr + String.format("%0" + length + "d", serialNum - count + i + 1)); } return result; } catch (Exception e) { log.error("{}生成业务ID失败", counter, e); } try { Thread.sleep(retryUnit.toMillis(retryInterval)); } catch (InterruptedException ex) { log.error("{}线程休眠失败", counter, ex); throw new RuntimeException(counter + "生成业务ID失败"); } } while (retryTimes-- > 0); throw new RuntimeException(counter + "生成业务ID失败,重试次数已用完"); } }
}
|
使用示例
| @Resource private StringRedisTemplate stringRedisTemplate;
public String getOrderBizId() { return BizIdUtil.builder(stringRedisTemplate) .prefix("SO") .params(Arrays.asList("DGDA010")) .length(5) .retryTimes(3) .retryInterval(100) .unit(TimeUnit.MILLISECONDS) .build(); }
|
每次运行都能得到唯一的自增单号,如
| SODGDA0102024010100001 SODGDA0102024010100002 SODGDA0102024010100003
|
优化方向
内存占用
如果prefix + paramsStr + dateStr
可能组合非常多,则会导致Redis中保存大量Key和 RedisAtomicLong
实例。可以考虑:
- 设置合理的过期策略(每天自动过期)
- 评估业务实际组合量,或者使用更灵活的生成策略
序列号溢出
如果序列号长度是5位,意味着同一天最多只能生成99999个订单。如果实际业务量更大,需要适当增加序列号长度或在序列号快到上限时发出告警,并做相应扩容策略。
高并发下的原子性
RedisAtomicLong
的内部操作本质是Redis的INCR
命令,保证了计数的原子性。如果需要更复杂的操作(比如提前判断是否溢出、再进行INCR
),可以考虑Lua
脚本进行一次性判断与递增操作,以避免两次Redis调用中间产生竞态条件。
重试次数与间隔
在网络抖动或Redis短暂不可用时,此处的重试逻辑可以提供一定的容错能力。也可以结合更专业的断路器(Circuit Breaker
)与熔断策略来提高系统稳定性。
分布式锁
如果业务需要在生成 ID 时确保其他关键操作的互斥,可以考虑配合分布式锁(如 Redisson)使用。但本示例只关注生成ID,不涉及到额外的锁。
批量生成的效率
对于需要一次性生成大批量ID(如1万条)的场景,可考虑基于 “段式分配” 的思路,即从Redis一次获取一段计数区间(如1~10000),再在应用内自行分配,以减少与Redis的交互次数。此思路实现较为复杂,此处只作思路提示。
总结
通过以上思路和示例,就能在分布式场景下生成带业务信息的订单号或流水号,既满足了业务侧对“可读性”的需求,也保证了分布式全局唯一与高并发下的有序性。在实际项目中,可按需进一步扩展,如添加分库分表策略、结合监控报警、或引入更多重试和降级机制,打造更完善的分布式业务ID生成方案。