【 分布式唯一业务单号生成方案: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 故障自动降级的考验

相关推荐
檀越剑指大厂5 小时前
金仓数据库以“多模融合”引领文档数据库国产化新篇章
数据库
煎蛋学姐6 小时前
SSM星河书城9p6tr(程序+源码+数据库+调试部署+开发环境)带论文文档1万字以上,文末可获取,系统界面在最后面
数据库·用户管理·ssm 框架·星河书城·线上书城
宋情写6 小时前
docker-compose安装Redis
redis·docker·容器
jason成都6 小时前
实战 | 国产数据库 R2DBC-JDBC 桥接踩坑记 - JetLinks适配达梦数据库
java·数据库·物联网
Elastic 中国社区官方博客6 小时前
使用 Elasticsearch 管理 agentic 记忆
大数据·数据库·人工智能·elasticsearch·搜索引擎·ai·全文检索
小宇的天下6 小时前
Calibre 3Dstack --每日一个命令day13【enclosure】(3-13)
服务器·前端·数据库
云和数据.ChenGuang7 小时前
达梦数据库安装服务故障四
linux·服务器·数据库·达梦数据库·达梦数据
陌路207 小时前
RPC分布式通信(3)--RPC基础框架接口
分布式·网络协议·rpc
尽兴-7 小时前
MySQL 8.0主从复制原理与实战深度解析
数据库·mysql·主从复制
Mr_sun.7 小时前
Day04——权限认证-基础
android·服务器·数据库