【 分布式唯一业务单号生成方案:Redis + 数据库双保险架构】

分布式唯一业务单号生成方案:Redis + 数据库双保险架构

本文分享一个生产环境验证过的分布式唯一单号生成方案,适用于订单号、运单号、流水号等业务场景。方案支持 5000+ QPS,具备完善的容灾降级能力。

一、问题背景

在分布式系统中,业务单号生成是一个经典问题。常见的痛点包括:

  1. 并发重复:多实例部署时,同一时刻生成相同单号
  2. 单点故障:依赖单一组件(如 Redis)宕机导致业务不可用
  3. 性能瓶颈:数据库自增 ID 在高并发下成为瓶颈
  4. 格式要求:业务单号往往有特定格式(日期+流水号),不能用 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)

降级方案需要独立事务,原因:

  1. 外层事务可能回滚,但单号已生成,不应回退
  2. 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 业务单号

八、总结

本方案的核心思想:

  1. 主力方案要快:Redis INCR 原子操作,无锁高性能
  2. 降级方案要有:数据库兜底,保证可用性
  3. 最后防线要稳:唯一索引,防止任何漏网之鱼
  4. 细节要到位:Lua 原子初始化、包含已删除记录、容量告警

这套方案已在生产环境稳定运行,日均处理数万单,经历过 Redis 故障自动降级的考验

相关推荐
前端世界2 小时前
HarmonyOS 分布式硬件实战指南:从原理到可运行 Demo
分布式·华为·harmonyos
gjc5922 小时前
MySQL无主键大表删除导致主从同步延迟的深度分析
数据库·mysql
典孝赢麻崩乐急2 小时前
Redis复习-------Redis事务
数据库·redis·缓存
Gofarlic_OMS2 小时前
通过MathWorks API实现许可证管理自动化
大数据·数据库·人工智能·adobe·金融·自动化·区块链
橘子真甜~2 小时前
Reids命令原理与应用3 - Redis 主线程,辅助线程与存储原理
网络·数据库·redis·缓存·线程·数据类型·存储结构
杨了个杨89822 小时前
Rsyslog + MySQL 实现日志集中存储
数据库·mysql
程序猿20232 小时前
SQL-性能优化
数据库·sql·性能优化
程序员王天2 小时前
SQLite 查询优化实战:从9秒到300毫秒
数据库·electron·sqlite
码农水水2 小时前
宇树科技Java被问:数据库连接池的工作原理
java·数据库·后端·oracle