节目服务与支付服务分库分表设计思维全景

视频

image-b7f98b9e

一、节目服务(Program Service)设计分析

1.1 需求分析:业务场景梳理

需求分析:业务场景梳理-a4b5afbb

1.2 实体关系分析

实体关系分析-e4e8faab

1.3 策略选择:为什么这样设计

策略选择:为什么这样设计-fd618198

1.4 问题诊断:同频共振

节目服务原设计问题-ecb2dd59

1.5 节目服务修正方案

节目服务修正方案-627ec481
package com.damai.program.sharding;

import org.apache.shardingsphere.sharding.api.sharding.standard.PreciseShardingValue;
import org.apache.shardingsphere.sharding.api.sharding.standard.StandardShardingAlgorithm;

import java.util.Collection;
import java.util.Properties;

/**
 * 节目服务 - 层级分片算法 (Hierarchical Sharding)
 * <p>
 * 核心思想:
 * 将 2个库 * 2个表 视为 4个连续的全局槽位 (0, 1, 2, 3)。
 * 通过 ID 取模计算出全局槽位,再通过“整除”确定库,“取余”确定表。
 * <p>
 * 优势:
 * 相比于传统的 Hash(ID)%2 分库 + Hash(ID)%2 分表,此算法能避免
 * "奇数ID落入库1表1,偶数ID落入库0表0",导致另外两张表为空的数据倾斜问题。
 */
public class ProgramHierarchicalAlgorithm implements StandardShardingAlgorithm<Long> {

    private int dbCount;
    private int tableCount;
    // 算法类型标识:区分当前是用于计算分库(DATABASE)还是分表(TABLE)
    private String shardingType;

    @Override
    public void init(Properties props) {
        // 从配置文件加载参数,提供默认值防止报错
        this.dbCount = Integer.parseInt(props.getProperty("db-count", "2"));
        this.tableCount = Integer.parseInt(props.getProperty("table-count", "2"));
        this.shardingType = props.getProperty("sharding-type", "DATABASE");
    }

    @Override
    public String doSharding(Collection<String> targets, PreciseShardingValue<Long> shardingValue) {
        // 1. 获取分片键的值 (节目ID)
        long id = shardingValue.getValue();

        // 2. 计算全局槽位索引 (Global Slot Index)
        // 例如:2库2表共4个槽位。ID=5 -> 5%4=1
        int totalSlots = dbCount * tableCount;
        // 使用 Math.abs 确保正数(虽然 Long ID 通常为正,但作为公共组件需健壮)
        int globalIndex = (int) (Math.abs(id) % totalSlots);

        // 3. 计算目标索引 (Target Index)
        int targetIndex;
        if ("DATABASE".equals(shardingType)) {
            // 分库逻辑:使用商 (Quotient)
            // 0/2=0, 1/2=0, 2/2=1, 3/2=1 -> 前两个槽位在库0,后两个在库1
            targetIndex = globalIndex / tableCount;
        } else {
            // 分表逻辑:使用余数 (Remainder)
            // 0%2=0, 1%2=1, 2%2=0, 3%2=1 -> 表0, 表1, 表0, 表1 交替分布
            targetIndex = globalIndex % tableCount;
        }

        // 4. 根据后缀匹配实际的数据源或表名
        // targets 可能是 ["ds_0", "ds_1"] 或 ["program_0", "program_1"]
        String suffix = String.valueOf(targetIndex);
        return targets.stream()
                .filter(t -> t.endsWith("_" + suffix) || t.endsWith(suffix)) // 兼容 endsWith("0") 或 endsWith("_0")
                .findFirst()
                .orElseThrow(() -> new IllegalArgumentException("Target not found for index: " + targetIndex));
    }

    @Override
    public String getType() {
        return "PROGRAM_HIERARCHICAL";
    }
}

二、支付服务(Pay Service)设计分析

2.1 需求分析:业务场景梳理

支付服务需求分析-cbb6e9c7

2.2 实体关系分析

支付服务实体关系-25c81871

2.3 策略分析与潜在问题

支付服务策略分析-d149ba73

2.4 问题一:同频共振(与其他服务相同)

支付服务同频共振问题-6c83eaf8

2.5 问题二:支付回调的全路由问题

支付回调全路由问题分析-8ec4c49b

2.6 支付服务优化方案:基因法

支付单号基因法设计-623af29a

支付单号生成器 (基因嵌入)

这是基因法的核心。在生成支付单号时,不随机生成,而是将订单号的 Hash 特征(基因)强制“烙印”在支付单号中。

package com.damai.pay.util;

import com.damai.common.util.SnowflakeIdGenerator;

/**
 * 支付单号生成器 - 基因法 (Gene Method)
 * <p>
 * 作用:
 * 在生成支付单号 (pay_bill_no) 时,将外部订单号 (out_order_no) 的分片规则(基因)嵌入其中。
 * <p>
 * 结果:
 * pay_bill_no 和 out_order_no 虽然数值不同,但它们对 TOTAL_SHARDS 取模的结果完全一致。
 * 这保证了同一笔订单的支付记录,一定落在该订单所在的分片库/表中。
 */
public class PayBillNoGenerator {

    // 分片总数 = 库数 * 表数 (此处假设 2库 * 2表 = 4)
    // 注意:基因法要求扩容时必须成倍扩容,且保持基因提取规则兼容
    private static final int TOTAL_SHARDS = 4;

    /**
     * 生成带基因的支付单号
     *
     * @param outOrderNo 外部订单号 (String 类型,因为订单号可能带字母或很长)
     * @return 嵌入了基因的支付单号
     */
    public static String generate(String outOrderNo) {
        // 1. 提取基因:计算订单号的分片基因 (0 ~ 3)
        // 必须与分片算法中的 hash 逻辑保持绝对一致
        int gene = Math.abs(outOrderNo.hashCode()) % TOTAL_SHARDS;

        // 2. 生成全局唯一的序列号 (使用雪花算法或分布式序列号服务)
        long snowflakeId = SnowflakeIdGenerator.nextId();

        // 3. 嵌入基因
        // 策略:使用位移或数值乘法腾出位置,将基因放在最低位
        // 公式:(ID * 分片总数) + 基因
        // 举例:ID=100, Total=4, Gene=3 -> 100*4 + 3 = 403
        // 403 % 4 = 3 (还原了基因)
        long payBillNo = (snowflakeId * TOTAL_SHARDS) + gene;

        return String.valueOf(payBillNo);
    }

    /**
     * 从支付单号中反向提取基因
     *
     * @param payBillNo 支付单号
     * @return 分片索引 (0 ~ TOTAL_SHARDS-1)
     */
    public static int extractGene(String payBillNo) {
        try {
            long billNo = Long.parseLong(payBillNo);
            // 直接取模即可还原基因
            return (int) (billNo % TOTAL_SHARDS);
        } catch (NumberFormatException e) {
            throw new IllegalArgumentException("Invalid pay bill no format", e);
        }
    }
}

支付服务分片算法 (双维度支持)

这个算法类非常巧妙,它同时处理 out_order_no (普通路由) 和 pay_bill_no (基因路由),实现了在同一个算法类中支持多种业务场景。

package com.damai.pay.sharding;

import com.damai.pay.util.PayBillNoGenerator;
import org.apache.shardingsphere.sharding.api.sharding.standard.PreciseShardingValue;
import org.apache.shardingsphere.sharding.api.sharding.standard.StandardShardingAlgorithm;

import java.util.Collection;
import java.util.Properties;

/**
 * 支付服务通用分片算法
 * <p>
 * 支持双分片键路由:
 * 1. 按 out_order_no 路由:常规 Hash 取模。
 * 2. 按 pay_bill_no 路由:提取嵌入的基因。
 * <p>
 * 效果:
 * SELECT * FROM t_pay_bill WHERE out_order_no = 'O123'; -> 路由到 Shard X
 * SELECT * FROM t_pay_bill WHERE pay_bill_no = 'P456';  -> 也路由到 Shard X
 * (前提是 P456 是由 O123 生成的)
 */
public class PayBillShardingAlgorithm implements StandardShardingAlgorithm<String> {

    private int dbCount;
    private int tableCount;
    private String shardingType; // DATABASE 或 TABLE

    @Override
    public void init(Properties props) {
        this.dbCount = Integer.parseInt(props.getProperty("db-count", "2"));
        this.tableCount = Integer.parseInt(props.getProperty("table-count", "2"));
        this.shardingType = props.getProperty("sharding-type", "DATABASE");
    }

    @Override
    public String doSharding(Collection<String> targets, PreciseShardingValue<String> shardingValue) {
        // 获取当前 SQL 中的分片列名
        String columnName = shardingValue.getColumnName();
        String value = shardingValue.getValue();
        int totalShards = dbCount * tableCount;

        // 核心逻辑:统一转换为全局基因 (Global Gene/Index)
        int gene;
        if ("out_order_no".equalsIgnoreCase(columnName)) {
            // 场景A:根据订单号查询
            // 直接计算 Hash,逻辑必须与 PayBillNoGenerator 中的 gene 计算一致
            gene = Math.abs(value.hashCode()) % totalShards;
        } else if ("pay_bill_no".equalsIgnoreCase(columnName)) {
            // 场景B:根据支付单号查询
            // 提取单号中预埋的基因
            gene = PayBillNoGenerator.extractGene(value);
        } else {
            throw new UnsupportedOperationException("Unsupported sharding column: " + columnName);
        }

        // 接下来使用层级分片逻辑,将基因映射到具体的库和表
        int targetIndex;
        if ("DATABASE".equals(shardingType)) {
            // 基因 / 表数 = 库索引
            targetIndex = gene / tableCount;
        } else {
            // 基因 % 表数 = 表索引
            targetIndex = gene % tableCount;
        }

        // 匹配物理节点
        String suffix = String.valueOf(targetIndex);
        return targets.stream()
                .filter(t -> t.endsWith("_" + suffix) || t.endsWith(suffix))
                .findFirst()
                .orElseThrow(() -> new IllegalArgumentException("Target not found for index: " + targetIndex));
    }

    @Override
    public String getType() {
        return "PAY_BILL_SHARDING"; // 对应 YAML 中的 algorithmName
    }
}

三、修正后完整配置

3.1 节目服务修正配置

======================================================================
shardingsphere-program.yaml
Program 服务分片配置
策略:分层分片(Hierarchical Sharding)
======================================================================

dataSources:
  ds_0:
    dataSourceClassName: com.zaxxer.hikari.HikariDataSource
    driverClassName: com.mysql.cj.jdbc.Driver
    jdbcUrl: jdbc:mysql://127.0.0.1:3306/damai_program_0?...
    username: root
    password: root
  ds_1:
    dataSourceClassName: com.zaxxer.hikari.HikariDataSource
    driverClassName: com.mysql.cj.jdbc.Driver
    jdbcUrl: jdbc:mysql://127.0.0.1:3306/damai_program_1?...
    username: root
    password: root

rules:
  - !SHARDING
    tables:
      # ========== 主 Program 表(d_program) ==========
      # 分片键:id(Long)
      # 算法:分层分片(先算全局槽位,再映射 DB / Table)
      d_program:
        actualDataNodes: ds_${0..1}.d_program_${0..1}
        databaseStrategy:
          standard:
            shardingColumn: id
            shardingAlgorithmName: programDbAlgorithm
        tableStrategy:
          standard:
            shardingColumn: id
            shardingAlgorithmName: programTableAlgorithm

      # ========== Program 分组表(d_program_group) ==========
      # 分片键:id(Long)
      d_program_group:
        actualDataNodes: ds_${0..1}.d_program_group_${0..1}
        databaseStrategy:
          standard:
            shardingColumn: id
            shardingAlgorithmName: programDbAlgorithm
        tableStrategy:
          standard:
            shardingColumn: id
            shardingAlgorithmName: programTableAlgorithm

      # ========== 演出场次表(d_program_show_time) ==========
      # 子表,按 program_id 分片,保证与主表数据同节点存储(Join 更高效)
      d_program_show_time:
        actualDataNodes: ds_${0..1}.d_program_show_time_${0..1}
        databaseStrategy:
          standard:
            shardingColumn: program_id
            shardingAlgorithmName: programDbAlgorithm
        tableStrategy:
          standard:
            shardingColumn: program_id
            shardingAlgorithmName: programTableAlgorithm

      # ========== 座位表(d_seat) ==========
      # 大数据量表,按 program_id 分片
      d_seat:
        actualDataNodes: ds_${0..1}.d_seat_${0..1}
        databaseStrategy:
          standard:
            shardingColumn: program_id
            shardingAlgorithmName: programDbAlgorithm
        tableStrategy:
          standard:
            shardingColumn: program_id
            shardingAlgorithmName: programTableAlgorithm

      # ========== 票档表(d_ticket_category) ==========
      # 子表,按 program_id 分片。
      d_ticket_category:
        actualDataNodes: ds_${0..1}.d_ticket_category_${0..1}
        databaseStrategy:
          standard:
            shardingColumn: program_id
            shardingAlgorithmName: programDbAlgorithm
        tableStrategy:
          standard:
            shardingColumn: program_id
            shardingAlgorithmName: programTableAlgorithm

    # ========== 广播表(Broadcast Tables) ==========
    # 小量、静态表,在所有库上完全复制,用于本地 Join
    broadcastTables:
      - d_program_category

    # ========== 分片算法定义 ==========
    shardingAlgorithms:
      # [自定义] Program 数据库分片算法
      # 类:com.damai.program.sharding.ProgramHierarchicalAlgorithm
      # 逻辑:(id % 4) / 2
      programDbAlgorithm:
        type: CLASS_BASED
        props:
          strategy: STANDARD
          algorithmClassName: com.damai.program.sharding.ProgramHierarchicalAlgorithm
          db-count: 2
          table-count: 2
          sharding-type: DATABASE

      # [自定义] Program 表分片算法
      # 逻辑:(id % 4) % 2
      programTableAlgorithm:
        type: CLASS_BASED
        props:
          strategy: STANDARD
          algorithmClassName: com.damai.program.sharding.ProgramHierarchicalAlgorithm
          db-count: 2
          table-count: 2
          sharding-type: TABLE

props:
  sql-show: true

3.2 支付服务修正配置

======================================================================
shardingsphere-pay.yaml
Pay 服务分片配置
策略:分层分片 + 基因法(Gene Method)
目标:
1. 解决数据倾斜(分层分片)
2. 支付账单与订单数据同库同表,方便 Join
======================================================================
dataSources:
  ds_0:
    dataSourceClassName: com.zaxxer.hikari.HikariDataSource
    driverClassName: com.mysql.cj.jdbc.Driver
    jdbcUrl: jdbc:mysql://127.0.0.1:3306/damai_pay_0?...
    username: root
    password: root
  ds_1:
    dataSourceClassName: com.zaxxer.hikari.HikariDataSource
    driverClassName: com.mysql.cj.jdbc.Driver
    jdbcUrl: jdbc:mysql://127.0.0.1:3306/damai_pay_1?...
    username: root
    password: root

rules:
  - !SHARDING
    tables:
      # ========== 支付账单表(d_pay_bill) ==========
      # 主分片键:out_order_no(关联订单 ID)
      # - 插入 / Join:使用 out_order_no 做 Hash
      # - 根据 pay_bill_no 查询:从 pay_bill_no 中提取 “基因” 再计算分片
      d_pay_bill:
        actualDataNodes: ds_${0..1}.d_pay_bill_${0..1}
        databaseStrategy:
          standard:
            shardingColumn: out_order_no
            shardingAlgorithmName: payDbAlgorithm
        tableStrategy:
          standard:
            shardingColumn: out_order_no
            shardingAlgorithmName: payTableAlgorithm

      # ========== 退款表(d_refund_bill) ==========
      # 同样按 out_order_no 分片,确保退款账单与原支付单存放在同节点
      d_refund_bill:
        actualDataNodes: ds_${0..1}.d_refund_bill_${0..1}
        databaseStrategy:
          standard:
            shardingColumn: out_order_no
            shardingAlgorithmName: payDbAlgorithm
        tableStrategy:
          standard:
            shardingColumn: out_order_no
            shardingAlgorithmName: payTableAlgorithm

    # ========== 分片算法定义 ==========
    shardingAlgorithms:
      # [自定义] Pay 数据库分片算法
      # 类:com.damai.pay.sharding.PayBillShardingAlgorithm
      # 逻辑:
      #   1. 判断列名
      #   2. out_order_no → Hash → 总槽位 → 分层计算
      #   3. pay_bill_no → 提取 Gene → 分层计算
      payDbAlgorithm:
        type: CLASS_BASED
        props:
          strategy: STANDARD
          algorithmClassName: com.damai.pay.sharding.PayBillShardingAlgorithm
          db-count: 2
          table-count: 2
          sharding-type: DATABASE

      # [自定义] Pay 表分片算法
      # 逻辑同上,但返回表索引
      payTableAlgorithm:
        type: CLASS_BASED
        props:
          strategy: STANDARD
          algorithmClassName: com.damai.pay.sharding.PayBillShardingAlgorithm
          db-count: 2
          table-count: 2
          sharding-type: TABLE

props:
  sql-show: true

四、设计总结对比

三个服务分库分表对比-0bce9794 设计决策流程-2b9e03b8

企业级项目导航:⬅️ 01-用户服务分库分表设计思维全景 | 02-节目服务与支付服务分库分表设计思维全景 | ➡️ 03-从决策到落地:如何根据业务优雅设计库表