分布式唯一业务单号生成方案:Redis + 数据库双保险架构
本文分享一个生产环境验证过的分布式唯一单号生成方案,适用于订单号、运单号、流水号等业务场景。方案支持 5000+ QPS,具备完善的容灾降级能力。
一、问题背景
在分布式系统中,业务单号生成是一个经典问题。常见的痛点包括:
- 并发重复:多实例部署时,同一时刻生成相同单号
- 单点故障:依赖单一组件(如 Redis)宕机导致业务不可用
- 性能瓶颈:数据库自增 ID 在高并发下成为瓶颈
- 格式要求:业务单号往往有特定格式(日期+流水号),不能用 UUID
业务单号格式示例
企业编码(2位) + 日期(yyMMdd) + 流水号(5-6位)
示例:hy251227 00086
二、方案设计
2.1 核心架构
降级方案
主方案
应用层
Redis故障
BusinessNoGenerator
统一单号生成入口
Redis INCR
原子递增
MySQL
SELECT FOR UPDATE
Redis
MySQL
2.2 三层防御体系
| 层级 | 组件 | 作用 | 并发能力 |
|---|---|---|---|
| 第一层 | Redis INCR | 主力生成,原子递增 | 10万+ QPS |
| 第二层 | 数据库行锁 | Redis 故障时降级 | 1000+ QPS |
| 第三层 | 唯一索引 | 最后防线,防止漏网之鱼 | - |
三、核心实现
3.1 Redis 原子递增(主方案)
java
@Service
@RequiredArgsConstructor
@Slf4j
public class BusinessNoGeneratorService {
private final StringRedisTemplate stringRedisTemplate;
private final RedissonClient redissonClient;
private static final String SEQ_KEY_PREFIX = "biz:seq:";
private static final String INIT_LOCK_PREFIX = "biz:init:lock:";
private static final long MAX_SEQ_5_DIGIT = 99999L;
private static final long MAX_SEQ_6_DIGIT = 999999L;
/**
* 生成业务单号(原子操作,并发安全)
* @param bizType 业务类型(如 ORDER、WAYBILL)
* @param prefix 单号前缀(如企业编码)
* @param dateTime 业务日期
*/
public String generateNo(String bizType, String prefix, LocalDateTime dateTime) {
String yearStr = String.format("%02d", dateTime.getYear() % 100);
String dateStr = String.format("%02d%02d%02d",
dateTime.getYear() % 100,
dateTime.getMonthValue(),
dateTime.getDayOfMonth());
String seqKey = SEQ_KEY_PREFIX + bizType + ":" + yearStr;
try {
// 确保 Redis key 已初始化
ensureKeyInitialized(seqKey, bizType, yearStr);
// 原子递增 - Redis INCR 天然线程安全
Long seq = stringRedisTemplate.opsForValue().increment(seqKey);
if (seq == null) {
throw new RuntimeException("生成单号失败");
}
// 超限检查与告警
checkAndWarn(seq, bizType, yearStr);
// 格式化:5位不够自动扩展到6位
String seqStr = formatSeq(seq);
return prefix + dateStr + seqStr;
} catch (Exception e) {
log.error("Redis 生成单号异常,触发降级", e);
return generateByDatabase(bizType, prefix, dateStr, yearStr);
}
}
private String formatSeq(long seq) {
return seq <= MAX_SEQ_5_DIGIT
? String.format("%05d", seq)
: String.format("%06d", seq);
}
private void checkAndWarn(long seq, String bizType, String yearStr) {
if (seq > MAX_SEQ_6_DIGIT) {
throw new RuntimeException("年度单号已用完(超过999999)");
}
if (seq == 90000L || seq == MAX_SEQ_5_DIGIT) {
log.warn("【告警】{} 年度 {} 流水号已达 {},请关注", bizType, yearStr, seq);
}
}
}
3.2 Lua 脚本原子初始化
Redis key 首次使用时需要从数据库同步当前最大值,这里用 Lua 脚本保证原子性:
java
// Lua 脚本:仅当 key 不存在时设置,避免竞态条件
private static final String INIT_SCRIPT =
"if redis.call('exists', KEYS[1]) == 0 then " +
" redis.call('set', KEYS[1], ARGV[1]) " +
" redis.call('expire', KEYS[1], ARGV[2]) " +
" return 1 " +
"else " +
" return 0 " +
"end";
private static final DefaultRedisScript<Long> INIT_REDIS_SCRIPT =
new DefaultRedisScript<>(INIT_SCRIPT, Long.class);
/**
* 确保 Redis key 已初始化
* 分布式锁 + Lua 脚本双重保证
*/
private void ensureKeyInitialized(String seqKey, String bizType, String yearStr) {
// 快速检查:key 存在直接返回
if (Boolean.TRUE.equals(stringRedisTemplate.hasKey(seqKey))) {
return;
}
// 分布式锁防止并发初始化
String lockKey = INIT_LOCK_PREFIX + bizType + ":" + yearStr;
RLock lock = redissonClient.getLock(lockKey);
try {
if (!lock.tryLock(5, 10, TimeUnit.SECONDS)) {
// 获取锁失败,等待后重试
Thread.sleep(100);
if (Boolean.TRUE.equals(stringRedisTemplate.hasKey(seqKey))) {
return;
}
throw new RuntimeException("系统繁忙,请稍后重试");
}
// 双重检查
if (Boolean.TRUE.equals(stringRedisTemplate.hasKey(seqKey))) {
return;
}
// 从数据库查询当前最大流水号(包含已删除记录,防止回退)
Long maxSeq = bizNoMapper.getMaxSeq(bizType, yearStr);
long initValue = (maxSeq == null || maxSeq < 0) ? 0L : maxSeq;
// Lua 脚本原子设置
stringRedisTemplate.execute(INIT_REDIS_SCRIPT,
Collections.singletonList(seqKey),
String.valueOf(initValue),
String.valueOf(730 * 24 * 60 * 60)); // 2年过期
log.info("初始化序列: bizType={}, year={}, initValue={}", bizType, yearStr, initValue);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new RuntimeException("初始化被中断");
} finally {
if (lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
}
3.3 数据库降级方案
当 Redis 不可用时,自动降级到数据库方案:
java
/**
* 降级方案:数据库行锁生成
* 使用 SELECT FOR UPDATE 保证分布式环境并发安全
*/
@Transactional(rollbackFor = Exception.class, propagation = Propagation.REQUIRES_NEW)
public String generateByDatabase(String bizType, String prefix, String dateStr, String yearStr) {
// 行锁查询当前最大值
Long maxSeq = bizNoMapper.getMaxSeqForUpdate(bizType, yearStr);
long newSeq = (maxSeq == null || maxSeq < 0) ? 1L : maxSeq + 1;
if (newSeq > MAX_SEQ_6_DIGIT) {
throw new RuntimeException("年度单号已用完");
}
log.warn("【告警】使用数据库降级方案生成单号: {}", bizType);
return prefix + dateStr + formatSeq(newSeq);
}
对应的 Mapper:
xml
<!-- 查询最大流水号(包含已删除记录) -->
<select id="getMaxSeq" resultType="java.lang.Long">
SELECT MAX(CAST(SUBSTRING(biz_no, -5) AS UNSIGNED))
FROM biz_record
WHERE biz_type = #{bizType}
AND biz_no LIKE CONCAT('%', #{yearStr}, '%')
</select>
<!-- 查询并加行锁(降级方案用) -->
<select id="getMaxSeqForUpdate" resultType="java.lang.Long">
SELECT MAX(CAST(SUBSTRING(biz_no, -5) AS UNSIGNED))
FROM biz_record
WHERE biz_type = #{bizType}
AND biz_no LIKE CONCAT('%', #{yearStr}, '%')
FOR UPDATE
</select>
3.4 数据库唯一索引兜底
sql
-- 最后一道防线:唯一索引
ALTER TABLE biz_record ADD UNIQUE INDEX uk_biz_no (tenant_id, biz_no);
四、关键设计点
4.1 为什么用 Redis INCR 而不是分布式锁?
| 方案 | 实现 | 性能 | 复杂度 |
|---|---|---|---|
| 分布式锁 | Redisson Lock + 查询 + 更新 | 1000 QPS | 高 |
| Redis INCR | 单命令原子操作 | 10万+ QPS | 低 |
INCR 是 Redis 单线程模型下的原子操作,天然无竞争,性能碾压分布式锁方案。
4.2 为什么需要 Lua 脚本初始化?
场景:Redis 重启后 key 丢失,多个请求同时触发初始化。
时间线:
T1: 线程A 检查 key 不存在
T2: 线程B 检查 key 不存在
T3: 线程A 查询数据库得到 maxSeq=100
T4: 线程B 查询数据库得到 maxSeq=100
T5: 线程A SET key=100
T6: 线程B SET key=100 ← 覆盖了!后续 INCR 从 100 开始,可能重复
Lua 脚本的 exists + set 是原子的,配合分布式锁,彻底解决竞态条件。
4.3 为什么查询要包含已删除记录?
场景:
1. 生成单号 00001~00100
2. 删除 00050~00100 的记录
3. Redis key 过期
4. 重新初始化,如果只查未删除记录,maxSeq=00049
5. 下一个号是 00050 ← 与已删除记录重复!
虽然已删除,但单号可能已经打印、外发,必须保证全局唯一。
4.4 为什么降级方案用 REQUIRES_NEW?
java
@Transactional(propagation = Propagation.REQUIRES_NEW)
降级方案需要独立事务,原因:
- 外层事务可能回滚,但单号已生成,不应回退
FOR UPDATE锁需要尽快释放,独立事务提交更快
五、性能与容量
5.1 并发能力
| 场景 | QPS | 说明 |
|---|---|---|
| Redis 正常 | 5000-10000+ | 瓶颈在网络和业务逻辑 |
| Redis 降级 | 500-1000 | 数据库行锁,够用但要尽快恢复 |
5.2 容量规划
- 5位流水号:99,999/年
- 6位流水号:999,999/年(自动扩展)
- 超过 90,000 时告警,提前预警
六、监控告警
建议配置以下监控项:
java
// 关键日志 pattern
"【告警】Redis 生成单号异常" // Redis 故障,触发降级
"【告警】使用数据库降级方案" // 正在降级运行
"流水号已达" // 容量预警
七、方案对比
| 方案 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| 数据库自增 | 简单 | 性能差,单点 | 低并发 |
| UUID | 简单,无依赖 | 无序,太长 | 内部ID |
| 雪花算法 | 高性能,有序 | 时钟回拨,格式固定 | 通用ID |
| 本方案 | 高性能,可降级,格式灵活 | 依赖 Redis | 业务单号 |
八、总结
本方案的核心思想:
- 主力方案要快:Redis INCR 原子操作,无锁高性能
- 降级方案要有:数据库兜底,保证可用性
- 最后防线要稳:唯一索引,防止任何漏网之鱼
- 细节要到位:Lua 原子初始化、包含已删除记录、容量告警
这套方案已在生产环境稳定运行,日均处理数万单,经历过 Redis 故障自动降级的考验