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

简介: 本文系统讲解MySQL分库分表核心实践:涵盖垂直/水平拆分原理、哈希取模/一致性哈希/范围/枚举/复合五大分片策略、XA强一致与TCC/事务消息等最终一致性方案、双倍停机与预分片无停机扩容,以及分布式ID、避坑指南等关键要点。

随着业务数据量的爆发式增长,单库单表的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<String> doSharding(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,1~1000万对应0号分片,1001~2000万对应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、索引、缓存,只有当这些优化都无法解决问题时,才考虑分库分表。

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

目录
相关文章
|
3月前
|
存储 算法 关系型数据库
吃透分布式 ID:雪花算法、号段模式的底层逻辑与全场景架构避坑
本文深度解析分布式ID两大主流方案——雪花算法与号段模式,涵盖核心设计准则(唯一性、趋势递增、高性能等)、底层原理、代码实现、6大生产避坑指南及场景化选型建议,助你构建稳定可靠的分布式ID服务。
627 4
|
4月前
|
SQL 关系型数据库 MySQL
分库分表下的分页查询:底层逻辑、全场景坑点与生产级最优解
分库分表环境下分页查询的挑战与解决方案 在分库分表架构中,传统分页查询面临数据错乱、性能下降等核心问题。本文剖析了五种主流解决方案: 全局视野法:全量查询后归并排序,保证准确性但性能随分页深度下降 游标分页法:基于值定位,性能稳定但仅支持顺序翻页 分片键路由法:精准定位分片,性能最优但需携带分片键 ES索引法:支持复杂查询和跳页,但引入额外组件 范围分片优化:减少扫描分片数,仅适用于范围分片场景 生产实践需注意排序字段唯一性、深分页限制、分片键选择等关键点。
618 2
|
2月前
|
SQL 算法 关系型数据库
击穿 InnoDB 事务隔离级别:RC 与 RR 的底层实现、锁机制、MVCC 与幻读终极拆解
本文深入剖析InnoDB事务隔离原理,聚焦RC(读已提交)与RR(可重复读)的核心差异:从锁机制(记录锁/间隙锁/临键锁)、MVCC可见性规则(Read View生成时机)到幻读解决方案。结合可复现实例与Java实战,助你彻底理解底层逻辑,规避90%的数据不一致与死锁问题。
325 3
|
3月前
|
存储 Java 中间件
分布式协调双雄深度拆解:ZooKeeper 与 Nacos 从底层原理到生产实战全指南
本文深度解析ZooKeeper与Nacos两大分布式协调中间件:ZooKeeper专注强一致协调,基于ZAB协议与ZNode模型,适用于大数据生态;Nacos则提供AP/CP双模、三层数据隔离及长轮询机制,是云原生下配置中心+服务发现的一站式选择。二者核心能力、架构差异与选型建议全面对比,附生产实践与避坑指南。
1217 6
|
3月前
|
存储 SQL 关系型数据库
MySQL 索引底层彻底吃透:B + 树原理、聚簇索引机制与全场景优化指南
本文深入剖析MySQL InnoDB索引底层原理:从B+树为何成为最优选,到聚簇/二级索引机制、回表与覆盖索引;详解最左前缀、索引失效10大场景及根因;并给出分页优化、联合索引设计、ICP等生产级实战方案,助你真正知其所以然。
472 2
|
3月前
|
SQL 关系型数据库 Java
吃透 Seata 分布式事务:原理拆解 + 生产级落地 + 全场景避坑实战
本文深度解析阿里开源分布式事务框架Seata:剖析TC/TM/RM三大角色与全局事务流程,详解AT(零侵入)、TCC(强控制)、SAGA(长事务)、XA(强一致)四大模式原理、适用场景及核心对比,并通过电商下单实战演示AT模式落地,最后系统梳理生产环境高可用、SQL限制、幂等处理、XID传播等全链路避坑指南。
984 4
|
1月前
|
缓存 Java 关系型数据库
90% Java 开发都踩过坑的 @Resource 与 @Autowired
本文深度解析Spring中`@Resource`与`@Autowired`的核心差异:前者属Java官方JSR-250规范(JDK8为`javax.annotation.Resource`,JDK11+为`jakarta.annotation.Resource`),默认按名注入、兼容多容器;后者为Spring原生注解,默认按类型注入、强耦合Spring生态。详述两者在注入逻辑、查找顺序、容错机制、构造器支持及源码执行优先级等维度的全量对比,并梳理高频踩坑场景与选型建议。
336 1
|
3月前
|
SQL 关系型数据库 MySQL
击穿 MySQL 事务隔离级别:底层实现原理 + 生产级架构选型避坑指南
本文深度解析MySQL事务隔离级别,从SQL标准定义出发,结合InnoDB底层的MVCC、undo log、Read View与锁机制,详解脏读、不可重复读、幻读三大异常及4种隔离级别的实现差异,辅以可复现SQL示例与Spring Boot实战代码,提供生产环境选型指南与避坑方案。
274 1
|
3月前
|
缓存 NoSQL 算法
扛住亿级流量的核心防线:限流、熔断、降级全链路深度拆解与实战
本文系统讲解分布式系统亿级流量治理,涵盖限流(固定/滑动窗口、漏桶、令牌桶及Redis分布式实现)、熔断(状态机、Resilience4j实战)与降级(功能开关、读/写降级)三大核心能力,并提供全链路分层架构、生产避坑指南与最佳实践,助力系统稳定扛压。
567 2
|
3月前
|
安全 Java 关系型数据库
分布式权限体系破局:统一认证授权与 OAuth2.0 全链路架构落地实战
本文系统阐述分布式架构下基于OAuth2.0的统一认证授权体系:剖析微服务权限痛点,厘清认证与授权本质区别;详解OAuth2.0四大角色、授权码等安全模式及JWT等易混淆概念;设计分层架构与RBAC权限模型;提供Spring Authorization Server实战搭建(含数据库、配置、代码)及全流程调用示例;并给出生产环境令牌安全、客户端管控与审计加固等最佳实践。
564 1