Files
travel/docs/分库分表方案/业务单号设计最佳实践.md
2026-03-09 11:22:43 +08:00

10 KiB
Raw Blame History

业务单号设计最佳实践

核心问题

在分库分表场景下,如何设计业务单号才能支持:

  1. 按单号快速查询(无需遍历所有分片)
  2. 按用户维度查询(订单列表、凭证列表等)
  3. 第三方系统回调查询(如支付回调)

方案对比

方案一:单号包含分片信息(推荐)

设计思路

在业务单号中嵌入 userNo 信息,实现自路由。

单号格式

格式:{前缀}{时间戳}{userNo后6位}{序列号}

订单号OD20240309123456789012001
凭证号TK20240309123456789012001
支付单PY20240309123456789012001

解析:
- 前缀2位业务类型标识
- 时间戳14位yyyyMMddHHmmss
- userNo后6位6位用于分片路由
- 序列号3位保证唯一性

总长度25位

优点

无需额外路由表 查询性能最优(直接路由到单个分片) 实现简单 单号可读性好(包含时间信息)

缺点

单号长度较长25位 userNo 信息可能泄露(可通过加密解决) userNo 变更时单号无法更新(实际场景很少)

适用场景

  • 大部分业务场景(推荐)
  • 对单号长度不敏感
  • userNo 相对稳定

方案二:路由表

设计思路

单独维护一张路由表,记录 业务单号 → userNo/分片信息 的映射。

路由表设计

-- 订单路由表
CREATE TABLE `order_route` (
  `id` BIGINT NOT NULL AUTO_INCREMENT,
  `order_no` VARCHAR(64) NOT NULL COMMENT '订单编号',
  `user_no` VARCHAR(64) NOT NULL COMMENT '用户编号',
  `db_index` TINYINT NOT NULL COMMENT '库索引',
  `table_index` TINYINT NOT NULL COMMENT '表索引',
  `create_time` DATETIME NOT NULL,
  PRIMARY KEY (`id`),
  UNIQUE KEY `uk_order_no` (`order_no`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

-- 凭证路由表
CREATE TABLE `ticket_route` (
  `id` BIGINT NOT NULL AUTO_INCREMENT,
  `ticket_no` VARCHAR(64) NOT NULL COMMENT '凭证编号',
  `user_no` VARCHAR(64) NOT NULL COMMENT '用户编号',
  `db_index` TINYINT NOT NULL COMMENT '库索引',
  `table_index` TINYINT NOT NULL COMMENT '表索引',
  `create_time` DATETIME NOT NULL,
  PRIMARY KEY (`id`),
  UNIQUE KEY `uk_ticket_no` (`ticket_no`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

-- 支付单路由表
CREATE TABLE `payment_route` (
  `id` BIGINT NOT NULL AUTO_INCREMENT,
  `payment_no` VARCHAR(64) NOT NULL COMMENT '支付单编号',
  `third_party_no` VARCHAR(128) COMMENT '第三方流水号',
  `user_no` VARCHAR(64) NOT NULL COMMENT '用户编号',
  `db_index` TINYINT NOT NULL COMMENT '库索引',
  `table_index` TINYINT NOT NULL COMMENT '表索引',
  `create_time` DATETIME NOT NULL,
  PRIMARY KEY (`id`),
  UNIQUE KEY `uk_payment_no` (`payment_no`),
  UNIQUE KEY `uk_third_party_no` (`third_party_no`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

查询流程

// 1. 查询路由表
OrderRoute route = orderRouteRepository.findByOrderNo(orderNo);

// 2. 根据路由信息查询订单
ShardingInfo sharding = new ShardingInfo(route.getDbIndex(), route.getTableIndex());
Order order = orderMapper.selectByOrderNo(sharding, orderNo);

优点

单号格式灵活(不受分片规则限制) 支持任意查询维度(如第三方流水号) userNo 信息不泄露

缺点

需要额外查询路由表(多一次 IO 路由表数据量大(与业务表同量级) 需要维护路由表的一致性 路由表本身也需要分表

适用场景

  • 单号格式有特殊要求
  • 需要支持多维度查询(如第三方流水号)
  • 对查询性能要求不是特别高

方案三:遍历所有分片(不推荐)

设计思路

查询时遍历所有分片,直到找到数据。

实现

public Order getOrderByOrderNo(String orderNo) {
    // 遍历所有分片
    for (int db = 0; db < 16; db++) {
        for (int table = 0; table < 16; table++) {
            Order order = orderMapper.selectByOrderNo(
                new ShardingInfo(db, table), 
                orderNo
            );
            if (order != null) {
                return order;
            }
        }
    }
    return null;
}

优点

实现简单 无需额外设计

缺点

性能极差(最坏情况查询 256 次) 数据库压力大 响应时间不可控

适用场景

  • 仅用于临时查询、数据修复等场景
  • 不适合线上业务

推荐方案

核心业务单号:方案一(单号包含分片信息)

适用于:

  • 订单号
  • 凭证号
  • 支付单号
  • 退款单号

理由:

  • 查询性能最优
  • 实现简单
  • 无需额外维护

第三方关联单号:方案二(路由表)

适用于:

  • 第三方支付流水号
  • 第三方物流单号
  • 外部系统订单号

理由:

  • 第三方单号不可控
  • 必须支持反向查询
  • 路由表数据量相对较小

实现细节

1. 单号生成器

@Service
public class BusinessNoGenerator {
    
    @Autowired
    private RedisTemplate<String, Long> redisTemplate;
    
    @Autowired
    private SnowflakeIdWorker snowflakeIdWorker;
    
    /**
     * 生成业务单号(包含分片信息)
     */
    public String generateBusinessNo(String prefix, String userNo) {
        // 时间戳yyyyMMddHHmmss
        String timestamp = LocalDateTime.now()
            .format(DateTimeFormatter.ofPattern("yyyyMMddHHmmss"));
        
        // userNo 后6位用于路由
        String userSuffix = extractUserSuffix(userNo);
        
        // 序列号3位
        String sequence = String.format("%03d", getSequence(prefix + timestamp));
        
        return prefix + timestamp + userSuffix + sequence;
    }
    
    /**
     * 提取 userNo 后6位
     */
    private String extractUserSuffix(String userNo) {
        // 如果 userNo 是数字
        if (userNo.matches("\\d+")) {
            long num = Long.parseLong(userNo);
            return String.format("%06d", num % 1000000);
        }
        
        // 如果 userNo 是字符串,取 hashCode
        int hash = Math.abs(userNo.hashCode());
        return String.format("%06d", hash % 1000000);
    }
    
    /**
     * 获取序列号Redis 自增)
     */
    private Long getSequence(String key) {
        String redisKey = "seq:" + key;
        Long seq = redisTemplate.opsForValue().increment(redisKey);
        
        // 设置过期时间(避免 key 过多)
        redisTemplate.expire(redisKey, 1, TimeUnit.DAYS);
        
        return seq % 1000; // 3位序列号
    }
    
    /**
     * 从业务单号解析分片信息
     */
    public ShardingInfo parseShardingInfo(String businessNo) {
        // 提取 userNo 后缀第16-21位
        if (businessNo.length() < 22) {
            throw new IllegalArgumentException("Invalid businessNo: " + businessNo);
        }
        
        String userSuffix = businessNo.substring(16, 22);
        long userNum = Long.parseLong(userSuffix);
        
        int dbIndex = (int) (userNum % 16);
        int tableIndex = (int) ((userNum / 16) % 16);
        
        return new ShardingInfo(dbIndex, tableIndex);
    }
}

2. 单号安全性增强(可选)

如果担心 userNo 信息泄露,可以对 userNo 后缀进行简单加密:

/**
 * 生成业务单号(加密版)
 */
public String generateBusinessNoWithEncryption(String prefix, String userNo) {
    String timestamp = LocalDateTime.now()
        .format(DateTimeFormatter.ofPattern("yyyyMMddHHmmss"));
    
    // 提取 userNo 后6位
    String userSuffix = extractUserSuffix(userNo);
    
    // 简单加密:异或运算
    long userNum = Long.parseLong(userSuffix);
    long encrypted = userNum ^ SECRET_KEY; // SECRET_KEY 是固定密钥
    String encryptedSuffix = String.format("%06d", encrypted % 1000000);
    
    String sequence = String.format("%03d", getSequence(prefix + timestamp));
    
    return prefix + timestamp + encryptedSuffix + sequence;
}

/**
 * 解密并解析分片信息
 */
public ShardingInfo parseShardingInfoWithDecryption(String businessNo) {
    String encryptedSuffix = businessNo.substring(16, 22);
    long encrypted = Long.parseLong(encryptedSuffix);
    
    // 解密
    long userNum = encrypted ^ SECRET_KEY;
    
    int dbIndex = (int) (userNum % 16);
    int tableIndex = (int) ((userNum / 16) % 16);
    
    return new ShardingInfo(dbIndex, tableIndex);
}

3. 路由表优化

如果使用路由表,也需要对路由表进行分表:

-- 按单号后4位分表16张表
CREATE TABLE `order_route_0` LIKE `order_route`;
CREATE TABLE `order_route_1` LIKE `order_route`;
...
CREATE TABLE `order_route_15` LIKE `order_route`;

-- 路由规则
table_index = orderNo 4 % 16
/**
 * 路由表查询
 */
public OrderRoute findByOrderNo(String orderNo) {
    // 计算路由表分片
    int routeTableIndex = calculateRouteTableIndex(orderNo);
    
    // 查询对应的路由表
    return orderRouteMapper.selectByOrderNo(routeTableIndex, orderNo);
}

private int calculateRouteTableIndex(String orderNo) {
    // 取单号后4位
    String suffix = orderNo.substring(orderNo.length() - 4);
    return Integer.parseInt(suffix) % 16;
}

性能对比

方案 查询次数 响应时间 数据库压力 实现复杂度
单号包含分片信息 1次 <5ms
路由表 2次 <10ms
遍历所有分片 1-256次 不可控

总结

推荐方案:单号包含分片信息 + 路由表(混合模式)

  • 订单号、凭证号、支付单号:包含分片信息(方案一)
  • 第三方流水号:使用路由表(方案二)

这样既保证了核心查询的性能,又支持了第三方系统的回调查询。

关键设计原则:

  1. 优先使用自路由单号(性能最优)
  2. 不可控的外部单号使用路由表
  3. 路由表本身也要分表
  4. 考虑单号的安全性(可选加密)
  5. 预留扩展空间(单号格式要考虑未来扩容)