吃透分库分表:分片策略、跨库事务与平滑扩容全解

0 阅读22分钟

随着业务数据量的爆发式增长,单库单表的MySQL数据库很快会遇到性能瓶颈。当索引优化、SQL改写、缓存升级甚至硬件扩容都无法支撑业务的并发与存储需求时,分库分表就成为了数据库架构升级的核心方案。 分库分表的本质,是通过对数据的垂直与水平拆分,将原本集中在单库单表的压力分散到多个数据库实例与数据表中,从而突破单机的性能上限,提升系统的吞吐量、稳定性与可扩展性。

一、分库分表的核心拆分维度

分库分表分为两大拆分方向:垂直拆分与水平拆分,二者解决的痛点完全不同,必须先明确区分,避免混用。

1.1 垂直拆分

垂直拆分以字段/业务模块为拆分维度,分为垂直分库与垂直分表两类。

1.1.1 垂直分库

垂直分库是按照业务领域边界,将原本集中在一个库中的不同业务表,拆分到多个独立的数据库中,每个库对应一个独立的业务模块。 比如电商系统中,将用户相关表拆分到user_db,订单相关表拆分到order_db,商品相关表拆分到product_db,支付相关表拆分到pay_db,每个库部署在独立的服务器上。

  • 核心解决的问题:单库的连接数上限、IO带宽、CPU负载瓶颈,分散并发压力,实现业务的物理隔离,避免单个业务的故障影响全系统。
  • 底层逻辑:数据库的连接数、磁盘IO、CPU资源都是有限的,多个业务模块共用一个库,会出现资源争抢,垂直分库将资源隔离,每个业务独享数据库资源,提升整体性能。

1.1.2 垂直分表

垂直分表是按照字段的访问频率、数据大小,将一张大表拆分为多张结构不同的表,分为主表与扩展表,主表存储高频访问的小字段,扩展表存储低频访问的大字段。 比如用户表,主表user_main存储user_id、username、phone、status等高频访问字段,扩展表user_ext存储user_id、avatar、signature、address、ext_info等低频访问的大字段,二者通过user_id关联。

  • 核心解决的问题:单行数据过大导致的InnoDB B+树查询性能下降问题。
  • 底层逻辑:InnoDB的默认页大小为16KB,B+树的每个叶子节点对应一个数据页。单行数据的体积越大,单个数据页能存储的行数就越少,查询相同范围的数据需要的磁盘IO次数就越多,性能越差。垂直分表将高频小字段集中在主表,大幅降低单行数据体积,提升主表的查询性能。

1.2 水平拆分(分片)

水平拆分以行数据为拆分维度,按照指定的规则,将一张表的行数据拆分到多张结构完全相同的表中,这些表被称为分片表,拆分规则被称为分片策略。 如果拆分后的分片表分布在多个数据库中,就是分库+分表;如果都在同一个数据库中,就是单库分表。

  • 核心解决的问题:单表数据量过大导致的查询、写入性能衰减问题。
  • 权威阈值:InnoDB存储引擎中,当单表数据量超过1000万行时,B+树的层级会从3层增长到4层,每次查询需要多一次磁盘IO操作,性能会出现明显的线性衰减。当单表数据量超过5000万行时,绝大多数场景下的SQL优化都无法带来明显的性能提升,必须进行水平拆分。

二、核心分片策略

分片策略是水平拆分的核心,决定了数据如何分布到各个分片表中,直接影响分库分表后的查询性能、数据均匀性与扩容难度。 在选择分片策略之前,必须先确定分片键,分片键是用于拆分数据的字段,是分片策略的核心,分片键的选择直接决定了分库分表的成败。

2.1 分片键的选择黄金原则

  1. 高频查询覆盖原则:分片键必须覆盖业务中90%以上的查询场景,避免出现不带分片键的查询,导致全分片扫描,性能急剧下降。
  2. 数据均匀分布原则:分片键的取值必须尽可能均匀,避免出现数据倾斜,比如不要用订单状态、性别等枚举值作为分片键,否则会出现某个分片数据量远超其他分片的热点问题。
  3. 不可变更原则:分片键的值一旦写入,绝对不允许修改,否则会导致数据需要在不同分片之间迁移,引发分布式事务、数据不一致等一系列问题。

最常用的分片键:订单系统的order_id、user_id,用户系统的user_id,商品系统的product_id等全局唯一的主键字段。

2.2 主流分片策略详解

2.2.1 哈希取模分片

哈希取模分片是生产环境中最常用的分片策略,核心逻辑是对分片键的值进行哈希计算,再对分片总数取模,最终得到数据所在的分片序号。

  • 核心公式:分片序号 = hash(分片键) % 分片总数
  • 适用场景:用户、订单等核心交易表,查询场景固定,对数据均匀性要求高的业务。
  • 核心优势:实现简单,数据分布均匀,带分片键的查询性能稳定,只需一次路由即可定位到目标分片。
  • 核心劣势:扩容难度大,分片总数变更后,所有数据的分片序号都需要重新计算,全量数据迁移成本极高。

SQL实例:4分片订单表创建

CREATE TABLE IF NOT EXISTS `t_order_0` (
  `order_id` bigint NOT NULL COMMENT '订单ID',
  `user_id` bigint NOT NULL COMMENT '用户ID(分片键)',
  `order_amount` decimal(12,2) NOT NULL COMMENT '订单金额',
  `order_status` tinyint NOT NULL COMMENT '订单状态',
  `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
  `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
  PRIMARY KEY (`order_id`),
  KEY `idx_user_id` (`user_id`),
  KEY `idx_create_time` (`create_time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='订单分片表0';

CREATE TABLE IF NOT EXISTS `t_order_1` LIKE `t_order_0`;
CREATE TABLE IF NOT EXISTS `t_order_2` LIKE `t_order_0`;
CREATE TABLE IF NOT EXISTS `t_order_3` LIKE `t_order_0`;

Java实例:哈希取模分片算法实现

package com.jam.demo.sharding.algorithm;

import org.apache.shardingsphere.sharding.api.sharding.standard.PreciseShardingValue;
import org.apache.shardingsphere.sharding.api.sharding.standard.RangeShardingValue;
import org.apache.shardingsphere.sharding.api.sharding.standard.StandardShardingAlgorithm;
import org.springframework.util.ObjectUtils;

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

/**
 * 哈希取模分片算法
 * @author ken
 */
public final class HashModShardingAlgorithm implements StandardShardingAlgorithm<Long> {

    private static final String SHARDING_COUNT_KEY = "sharding-count";
    private int shardingCount;

    @Override
    public void init(Properties props) {
        if (!props.containsKey(SHARDING_COUNT_KEY)) {
            throw new IllegalArgumentException("分片数量配置不能为空");
        }
        this.shardingCount = Integer.parseInt(props.getProperty(SHARDING_COUNT_KEY));
    }

    /**
     * 精准分片路由(=、IN查询)
     * @param availableTargetNames 可用的分片表名集合
     * @param shardingValue 分片键值
     * @return 目标分片表名
     */
    @Override
    public String doSharding(Collection<String> availableTargetNames, PreciseShardingValue<Long> shardingValue) {
        Long shardingKey = shardingValue.getValue();
        if (ObjectUtils.isEmpty(shardingKey)) {
            throw new IllegalArgumentException("分片键值不能为空");
        }
        long mod = Math.abs(shardingKey.hashCode()) % shardingCount;
        String targetTable = shardingValue.getLogicTableName() + "_" + mod;
        if (availableTargetNames.contains(targetTable)) {
            return targetTable;
        }
        throw new IllegalStateException("未找到匹配的分片表:" + targetTable);
    }

    /**
     * 范围分片路由(BETWEEN、>、<查询)
     * @param availableTargetNames 可用的分片表名集合
     * @param shardingValue 分片键范围值
     * @return 目标分片表名集合
     */
    @Override
    public Collection<StringdoSharding(Collection<String> availableTargetNames, RangeShardingValue<Long> shardingValue) {
        return new LinkedHashSet<>(availableTargetNames);
    }

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

2.2.2 一致性哈希分片

一致性哈希分片是为了解决哈希取模分片扩容痛点设计的,核心逻辑是将哈希空间组织成一个0~2^32-1的环形结构,每个分片节点对应环上的一个固定位置,数据的哈希值落在环上的位置后,顺时针找到的第一个节点,就是该数据的存储分片。 为了解决数据倾斜问题,一致性哈希引入了虚拟节点机制,每个物理分片节点对应多个虚拟节点,虚拟节点均匀分布在哈希环上,大幅提升数据分布的均匀性。

  • 适用场景:需要频繁扩容、节点动态变化的分布式系统,对扩容灵活性要求高的业务。
  • 核心优势:扩容时仅需要迁移环上相邻节点的部分数据,数据迁移量极小,无需全量数据重算。
  • 核心劣势:实现复杂,范围查询需要全分片扫描,数据均匀性高度依赖虚拟节点的数量。

一致性哈希路由流程图

Java实例:带虚拟节点的一致性哈希实现

package com.jam.demo.sharding.algorithm;

import com.google.common.collect.Lists;
import org.springframework.util.ObjectUtils;

import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.List;
import java.util.SortedMap;
import java.util.TreeMap;

/**
 * 带虚拟节点的一致性哈希算法
 * @author ken
 */
public class ConsistentHashAlgorithm<T> {

    private static final int DEFAULT_VIRTUAL_NODE_COUNT = 16;
    private final int virtualNodeCount;
    private final SortedMap<Long, T> hashRing = new TreeMap<>();
    private final MessageDigest md5Digest;

    public ConsistentHashAlgorithm(List<T> realNodes) throws NoSuchAlgorithmException {
        this(realNodes, DEFAULT_VIRTUAL_NODE_COUNT);
    }

    public ConsistentHashAlgorithm(List<T> realNodes, int virtualNodeCount) throws NoSuchAlgorithmException {
        this.virtualNodeCount = virtualNodeCount;
        this.md5Digest = MessageDigest.getInstance("MD5");
        for (T node : realNodes) {
            addNode(node);
        }
    }

    /**
     * 添加节点到哈希环
     * @param node 真实节点
     */
    public void addNode(T node) {
        for (int i = 0; i < virtualNodeCount; i++) {
            String virtualNodeKey = node.toString() + "#VN" + i;
            long hash = hash(virtualNodeKey);
            hashRing.put(hash, node);
        }
    }

    /**
     * 从哈希环移除节点
     * @param node 真实节点
     */
    public void removeNode(T node) {
        for (int i = 0; i < virtualNodeCount; i++) {
            String virtualNodeKey = node.toString() + "#VN" + i;
            long hash = hash(virtualNodeKey);
            hashRing.remove(hash);
        }
    }

    /**
     * 获取数据对应的目标节点
     * @param key 分片键值
     * @return 目标节点
     */
    public T getTargetNode(String key) {
        if (ObjectUtils.isEmpty(key)) {
            throw new IllegalArgumentException("分片键值不能为空");
        }
        long hash = hash(key);
        SortedMap<Long, T> tailMap = hashRing.tailMap(hash);
        Long targetHash = tailMap.isEmpty() ? hashRing.firstKey() : tailMap.firstKey();
        return hashRing.get(targetHash);
    }

    /**
     * MD5哈希计算,生成32位哈希值
     * @param key 待哈希的字符串
     * @return 哈希值
     */
    private long hash(String key) {
        byte[] digest = md5Digest.digest(key.getBytes());
        return ((long) (digest[3] & 0xFF) << 24)
                | ((long) (digest[2] & 0xFF) << 16)
                | ((long) (digest[1] & 0xFF) << 8)
                | (digest[0] & 0xFF);
    }
}

2.2.3 范围分片

范围分片是按照分片键的数值范围、时间范围进行拆分,每个分片对应一个连续的范围区间。 常见的场景:按订单创建时间,每个月对应一个分片表;按用户ID,11000万对应0号分片,10012000万对应1号分片,以此类推。

  • 适用场景:日志、监控、统计等时序数据,或者需要按范围批量查询的业务。
  • 核心优势:范围查询效率极高,只需路由到对应范围的分片,无需全分片扫描;扩容简单,只需新增分片即可,无需迁移历史数据。
  • 核心劣势:极易出现数据热点,比如最新的时间分片的访问量远高于历史分片,导致数据库压力分布不均,数据倾斜严重。

SQL实例:时间范围分片表创建

CREATE TABLE IF NOT EXISTS `t_order_202601` (
  `order_id` bigint NOT NULL COMMENT '订单ID',
  `user_id` bigint NOT NULL COMMENT '用户ID',
  `order_amount` decimal(12,2) NOT NULL COMMENT '订单金额',
  `order_status` tinyint NOT NULL COMMENT '订单状态',
  `create_time` datetime NOT NULL COMMENT '创建时间(分片键)',
  `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
  PRIMARY KEY (`order_id`, `create_time`),
  KEY `idx_create_time` (`create_time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='2026年01月订单分片表';

CREATE TABLE IF NOT EXISTS `t_order_202602` LIKE `t_order_202601`;
CREATE TABLE IF NOT EXISTS `t_order_202603` LIKE `t_order_202601`;

2.2.4 枚举分片

枚举分片是按照分片键的固定枚举值进行拆分,每个枚举值对应一个固定的分片。 常见场景:按地区拆分,华东地区对应0号库,华北地区对应1号库;按订单类型拆分,普通订单对应0号表,虚拟订单对应1号表。

  • 适用场景:分片键的取值固定、数量有限的业务场景。
  • 核心优势:实现简单,查询路由精准,扩容灵活,可单独为某个枚举值新增分片。
  • 核心劣势:分片数量有限,极易出现数据倾斜,不适合分片键取值动态变化的场景。

2.2.5 复合分片

复合分片是结合多种分片策略的组合方案,最常用的是先分库、后分表的模式,比如先按user_id哈希取模分库,再按订单创建时间范围分表。 复合分片解决了单维度分片的痛点,既能保证数据均匀分布,又能支持范围查询,适合超大规模数据的业务场景。


三、跨库事务解决方案

分库分表后,一个业务操作往往需要操作多个数据库实例,单库的本地事务无法保证多个库操作的原子性,这就是分布式事务问题。 分布式事务的核心矛盾,是一致性、可用性、性能三者的平衡,根据业务对一致性的要求,分为强一致性方案与最终一致性方案两大类。

3.1 强一致性方案:XA两阶段提交(2PC)

XA是X/Open组织定义的分布式事务规范,基于两阶段提交(2PC)实现,是数据库层面的强一致性分布式事务方案。

3.1.1 核心原理

XA事务分为两个阶段:

  1. 准备阶段(Prepare) :事务管理器(TM)向所有参与事务的资源管理器(RM,即数据库)发送Prepare请求,每个RM执行本地事务操作,但不提交,同时锁定资源,向TM返回执行结果。
  2. 提交阶段(Commit) :如果所有RM都返回Prepare成功,TM向所有RM发送Commit请求,所有RM提交本地事务,释放资源,事务完成;如果有任意一个RM返回Prepare失败,TM向所有RM发送Rollback请求,所有RM回滚本地事务,释放资源。

MySQL 8.0的InnoDB存储引擎完整支持XA事务规范,保证了本地事务与XA事务的ACID特性。

  • 适用场景:核心交易场景,对数据一致性要求极高,并发量相对可控的场景,比如支付、转账。
  • 核心优势:强一致性,完全符合ACID特性,业务代码无侵入,实现简单。
  • 核心劣势:性能差,整个事务过程中资源被锁定,锁等待时间长,并发能力低;可用性差,事务管理器发生故障时,会导致资源长期锁定,出现阻塞。

Maven依赖配置

<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-jta-atomikos</artifactId>
        <version>3.2.4</version>
    </dependency>
    <dependency>
        <groupId>mysql</groupId>
        <artifactId>mysql-connector-j</artifactId>
        <version>8.3.0</version>
    </dependency>
    <dependency>
        <groupId>org.projectlombok</groupId>
        <artifactId>lombok</artifactId>
        <version>1.18.30</version>
        <scope>provided</scope>
    </dependency>
</dependencies>

Java实例:XA编程式事务实现

package com.jam.demo.transaction;

import com.atomikos.icatch.jta.UserTransactionImp;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.jta.JtaTransactionManager;
import org.springframework.transaction.support.TransactionTemplate;

import javax.transaction.UserTransaction;
import java.math.BigDecimal;

/**
 * XA分布式事务服务
 * @author ken
 */
@Slf4j
@Service
public class XaTransactionService {

    @Resource(name = "orderJdbcTemplate")
    private JdbcTemplate orderJdbcTemplate;

    @Resource(name = "payJdbcTemplate")
    private JdbcTemplate payJdbcTemplate;

    @Resource
    private JtaTransactionManager jtaTransactionManager;

    /**
     * 订单创建与支付记录插入的XA事务
     * @param orderId 订单ID
     * @param userId 用户ID
     * @param amount 订单金额
     */
    public void createOrderWithPay(Long orderId, Long userId, BigDecimal amount) {
        TransactionTemplate transactionTemplate = new TransactionTemplate(jtaTransactionManager);
        transactionTemplate.execute(status -> {
            try {
                String orderSql = "INSERT INTO t_order (order_id, user_id, order_amount, order_status) VALUES (?, ?, ?, 1)";
                orderJdbcTemplate.update(orderSql, orderId, userId, amount);

                String paySql = "INSERT INTO t_pay_record (pay_id, order_id, user_id, pay_amount, pay_status) VALUES (?, ?, ?, ?, 1)";
                payJdbcTemplate.update(paySql, generatePayId(), orderId, userId, amount);

                return Boolean.TRUE;
            } catch (Exception e) {
                status.setRollbackOnly();
                log.error("XA事务执行失败,已回滚", e);
                throw new RuntimeException("事务执行失败", e);
            }
        });
    }

    private Long generatePayId() {
        return System.currentTimeMillis() * 1000 + (long) (Math.random() * 1000);
    }
}

3.2 最终一致性方案:柔性事务

柔性事务放弃了强一致性,只保证数据的最终一致性,大幅提升了系统的并发能力与可用性,是高并发场景下的首选方案。

3.2.1 TCC事务

TCC(Try-Confirm-Cancel)是业务层的分布式事务方案,完全由业务代码实现,分为三个阶段:

  1. Try阶段:资源检查与预留,比如冻结用户账户的可用余额,锁定库存,完成所有业务前置检查。
  2. Confirm阶段:确认执行业务操作,使用Try阶段预留的资源,完成业务提交,该阶段必须保证幂等。
  3. Cancel阶段:取消业务操作,释放Try阶段预留的资源,回滚业务,该阶段必须保证幂等,同时处理空回滚、悬挂问题。
  • 适用场景:高并发核心交易场景,对性能与一致性都有较高要求的业务。
  • 核心优势:性能高,无长期资源锁定,并发能力强;可用性高,单个分支失败可通过补偿回滚,不会阻塞整个事务。
  • 核心劣势:对业务代码侵入性极强,开发成本高,需要处理幂等、空回滚、悬挂三大核心问题。

TCC三大核心问题解决方案

  1. 幂等性:通过事务ID+分支ID的唯一索引,保证每个操作只执行一次,避免重复提交。
  2. 空回滚:Cancel阶段先检查是否执行过Try阶段,如果没有执行过Try,直接返回成功,不执行回滚逻辑。
  3. 悬挂问题:Try阶段先检查是否已经执行过Cancel阶段,如果已经执行过Cancel,直接拒绝执行Try,避免后续Cancel无法回滚新预留的资源。

Java实例:TCC库存扣减实现

package com.jam.demo.tcc;

import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import lombok.extern.slf4j.Slf4j;
import org.apache.ibatis.annotations.Mapper;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.ObjectUtils;

import java.io.Serializable;
import java.time.LocalDateTime;

/**
 * TCC库存服务实现
 * @author ken
 */
@Slf4j
@Service
public class TccStockService {

    private final StockMapper stockMapper;
    private final StockTccLogMapper tccLogMapper;

    public TccStockService(StockMapper stockMapper, StockTccLogMapper tccLogMapper) {
        this.stockMapper = stockMapper;
        this.tccLogMapper = tccLogMapper;
    }

    /**
     * Try阶段:冻结库存
     * @param txId 全局事务ID
     * @param productId 商品ID
     * @param num 扣减数量
     * @return 执行结果
     */
    @Transactional(rollbackFor = Exception.class)
    public boolean tryFreezeStock(String txId, Long productId, Integer num) {
        StockTccLog existLog = tccLogMapper.selectById(txId);
        if (!ObjectUtils.isEmpty(existLog)) {
            return existLog.getStatus() != 2;
        }
        StockTccLog cancelLog = tccLogMapper.selectById(txId);
        if (!ObjectUtils.isEmpty(cancelLog) && cancelLog.getStatus() == 2) {
            throw new IllegalStateException("事务已回滚,拒绝执行Try操作");
        }
        int update = stockMapper.freezeStock(productId, num);
        if (update <= 0) {
            throw new RuntimeException("库存冻结失败,库存不足");
        }
        StockTccLog tccLog = new StockTccLog();
        tccLog.setTxId(txId);
        tccLog.setProductId(productId);
        tccLog.setNum(num);
        tccLog.setStatus(1);
        tccLog.setCreateTime(LocalDateTime.now());
        tccLogMapper.insert(tccLog);
        return true;
    }

    /**
     * Confirm阶段:确认扣减库存
     * @param txId 全局事务ID
     * @return 执行结果
     */
    @Transactional(rollbackFor = Exception.class)
    public boolean confirmDeductStock(String txId) {
        StockTccLog tccLog = tccLogMapper.selectById(txId);
        if (ObjectUtils.isEmpty(tccLog)) {
            return true;
        }
        if (tccLog.getStatus() == 9) {
            return true;
        }
        stockMapper.confirmDeduct(tccLog.getProductId(), tccLog.getNum());
        tccLog.setStatus(9);
        tccLogMapper.updateById(tccLog);
        return true;
    }

    /**
     * Cancel阶段:释放冻结库存
     * @param txId 全局事务ID
     * @return 执行结果
     */
    @Transactional(rollbackFor = Exception.class)
    public boolean cancelReleaseStock(String txId) {
        StockTccLog tccLog = tccLogMapper.selectById(txId);
        if (ObjectUtils.isEmpty(tccLog)) {
            StockTccLog cancelLog = new StockTccLog();
            cancelLog.setTxId(txId);
            cancelLog.setStatus(2);
            cancelLog.setCreateTime(LocalDateTime.now());
            tccLogMapper.insert(cancelLog);
            return true;
        }
        if (tccLog.getStatus() == 2 || tccLog.getStatus() == 9) {
            return true;
        }
        stockMapper.releaseFreeze(tccLog.getProductId(), tccLog.getNum());
        tccLog.setStatus(2);
        tccLogMapper.updateById(tccLog);
        return true;
    }

    @Data
    @TableName("t_stock")
    @Schema(description = "商品库存表")
    public static class Stock implements Serializable {
        @TableId(type = IdType.AUTO)
        @Schema(description = "主键ID")
        private Long id;
        @Schema(description = "商品ID")
        private Long productId;
        @Schema(description = "可用库存")
        private Integer availableStock;
        @Schema(description = "冻结库存")
        private Integer frozenStock;
        @Schema(description = "创建时间")
        private LocalDateTime createTime;
        @Schema(description = "更新时间")
        private LocalDateTime updateTime;
    }

    @Data
    @TableName("t_stock_tcc_log")
    @Schema(description = "库存TCC事务日志表")
    public static class StockTccLog implements Serializable {
        @TableId
        @Schema(description = "全局事务ID")
        private String txId;
        @Schema(description = "商品ID")
        private Long productId;
        @Schema(description = "操作数量")
        private Integer num;
        @Schema(description = "状态:1-try中,2-已回滚,9-已提交")
        private Integer status;
        @Schema(description = "创建时间")
        private LocalDateTime createTime;
    }

    @Mapper
    public interface StockMapper extends BaseMapper<Stock> {
        int freezeStock(Long productId, Integer num);
        int confirmDeduct(Long productId, Integer num);
        int releaseFreeze(Long productId, Integer num);
    }

    @Mapper
    public interface StockTccLogMapper extends BaseMapper<StockTccLog> {
    }
}

3.2.2 事务消息方案

事务消息是基于消息中间件实现的最终一致性方案,RocketMQ等主流消息队列都提供了事务消息功能,本质是将本地消息表封装到了MQ内部,对业务代码无侵入。 核心流程:

  1. 生产者发送半消息到MQ,此时消息对消费者不可见。
  2. 生产者执行本地事务。
  3. 本地事务执行成功,向MQ发送Commit请求,消息对消费者可见;执行失败,发送Rollback请求,MQ删除消息。
  4. 超时未收到确认请求,MQ触发事务回查,查询生产者本地事务的执行状态,根据回查结果决定提交或回滚消息。
  5. 消费者消费消息,执行本地事务,完成业务闭环。
  • 适用场景:高并发、非核心联动业务场景,比如订单创建后通知积分、物流、统计系统。
  • 核心优势:业务代码无侵入,性能高,可靠性强,无锁阻塞。
  • 核心劣势:只支持最终一致性,依赖消息中间件的可用性。

四、平滑扩容方案

随着业务数据量的增长,原有的分片数量很快会无法支撑业务需求,必须进行扩容。扩容的核心目标是:数据零丢失、业务无感知、服务无停机

4.1 双倍停机扩容法

双倍停机扩容法是最简单、最稳妥的扩容方案,核心逻辑是分片数量始终按2的倍数扩容,比如从4分片扩到8分片,8分片扩到16分片。

4.1.1 核心原理

原分片规则:分片序号 = hash(key) % N 新分片规则:分片序号 = hash(key) % 2N 对于原分片i中的数据,新的分片序号只会是i或者i+N,因此每个原分片只需要迁移i+N的数据到新分片,剩余数据保留在原分片,无需全量数据重算,数据迁移量减少50%。

4.1.2 扩容流程

  • 适用场景:中小规模系统,业务低峰期可接受短时间停机的场景。
  • 核心优势:实现简单,数据迁移量小,校验逻辑简单,风险可控。
  • 核心劣势:需要停机,业务有中断,不适合高可用要求极高的系统。

4.2 预分片+水平扩库(无停机最佳实践)

预分片+水平扩库是生产环境中首选的无停机扩容方案,核心逻辑是提前规划好足够的分片数量,将大量分片表预先分布在少量的数据库实例中,扩容时只需将部分分片表整表迁移到新的数据库实例,无需修改分片规则,无需迁移单条数据,对业务完全无感知。

4.2.1 核心原理

分片规则分为两层:

  1. 分片键到分表的映射:固定不变,比如提前分1024个分表,映射规则为表序号 = hash(user_id) % 1024,永远不变。
  2. 分表到数据库的映射:动态可调整,初始时1024个分表分布在4个库中,每个库256个表;扩容时,将部分分表从原库迁移到新库,只需修改分表到库的映射关系,业务代码完全无需修改。

4.2.2 扩容架构

4.2.3 扩容流程

  1. 提前预分片:业务上线前,根据未来3年的容量规划,创建足够的分表(推荐1024/2048个),分布在初始的数据库实例中。
  2. 扩容准备:当数据库实例的CPU、IO、连接数达到阈值时,准备新的数据库实例,创建与原库完全一致的分表结构。
  3. 数据同步:通过MySQL主从同步,将需要迁移的分表从原库同步到新库,全量同步完成后,追平增量binlog,保证主从数据完全一致。
  4. 配置切换:业务低峰期,修改Sharding-JDBC的分片配置,将迁移的分表的数据源切换到新库,灰度发布到部分服务节点。
  5. 业务验证:验证灰度节点的读写业务正常,无报错,数据写入正确,逐步全量发布配置。
  6. 收尾清理:确认全量业务正常后,断开主从同步,删除原库中已迁移的分表,完成扩容。
  • 适用场景:中大规模高并发系统,对可用性要求极高,不允许停机的生产环境。
  • 核心优势:完全无停机,业务无感知,数据迁移风险极低,扩容灵活,无需修改分片规则与业务代码。
  • 核心劣势:需要提前做好容量规划,对架构设计有一定要求。

五、分库分表避坑指南

5.1 分布式ID生成

分库分表后,单表的自增主键无法保证全局唯一,必须使用分布式ID生成方案,推荐使用雪花算法(Snowflake),64位结构如下:

  • 1位:符号位,固定为0
  • 41位:时间戳,精确到毫秒,可使用69年
  • 10位:机器ID,支持1024个节点
  • 12位:序列号,每毫秒支持4096个ID

Java实例:雪花算法实现

package com.jam.demo.common;

import org.springframework.util.ObjectUtils;

/**
 * 雪花算法分布式ID生成器
 * @author ken
 */
public class SnowflakeIdGenerator {

    private static final long EPOCH = 1704067200000L;
    private static final long WORKER_ID_BITS = 10L;
    private static final long SEQUENCE_BITS = 12L;
    private static final long MAX_WORKER_ID = (1L << WORKER_ID_BITS) - 1;
    private static final long WORKER_ID_SHIFT = SEQUENCE_BITS;
    private static final long TIMESTAMP_SHIFT = SEQUENCE_BITS + WORKER_ID_BITS;
    private static final long SEQUENCE_MASK = (1L << SEQUENCE_BITS) - 1;

    private final long workerId;
    private long lastTimestamp = -1L;
    private long sequence = 0L;

    public SnowflakeIdGenerator(long workerId) {
        if (workerId < 0 || workerId > MAX_WORKER_ID) {
            throw new IllegalArgumentException("机器ID超出范围");
        }
        this.workerId = workerId;
    }

    /**
     * 生成下一个分布式ID
     * @return 全局唯一ID
     */
    public synchronized long nextId() {
        long currentTimestamp = System.currentTimeMillis();
        if (currentTimestamp < lastTimestamp) {
            throw new RuntimeException("时钟回拨,无法生成ID");
        }
        if (currentTimestamp == lastTimestamp) {
            sequence = (sequence + 1) & SEQUENCE_MASK;
            if (sequence == 0) {
                currentTimestamp = waitNextMillis(lastTimestamp);
            }
        } else {
            sequence = 0L;
        }
        lastTimestamp = currentTimestamp;
        return ((currentTimestamp - EPOCH) << TIMESTAMP_SHIFT)
                | (workerId << WORKER_ID_SHIFT)
                | sequence;
    }

    private long waitNextMillis(long lastTimestamp) {
        long timestamp = System.currentTimeMillis();
        while (timestamp <= lastTimestamp) {
            timestamp = System.currentTimeMillis();
        }
        return timestamp;
    }
}

5.2 常见坑点与解决方案

  1. 跨分片JOIN:避免使用跨分片JOIN,解决方案:字段冗余、数据同步到宽表、使用Elasticsearch等搜索引擎做关联查询。
  2. 全分片扫描:所有查询必须带上分片键,避免不带分片键的查询导致全分片扫描,性能急剧下降。
  3. 数据倾斜:定期监控各分片的数据量与访问量,及时调整分片策略,避免热点分片。
  4. 深分页问题:避免使用limit offset, size的深分页查询,解决方案:游标分页,带上上一页的最大主键,where id > lastId limit size
  5. 过度拆分:分库分表会大幅提升系统复杂度,优先优化SQL、索引、缓存,只有当这些优化都无法解决问题时,才考虑分库分表。

分库分表是数据库架构升级的重要手段,它能有效突破单库单表的性能瓶颈,但同时也带来了分布式系统的复杂度。 在实际生产中,我们需要根据业务的实际场景,合理选择分片策略、事务方案与扩容方案,平衡一致性、可用性与性能三者的关系,同时做好提前规划与风险控制,才能让分库分表真正发挥价值,支撑业务的长期增长。