别再乱分库分表了!MySQL 核心选型、拆分规则与生产级痛点全拆解

0 阅读33分钟

一、分库分表的本质与适用边界

MySQL作为互联网行业最主流的关系型数据库,在单库单表场景下,性能会随着数据量和并发量的增长出现明显瓶颈。InnoDB存储引擎以B+树作为索引结构,当单表数据量超过千万级时,索引层级会从3层增长到4层,每次查询都会增加一次磁盘IO,查询性能会出现断崖式下降;当单库TPS超过5000、QPS超过2万时,数据库的CPU、磁盘IO、连接数都会达到瓶颈,无法支撑更高的并发请求。

分库分表的本质是数据分片,通过一定的规则将一个大的数据集拆分成多个小的数据集,分散到多个数据库或多个数据表中,降低单节点的存储压力和并发负载,提升数据库的整体性能和扩展能力。

但分库分表不是银弹,它会引入极高的架构复杂度,带来分布式事务、跨库查询、数据一致性、运维成本等一系列问题。在决定使用分库分表之前,必须先明确其适用边界:

  • 优先不使用的场景:单表数据量在千万级以内、并发压力未达到数据库瓶颈,通过索引优化、SQL优化、读写分离、缓存方案就能解决性能问题的场景,绝对不要为了分而分。
  • 必须使用的场景:单表数据量预计超过5000万、单库磁盘存储超过2TB、TPS/QPS持续增长超过单库承载上限,且其他优化方案无法解决性能瓶颈的场景。

二、分库分表核心方案选型与对比

目前生产环境主流的分库分表方案分为三大类:客户端分片、服务端代理分片、云原生分布式数据库,三类方案各有优劣,选型的核心是匹配业务的架构、技术栈、并发规模和运维能力。

2.1 三类核心方案的底层逻辑

客户端分片方案

客户端分片方案的核心是将分片逻辑嵌入到应用的JDBC层,在应用内部完成SQL解析、路由、执行和结果归并,对应用层透明,无额外的网络转发开销。目前最主流的实现是Apache ShardingSphere-JDBC,是Java生态下的事实标准。

服务端代理分片方案

服务端代理分片方案的核心是部署独立的代理服务,兼容MySQL原生协议,应用层将代理服务当作一个完整的MySQL实例使用,所有的分片逻辑都在代理层完成,对应用完全透明,支持多语言接入。主流实现包括Apache ShardingSphere-Proxy、MyCat。

云原生分布式数据库方案

云原生分布式数据库是云厂商提供的托管式分布式数据库服务,底层自动完成数据分片、读写分离、高可用、容灾备份,对应用几乎完全透明,无需关注分片逻辑,开箱即用。主流实现包括阿里云DRDS、腾讯云TDSQL、OceanBase、TiDB。

2.2 方案对比与选型标准

方案类型核心优势核心劣势适用场景
客户端分片(ShardingSphere-JDBC)无额外网络开销、性能损耗极低、Java生态适配完善、无需额外部署节点、运维成本低仅支持Java语言、分片规则分散在各个应用、升级维护成本高Java微服务架构、中小规模集群、对性能要求极高的业务
服务端代理分片(ShardingSphere-Proxy)支持多语言接入、分片规则集中管理、运维统一、对应用完全透明有额外的网络转发开销、性能有一定损耗、需要保证代理节点的高可用多语言技术栈、大规模集群、需要统一管控分片规则的业务
云原生分布式数据库完全托管、免运维、自动扩缩容、强一致性支持完善、企业级特性丰富成本高、有厂商绑定风险、定制化能力弱不想投入大量精力维护分片架构、企业级核心业务、快速上线的业务

生产环境选型的核心原则:Java技术栈优先选择ShardingSphere-JDBC,多语言架构优先选择ShardingSphere-Proxy,无专职DBA团队的企业优先选择云原生分布式数据库。

三、垂直拆分:场景、规则与最佳实践

分库分表分为垂直拆分和水平拆分两大类,垂直拆分是按照业务维度或字段维度进行拆分,核心是解决单库单表的字段耦合和并发压力问题,分为垂直分库和垂直分表两种形式。

3.1 垂直分库:按业务域拆分

垂直分库的核心逻辑是按照业务模块/限界上下文,将一个单体库中的不同业务表,拆分到多个独立的数据库中,每个数据库对应一个独立的业务域,实现业务解耦和资源隔离。

适用场景

  • 业务模块边界清晰,耦合度低,符合DDD限界上下文设计;
  • 单库并发压力大,不同业务模块的访问频率、数据量差异极大;
  • 需要对核心业务进行单独的资源隔离、性能优化和扩容。

实战示例

以电商系统为例,单体库中包含用户、订单、商品、支付等所有业务表,通过垂直分库拆分为4个独立的业务库,每个库对应一个微服务,实现业务解耦和资源隔离。

-- 用户库:存储用户相关数据,对应用户微服务
CREATE DATABASE IF NOT EXISTS user_db DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;
USE user_db;
CREATE TABLE IF NOT EXISTS user_info (
  user_id BIGINT NOT NULL COMMENT '用户ID',
  username VARCHAR(64NOT NULL COMMENT '用户名',
  phone VARCHAR(11NOT NULL COMMENT '手机号',
  status TINYINT NOT NULL DEFAULT 1 COMMENT '状态:1-正常,0-禁用',
  create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
  update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
  PRIMARY KEY (user_id),
  UNIQUE KEY uk_username (username),
  UNIQUE KEY uk_phone (phone)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='用户主表';

-- 订单库:存储订单相关数据,对应订单微服务
CREATE DATABASE IF NOT EXISTS order_db DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;
USE order_db;
CREATE TABLE IF NOT EXISTS order_info (
  order_id BIGINT NOT NULL COMMENT '订单ID',
  user_id BIGINT NOT NULL COMMENT '用户ID',
  order_amount DECIMAL(12,2NOT NULL COMMENT '订单金额',
  order_status TINYINT NOT NULL DEFAULT 0 COMMENT '订单状态:0-待支付,1-已支付,2-已取消',
  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='订单主表';

-- 商品库:存储商品相关数据,对应商品微服务
CREATE DATABASE IF NOT EXISTS product_db DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;
USE product_db;
CREATE TABLE IF NOT EXISTS product_info (
  product_id BIGINT NOT NULL COMMENT '商品ID',
  product_name VARCHAR(128NOT NULL COMMENT '商品名称',
  price DECIMAL(12,2NOT NULL COMMENT '商品价格',
  stock INT NOT NULL DEFAULT 0 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 (product_id),
  KEY idx_product_name (product_name)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='商品主表';

最佳实践与避坑

  1. 拆分粒度必须匹配业务域,严格按照DDD限界上下文拆分,避免过度拆分导致跨库操作频繁;
  2. 拆分后必须避免跨库JOIN,核心解决方案包括:字段冗余、全局表(字典表)、服务接口调用、数据同步到ES统一查询;
  3. 核心业务库必须单独部署服务器,实现物理资源隔离,避免非核心业务影响核心业务的稳定性;
  4. 垂直分库必须配合微服务架构,每个库只能由对应的微服务访问,禁止多个服务直接访问同一个库。

3.2 垂直分表:按字段访问频率拆分

垂直分表的核心逻辑是按照字段的访问频率、数据长度,将一个大表拆分为多个一对一关联的小表,将热数据(高频访问、小字段)和冷数据(低频访问、大字段)分离,提升热数据的查询性能。

InnoDB存储引擎以页为单位存储数据,默认页大小为16KB,一行数据的长度越大,一个页能存储的行数就越少,磁盘IO的效率就越低。如果表中包含TEXT、BLOB等大字段,会导致行溢出,需要额外的溢出页存储,查询性能会大幅下降。垂直分表就是为了解决这个问题。

适用场景

  • 单表字段数量过多,超过20个,且字段的访问频率差异极大;
  • 表中包含TEXT、BLOB、VARCHAR(1000+)等大字段,且这些字段的访问频率极低;
  • 热数据只占表字段的20%,但承担了80%的查询请求。

实战示例

以用户表为例,将高频访问的核心字段和低频访问的扩展字段拆分为两个表,实现冷热数据分离。

-- 用户主表:存储高频访问的热数据,核心查询只访问此表
CREATE TABLE IF NOT EXISTS user_main (
  user_id BIGINT NOT NULL COMMENT '用户ID',
  username VARCHAR(64NOT NULL COMMENT '用户名',
  phone VARCHAR(11NOT NULL COMMENT '手机号',
  status TINYINT NOT NULL DEFAULT 1 COMMENT '状态:1-正常,0-禁用',
  create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
  update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
  PRIMARY KEY (user_id),
  UNIQUE KEY uk_username (username),
  UNIQUE KEY uk_phone (phone)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='用户主表-热数据';

-- 用户扩展表:存储低频访问的冷数据,仅在需要时查询
CREATE TABLE IF NOT EXISTS user_extend (
  user_id BIGINT NOT NULL COMMENT '用户ID',
  avatar VARCHAR(256DEFAULT NULL COMMENT '头像地址',
  signature VARCHAR(512DEFAULT NULL COMMENT '个性签名',
  address TEXT DEFAULT 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 (user_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='用户扩展表-冷数据';

最佳实践与避坑

  1. 拆分后的多个表必须使用相同的主键,保证一对一关联,禁止使用联合主键;
  2. 热数据必须全部放在主表,冷数据放在扩展表,核心查询只访问主表,避免不必要的JOIN;
  3. TEXT、BLOB等大字段必须拆分到扩展表,绝对不能出现在高频查询的主表中;
  4. 拆分粒度要合理,避免拆分为过多的小表,导致查询时需要多次JOIN,反而降低性能。

四、水平拆分:核心逻辑、分片规则与分片键选型

水平拆分是按照行维度进行拆分,将同一个表中的数据,按照一定的分片规则,分散到多个结构完全相同的表或库中,核心是解决单表数据量过大、单库并发压力过高的问题,是分库分表的核心场景,分为水平分表和水平分库两种形式。

4.1 水平分表与水平分库的核心区别

  • 水平分表:在同一个数据库内,将一个大表拆分为多个结构完全相同的子表,仅解决单表数据量过大的问题,无法解决单库的并发压力和存储瓶颈;
  • 水平分库:将拆分后的子表分散到多个独立的数据库中,既解决了单表数据量的问题,也解决了单库的并发和存储瓶颈,是大规模高并发场景的核心方案。

4.2 核心分片算法与适用场景

水平拆分的核心是分片算法,决定了数据如何分配到不同的分片表中,生产环境主流的分片算法有4种,各有优劣,必须匹配业务的查询模式。

1. 取模分片(哈希分片)

取模分片是最常用的分片算法,核心逻辑是使用分片键的值对分片数量进行取模,根据取模结果将数据路由到对应的分片表中,公式为:分片表序号 = 分片键值 % 分片数量

适用场景
  • 数据量均匀增长,无明显的热点数据;
  • 80%以上的查询都携带分片键,比如用户查询自己的订单,分片键为user_id;
  • 对查询性能要求高,需要稳定的路由性能。
优势与劣势
  • 优势:数据分布均匀,查询路由精准,性能稳定,实现简单;
  • 劣势:扩容难度大,分片数量变更时需要全量数据迁移,范围查询支持差。

2. 范围分片

范围分片的核心逻辑是按照分片键的数值范围进行分片,比如按照时间范围、ID范围进行拆分,常见的实现包括按月分片、按年分片、按ID区间分片。

适用场景
  • 时序类数据,比如订单、日志、监控数据,查询经常按照时间范围进行统计;
  • 需要频繁扩容,仅需新增分片即可,无需迁移历史数据;
  • 业务需要定期归档或删除历史数据,直接删除整个分片表即可。
优势与劣势
  • 优势:扩容简单,无需迁移历史数据,范围查询支持友好,便于数据归档;
  • 劣势:容易出现数据倾斜和热点分片,比如最新月份的分片承担了90%的读写请求。

3. 一致性哈希分片

一致性哈希分片的核心逻辑是将分片键映射到一个0-2^32的哈希环上,同时将分片节点也映射到哈希环上,数据路由到顺时针方向最近的分片节点。当新增或删除分片节点时,仅需要迁移环上相邻节点的少量数据,解决了取模分片扩容难的问题。

适用场景
  • 数据量持续高速增长,需要频繁扩容;
  • 分片数量不固定,需要动态调整;
  • 不能接受扩容时的全量数据迁移。
优势与劣势
  • 优势:扩容成本极低,仅需迁移少量数据,数据分布相对均匀;
  • 劣势:实现复杂,范围查询支持差,需要处理哈希环倾斜问题。

4. 业务前缀分片

业务前缀分片的核心逻辑是按照分片键的业务前缀进行分片,比如订单号的前两位是地区编码,按照地区编码进行分片,分片规则完全匹配业务逻辑。

适用场景
  • 分片键包含明确的业务含义,需要按照业务维度进行数据隔离;
  • 运维需要按照业务维度单独管理分片,比如不同地区的数据部署在不同的服务器;
  • 查询经常按照业务前缀进行筛选。
优势与劣势
  • 优势:符合业务逻辑,便于数据隔离和运维,路由精准;
  • 劣势:容易出现数据倾斜,热门业务的分片压力过大。

4.3 分片键选型的黄金法则

分片键是水平拆分的核心,80%的分库分表故障都是因为分片键选型错误导致的,生产环境分片键选型必须严格遵循以下5条黄金法则:

  1. 高频查询匹配原则:分片键必须是业务中80%以上的查询都会携带的字段,否则会导致全分片扫描,查询性能会出现断崖式下降;
  2. 数据均匀分布原则:分片键必须保证数据在各个分片之间均匀分布,避免出现数据倾斜,绝对不能使用性别、状态等枚举值极少的字段作为分片键;
  3. 不可变原则:分片键的值绝对不能更新,否则会导致数据在不同分片之间迁移,带来极高的性能开销和数据一致性风险;
  4. 业务绑定原则:分片键必须和核心业务场景强绑定,比如订单表的核心场景是用户查询自己的订单,优先选择user_id作为分片键,而不是order_id;
  5. 高基数原则:分片键必须有足够多的不同取值,保证数据能够均匀分散到各个分片,基数越高,数据分布越均匀。

4.4 水平拆分实战示例

以电商订单表为例,使用ShardingSphere-JDBC实现水平分表,分片键为user_id,采用取模分片算法,拆分为4个分片表。

1. 分片表建表语句

CREATE TABLE IF NOT EXISTS order_info_0 (
  order_id BIGINT NOT NULL COMMENT '订单ID',
  user_id BIGINT NOT NULL COMMENT '用户ID',
  order_amount DECIMAL(12,2NOT NULL COMMENT '订单金额',
  order_status TINYINT NOT NULL DEFAULT 0 COMMENT '订单状态:0-待支付,1-已支付,2-已取消',
  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 order_info_1 (
  order_id BIGINT NOT NULL COMMENT '订单ID',
  user_id BIGINT NOT NULL COMMENT '用户ID',
  order_amount DECIMAL(12,2NOT NULL COMMENT '订单金额',
  order_status TINYINT NOT NULL DEFAULT 0 COMMENT '订单状态:0-待支付,1-已支付,2-已取消',
  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='订单表1';

CREATE TABLE IF NOT EXISTS order_info_2 (
  order_id BIGINT NOT NULL COMMENT '订单ID',
  user_id BIGINT NOT NULL COMMENT '用户ID',
  order_amount DECIMAL(12,2NOT NULL COMMENT '订单金额',
  order_status TINYINT NOT NULL DEFAULT 0 COMMENT '订单状态:0-待支付,1-已支付,2-已取消',
  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='订单表2';

CREATE TABLE IF NOT EXISTS order_info_3 (
  order_id BIGINT NOT NULL COMMENT '订单ID',
  user_id BIGINT NOT NULL COMMENT '用户ID',
  order_amount DECIMAL(12,2NOT NULL COMMENT '订单金额',
  order_status TINYINT NOT NULL DEFAULT 0 COMMENT '订单状态:0-待支付,1-已支付,2-已取消',
  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='订单表3';

2. ShardingSphere-JDBC配置

spring:
  shardingsphere:
    datasource:
      names: order-ds
      order-ds:
        type: com.zaxxer.hikari.HikariDataSource
        driver-class-name: com.mysql.cj.jdbc.Driver
        jdbc-urljdbc:mysql://localhost:3306/order_db?useUnicode=true&characterEncoding=utf8mb4&serverTimezone=Asia/Shanghai&useSSL=false
        username: root
        password: root
        hikari:
          minimum-idle5
          maximum-pool-size20
          auto-commit: true
          idle-timeout30000
          pool-name: OrderHikariCP
          max-lifetime1800000
          connection-timeout30000
    rules:
      sharding:
        tables:
          order_info:
            actual-data-nodes: order-ds.order_info_$->{0..3}
            table-strategy:
              standard:
                sharding-column: user_id
                sharding-algorithm-name: order-inline
        sharding-algorithms:
          order-inline:
            type: INLINE
            props:
              algorithm-expression: order_info_$->{user_id % 4}
    props:
      sql-show: true

五、生产级核心难题一:全局唯一ID生成方案

分库分表之后,单表的自增主键无法保证全局唯一,会出现不同分片表的主键重复问题,必须使用分布式ID生成方案,保证全局唯一。生产环境的分布式ID必须满足5个核心要求:全局唯一性、趋势递增性、高性能、高可用、安全性。

5.1 主流分布式ID方案对比

方案类型全局唯一趋势递增高性能高可用安全性核心劣势
雪花算法(Snowflake)依赖系统时钟,存在时钟回拨风险
号段模式(Segment)ID连续递增,容易泄露业务数据量
数据库自增序列高并发下有锁竞争,单库存在瓶颈
UUID/GUID无序,字符串类型,占用空间大,导致InnoDB页分裂

生产环境绝对禁止使用UUID作为主键,InnoDB的聚簇索引要求主键有序,无序的UUID会导致插入时出现大量的页分裂,产生随机IO,插入性能暴跌,同时字符串类型的UUID占用空间是Long型的2倍以上,会导致索引体积大幅增大,查询性能下降。

5.2 雪花算法生产级实现

雪花算法是Twitter开源的分布式ID生成算法,生成64位的Long型ID,结构如下:

  • 1位符号位:固定为0,保证ID为正数;
  • 41位时间戳:以自定义纪元为起点,毫秒级时间戳,可使用约69年;
  • 10位机器ID:最多支持1024个节点,保证分布式环境下不同节点的ID不重复;
  • 12位序列号:每毫秒内从0开始递增,最多支持4096个ID,保证同一毫秒内的ID不重复。

生产级实现必须解决时钟回拨问题,当系统时钟出现回拨时,会导致生成重复的ID,带来主键冲突的严重故障。以下是完整的生产级实现:

package com.jam.demo.id;

import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;

/**
 * 雪花算法ID生成器
 * @author ken
 */
@Slf4j
@Component
public class SnowflakeIdGenerator {
    private static final long EPOCH = 1577836800000L;
    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 MAX_SEQUENCE = (1L << SEQUENCE_BITS) - 1;
    private static final long WORKER_ID_SHIFT = SEQUENCE_BITS;
    private static final long TIMESTAMP_SHIFT = SEQUENCE_BITS + WORKER_ID_BITS;

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

    public SnowflakeIdGenerator(long workerId) {
        if (workerId < 0 || workerId > MAX_WORKER_ID) {
            throw new IllegalArgumentException("workerId must be between 0 and " + MAX_WORKER_ID);
        }
        this.workerId = workerId;
    }

    public SnowflakeIdGenerator() {
        this.workerId = 0L;
    }

    /**
     * 生成全局唯一ID
     * @return 64位Long型ID
     */
    public synchronized long nextId() {
        long currentTimestamp = getCurrentTimestamp();

        if (currentTimestamp < lastTimestamp) {
            long offset = lastTimestamp - currentTimestamp;
            if (offset <= 5) {
                try {
                    wait(offset << 1);
                    currentTimestamp = getCurrentTimestamp();
                    if (currentTimestamp < lastTimestamp) {
                        throw new RuntimeException("Clock moved backwards, unable to generate id for " + offset + " milliseconds");
                    }
                } catch (InterruptedException e) {
                    Thread.currentThread().interrupt();
                    throw new RuntimeException("Thread interrupted while waiting for clock to catch up", e);
                }
            } else {
                throw new RuntimeException("Clock moved backwards, unable to generate id for " + offset + " milliseconds");
            }
        }

        if (currentTimestamp == lastTimestamp) {
            sequence = (sequence + 1) & MAX_SEQUENCE;
            if (sequence == 0) {
                currentTimestamp = waitNextMillis(lastTimestamp);
            }
        } else {
            sequence = 0L;
        }

        lastTimestamp = currentTimestamp;

        return ((currentTimestamp - EPOCH) << TIMESTAMP_SHIFT)
                | (workerId << WORKER_ID_SHIFT)
                | sequence;
    }

    private long getCurrentTimestamp() {
        return System.currentTimeMillis();
    }

    private long waitNextMillis(long lastTimestamp) {
        long timestamp = getCurrentTimestamp();
        while (timestamp <= lastTimestamp) {
            timestamp = getCurrentTimestamp();
        }
        return timestamp;
    }
}

5.3 号段模式生产级实现

号段模式的核心逻辑是从数据库批量获取一段ID区间,缓存在本地,用完之后再去数据库获取新的号段,完全不依赖系统时钟,不存在时钟回拨问题,性能极高,是大规模分布式系统的首选方案。

1. 号段表建表语句

CREATE TABLE IF NOT EXISTS id_segment (
  id BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID',
  business_type VARCHAR(64NOT NULL COMMENT '业务类型',
  current_max_id BIGINT NOT NULL DEFAULT 0 COMMENT '当前最大ID',
  step INT NOT NULL DEFAULT 1000 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 (id),
  UNIQUE KEY uk_business_type (business_type)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='号段表';

INSERT INTO id_segment (business_type, current_max_id, step) VALUES ('order'01000ON DUPLICATE KEY UPDATE update_time = CURRENT_TIMESTAMP;

2. 实体类与Mapper

package com.jam.demo.entity;

import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;

import java.time.LocalDateTime;

/**
 * 号段实体
 * @author ken
 */
@Data
@TableName("id_segment")
@Schema(description = "号段实体")
public class IdSegment {
    @TableId(type = IdType.AUTO)
    @Schema(description = "主键ID")
    private Long id;

    @Schema(description = "业务类型")
    private String businessType;

    @Schema(description = "当前最大ID")
    private Long currentMaxId;

    @Schema(description = "号段步长")
    private Integer step;

    @Schema(description = "创建时间")
    private LocalDateTime createTime;

    @Schema(description = "更新时间")
    private LocalDateTime updateTime;
}
package com.jam.demo.mapper;

import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.jam.demo.entity.IdSegment;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Update;

/**
 * 号段Mapper
 * @author ken
 */
public interface IdSegmentMapper extends BaseMapper<IdSegment> {
    @Update("UPDATE id_segment SET current_max_id = current_max_id + step WHERE business_type = #{businessType}")
    int incrementMaxId(@Param("businessType") String businessType, @Param("step") int step);
}

3. 号段生成器实现

package com.jam.demo.id;

import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.jam.demo.entity.IdSegment;
import com.jam.demo.mapper.IdSegmentMapper;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.TransactionStatus;
import org.springframework.transaction.support.DefaultTransactionDefinition;
import org.springframework.util.ObjectUtils;

import java.util.concurrent.atomic.AtomicLong;
import java.util.concurrent.locks.ReentrantLock;

/**
 * 号段模式ID生成器
 * @author ken
 */
@Slf4j
@Component
public class SegmentIdGenerator {
    private final IdSegmentMapper idSegmentMapper;
    private final PlatformTransactionManager transactionManager;
    private AtomicLong currentId = new AtomicLong(0);
    private volatile long maxId = 0;
    private final ReentrantLock lock = new ReentrantLock();

    public SegmentIdGenerator(IdSegmentMapper idSegmentMapper, PlatformTransactionManager transactionManager) {
        this.idSegmentMapper = idSegmentMapper;
        this.transactionManager = transactionManager;
    }

    /**
     * 生成指定业务类型的ID
     * @param businessType 业务类型
     * @return 全局唯一ID
     */
    public long nextId(String businessType) {
        long id = currentId.incrementAndGet();
        if (id <= maxId) {
            return id;
        }

        lock.lock();
        try {
            if (currentId.get() <= maxId) {
                return currentId.incrementAndGet();
            }
            refreshSegment(businessType);
            id = currentId.incrementAndGet();
        } finally {
            lock.unlock();
        }
        return id;
    }

    private void refreshSegment(String businessType) {
        DefaultTransactionDefinition definition = new DefaultTransactionDefinition();
        definition.setPropagationBehavior(DefaultTransactionDefinition.PROPAGATION_REQUIRES_NEW);
        TransactionStatus status = transactionManager.getTransaction(definition);

        try {
            IdSegment segment = idSegmentMapper.selectOne(
                    new LambdaQueryWrapper<IdSegment>()
                            .eq(IdSegment::getBusinessType, businessType)
            );
            if (ObjectUtils.isEmpty(segment)) {
                throw new IllegalArgumentException("Business type " + businessType + " not exists");
            }

            int affected = idSegmentMapper.incrementMaxId(businessType, segment.getStep());
            if (affected == 0) {
                throw new RuntimeException("Failed to increment max id for business type " + businessType);
            }

            segment = idSegmentMapper.selectOne(
                    new LambdaQueryWrapper<IdSegment>()
                            .eq(IdSegment::getBusinessType, businessType)
            );

            long newMaxId = segment.getCurrentMaxId();
            long newCurrentId = newMaxId - segment.getStep();
            this.currentId.set(newCurrentId);
            this.maxId = newMaxId;

            transactionManager.commit(status);
        } catch (Exception e) {
            transactionManager.rollback(status);
            throw new RuntimeException("Failed to refresh id segment", e);
        }
    }
}

六、生产级核心难题二:跨库分布式事务解决方案

分库分表之后,一个业务操作可能会涉及多个数据库的更新,比如下单操作需要同时更新订单库、商品库、用户库,单库的本地事务无法保证多个库的原子性,必须使用分布式事务解决方案。

分布式事务的核心理论基础是CAP定理和BASE理论:

  • CAP定理:分布式系统中,一致性、可用性、分区容错性三者不可兼得,最多只能满足其中两项,互联网系统通常选择AP(可用性+分区容错性),牺牲强一致性,保证最终一致性;
  • BASE理论:基本可用、软状态、最终一致性,是对CAP定理的延伸,互联网高并发场景下的分布式事务几乎都基于BASE理论实现。

6.1 主流分布式事务方案对比

生产环境主流的分布式事务方案分为强一致性方案和柔性事务方案两大类,核心对比如下:

方案类型一致性性能可用性开发成本适用场景
强一致性(XA/2PC)强一致性低并发、短事务、强一致性要求的金融核心场景
柔性事务-TCC最终一致性高并发、核心交易、同步场景,比如订单、支付
柔性事务-SAGA最终一致性长事务、非核心业务、允许异步补偿的场景
柔性事务-事务消息最终一致性异步场景、上下游解耦、非核心业务,比如下单送积分
柔性事务-本地消息表最终一致性简单异步场景、中小规模系统

生产环境的核心原则:能不用分布式事务就不用,优先通过业务设计避免跨库操作,必须使用时,优先选择柔性事务方案,仅在强一致性要求极高的场景使用XA/2PC方案。

6.2 TCC模式生产级实现

TCC(Try-Confirm-Cancel)是目前互联网核心交易场景最主流的分布式事务方案,分为三个阶段:

  • Try阶段:资源预留和检查,完成所有业务校验,预留业务资源;
  • Confirm阶段:确认提交,执行真正的业务操作,使用Try阶段预留的资源,保证幂等;
  • Cancel阶段:取消回滚,释放Try阶段预留的资源,保证幂等,处理空回滚和悬挂问题。

TCC模式必须处理三大核心问题:

  1. 幂等性:Confirm和Cancel方法可能会被重复调用,必须保证重复调用的结果和单次调用一致;
  2. 空回滚:Try方法还没执行,Cancel方法就被调用,必须处理这种情况,不能抛出异常;
  3. 悬挂问题:Cancel方法比Try方法先执行,导致Try方法预留的资源永远不会被释放,必须在Try方法中检查是否已经执行过Cancel。

以下是订单场景的TCC生产级实现:

package com.jam.demo.dto;

import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;

import java.math.BigDecimal;

/**
 * 订单TCC参数DTO
 * @author ken
 */
@Data
@Schema(description = "订单TCC参数")
public class OrderTccDTO {
    @Schema(description = "订单ID")
    private Long orderId;

    @Schema(description = "用户ID")
    private Long userId;

    @Schema(description = "商品ID")
    private Long productId;

    @Schema(description = "购买数量")
    private Integer quantity;

    @Schema(description = "订单金额")
    private BigDecimal orderAmount;
}
package com.jam.demo.tcc;

import com.jam.demo.dto.OrderTccDTO;

/**
 * 订单TCC服务接口
 * @author ken
 */
public interface OrderTccService {
    boolean tryCreateOrder(OrderTccDTO orderDTO);
    boolean confirmCreateOrder(OrderTccDTO orderDTO);
    boolean cancelCreateOrder(OrderTccDTO orderDTO);
}
package com.jam.demo.tcc.impl;

import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.jam.demo.dto.OrderTccDTO;
import com.jam.demo.entity.OrderInfo;
import com.jam.demo.mapper.OrderInfoMapper;
import com.jam.demo.tcc.OrderTccService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.TransactionStatus;
import org.springframework.transaction.support.DefaultTransactionDefinition;
import org.springframework.util.ObjectUtils;

/**
 * 订单TCC服务实现
 * @author ken
 */
@Slf4j
@Service
public class OrderTccServiceImpl implements OrderTccService {
    private final OrderInfoMapper orderInfoMapper;
    private final PlatformTransactionManager transactionManager;

    public OrderTccServiceImpl(OrderInfoMapper orderInfoMapper, PlatformTransactionManager transactionManager) {
        this.orderInfoMapper = orderInfoMapper;
        this.transactionManager = transactionManager;
    }

    @Override
    public boolean tryCreateOrder(OrderTccDTO orderDTO) {
        DefaultTransactionDefinition definition = new DefaultTransactionDefinition();
        TransactionStatus status = transactionManager.getTransaction(definition);

        try {
            OrderInfo existOrder = orderInfoMapper.selectOne(
                    new LambdaQueryWrapper<OrderInfo>()
                            .eq(OrderInfo::getOrderId, orderDTO.getOrderId())
            );
            if (!ObjectUtils.isEmpty(existOrder)) {
                log.info("Order already exists, orderId: {}", orderDTO.getOrderId());
                transactionManager.commit(status);
                return true;
            }

            OrderInfo order = new OrderInfo();
            order.setOrderId(orderDTO.getOrderId());
            order.setUserId(orderDTO.getUserId());
            order.setOrderAmount(orderDTO.getOrderAmount());
            order.setOrderStatus(0);
            orderInfoMapper.insert(order);

            transactionManager.commit(status);
            log.info("Order try phase success, orderId: {}", orderDTO.getOrderId());
            return true;
        } catch (Exception e) {
            transactionManager.rollback(status);
            log.error("Order try phase failed, orderId: {}", orderDTO.getOrderId(), e);
            return false;
        }
    }

    @Override
    public boolean confirmCreateOrder(OrderTccDTO orderDTO) {
        DefaultTransactionDefinition definition = new DefaultTransactionDefinition();
        TransactionStatus status = transactionManager.getTransaction(definition);

        try {
            OrderInfo order = orderInfoMapper.selectOne(
                    new LambdaQueryWrapper<OrderInfo>()
                            .eq(OrderInfo::getOrderId, orderDTO.getOrderId())
            );
            if (ObjectUtils.isEmpty(order)) {
                log.warn("Order not exists, confirm phase skip, orderId: {}", orderDTO.getOrderId());
                transactionManager.commit(status);
                return true;
            }
            if (order.getOrderStatus() == 1) {
                log.info("Order already confirmed, orderId: {}", orderDTO.getOrderId());
                transactionManager.commit(status);
                return true;
            }

            order.setOrderStatus(1);
            orderInfoMapper.updateById(order);

            transactionManager.commit(status);
            log.info("Order confirm phase success, orderId: {}", orderDTO.getOrderId());
            return true;
        } catch (Exception e) {
            transactionManager.rollback(status);
            log.error("Order confirm phase failed, orderId: {}", orderDTO.getOrderId(), e);
            return false;
        }
    }

    @Override
    public boolean cancelCreateOrder(OrderTccDTO orderDTO) {
        DefaultTransactionDefinition definition = new DefaultTransactionDefinition();
        TransactionStatus status = transactionManager.getTransaction(definition);

        try {
            OrderInfo order = orderInfoMapper.selectOne(
                    new LambdaQueryWrapper<OrderInfo>()
                            .eq(OrderInfo::getOrderId, orderDTO.getOrderId())
            );
            if (ObjectUtils.isEmpty(order)) {
                log.warn("Order not exists, cancel phase skip, orderId: {}", orderDTO.getOrderId());
                transactionManager.commit(status);
                return true;
            }

            if (order.getOrderStatus() == 2) {
                log.info("Order already canceled, orderId: {}", orderDTO.getOrderId());
                transactionManager.commit(status);
                return true;
            }

            order.setOrderStatus(2);
            orderInfoMapper.updateById(order);

            transactionManager.commit(status);
            log.info("Order cancel phase success, orderId: {}", orderDTO.getOrderId());
            return true;
        } catch (Exception e) {
            transactionManager.rollback(status);
            log.error("Order cancel phase failed, orderId: {}", orderDTO.getOrderId(), e);
            return false;
        }
    }
}

6.3 其他方案的核心适用场景

  1. SAGA模式:将分布式事务拆分为多个正向操作和对应的补偿操作,正向操作依次执行,某个步骤失败时,反向执行补偿操作回滚。适合长事务场景,比如跨系统的流程化操作,开发成本比TCC低,但一致性保证较弱,不适合资金相关的核心业务。
  2. 事务消息方案:基于RocketMQ等支持事务消息的中间件实现,通过两阶段提交保证本地事务和消息发送的原子性,实现异步场景的最终一致性。适合上下游解耦的异步场景,比如下单成功后给用户发送积分、通知,开发成本极低,性能极高。
  3. XA/2PC方案:基于数据库的XA协议实现两阶段提交,分为准备阶段和提交阶段,协调者统一管理所有参与者的事务,保证强一致性。但性能极低,协调者宕机会导致所有参与者阻塞,可用性差,仅适合低并发、强一致性要求的金融核心场景,互联网高并发场景基本不使用。

七、生产级核心难题三:跨分片分页查询全场景解决方案

分库分表之后,分页查询是最容易出现性能故障的场景。传统的limit offset, pageSize分页,在跨分片场景下,需要每个分片都查询offset + pageSize条数据,然后汇总所有分片的数据进行排序,再取对应的分页数据,当offset很大时(比如深分页),会出现严重的性能问题,甚至导致应用OOM。

7.1 分页查询的核心痛点

  1. 无分片键的分页查询:需要全分片扫描,每个分片都要执行查询,性能极差;
  2. 深分页场景:offset越大,每个分片需要扫描的数据量越大,内存占用和排序耗时呈指数级增长;
  3. 数据排序一致性:多个分片的数据合并排序,必须保证排序字段的唯一性,否则会出现分页数据重复或丢失。

7.2 生产级分页解决方案与实战

生产环境分页查询的核心原则:优先避免跨分片分页,必须跨分片时,禁止深分页,根据业务场景选择对应的解决方案。

方案1:带分片键的精准分页(首选方案)

带分片键的精准分页是性能最高、最稳定的方案,查询时携带分片键,路由到单个分片,在单个分片内执行传统的limit分页,和单表分页性能完全一致,覆盖80%以上的业务场景。

比如用户查询自己的订单列表,分片键为user_id,每次查询都携带user_id,精准路由到对应的分片表,性能极高。

-- 带分片键的精准分页,路由到单个分片,性能和单表一致
SELECT order_id, user_id, order_amount, order_status, create_time
FROM order_info
WHERE user_id = 1001
ORDER BY order_id DESC
LIMIT 010;

方案2:游标分页(滚动分页)

游标分页是解决深分页问题的最优方案,核心逻辑是基于上一页最后一条数据的排序字段值,查询下一页的数据,避免使用offset扫描大量历史数据。每个分片仅需查询pageSize条数据,无需扫描前面的offset条数据,性能提升几个数量级。

游标分页适合APP、小程序的无限滚动场景,不支持跳页,是目前互联网产品的主流分页方案。

-- 第一页查询,按order_id降序排序,每页10条
SELECT order_id, user_id, order_amount, order_status, create_time
FROM order_info
WHERE user_id = 1001
ORDER BY order_id DESC
LIMIT 10;

-- 第二页查询,基于上一页最后一条的order_id(比如上一页最后一条order_id为10000)
SELECT order_id, user_id, order_amount, order_status, create_time
FROM order_info
WHERE user_id = 1001 AND order_id < 10000
ORDER BY order_id DESC
LIMIT 10;

方案3:Elasticsearch搜索引擎方案

对于不带分片键的复杂分页查询,比如运营后台的全量订单查询、多条件筛选分页,最优解决方案是将所有分片的数据同步到Elasticsearch搜索引擎,通过ES实现分布式分页查询。

ES天生支持分布式分页和复杂的多条件查询,性能极高,功能丰富,同时可以解决跨库JOIN、多条件筛选等问题,是生产环境复杂查询场景的标准解决方案。

数据同步方案:通过Canal监听MySQL的binlog,实时将数据同步到ES,保证数据的最终一致性。

方案4:二次查询法(归并分页)

对于必须支持跳页的跨分片分页查询,可以使用二次查询法,减少每个分片扫描的数据量,提升分页性能。核心逻辑分为两步:

  1. 第一次查询:将offset平均分配到每个分片,每个分片查询limit offset/N, pageSize条数据(N为分片数量),获取所有分片返回数据中的最小排序值;
  2. 第二次查询:每个分片查询where 排序字段 >= 最小排序值 limit offset, pageSize条数据,汇总所有分片的数据,排序后取前pageSize条。

二次查询法可以大幅减少每个分片扫描的数据量,比全量扫描每个分片的offset + pageSize条数据性能提升明显,但仍有一定的开销,适合分片数不多、跳页不深的场景。

7.3 分页查询的绝对禁止项

生产环境绝对禁止使用不带分片键的limit offset, pageSize深分页查询,比如查询第1000页,每页10条,limit 10000, 10,在4个分片的场景下,需要每个分片查询10010条数据,汇总40040条数据进行排序,最终只返回10条数据,性能极差,会占用大量的内存和CPU,甚至导致应用OOM。

必须通过产品设计优化,限制最大翻页数,比如最多只能翻100页,引导用户通过条件筛选缩小查询范围,避免深分页。

八、分库分表的避坑指南与最佳实践

8.1 生产环境高频踩坑汇总

  1. 分片键选型错误:80%的查询不带分片键,导致全分片扫描,性能极差,这是最常见的致命错误;
  2. 过度拆分:为了分而分,单表数据量还没到千万级就进行分库分表,引入了不必要的复杂度,反而降低了系统性能和可维护性;
  3. 分布式事务处理不当:TCC模式没有处理幂等、空回滚、悬挂问题,导致数据不一致,出现资金损失等严重故障;
  4. 时钟回拨导致ID重复:雪花算法没有处理时钟回拨问题,导致生成重复ID,出现主键冲突,数据插入失败;
  5. 跨库JOIN滥用:频繁使用跨库JOIN,导致查询性能极差,无法优化,必须通过业务设计避免跨库JOIN;
  6. UUID作为主键:使用无序的UUID作为主键,导致InnoDB聚簇索引页分裂,插入性能暴跌,索引体积大幅增大;
  7. 分片数量选择不当:分片数量过少,无法支撑后续的扩容;分片数量过多,导致运维复杂度极高,跨分片操作频繁。

8.2 生产级最佳实践

  1. 能不分就不分:优先通过索引优化、SQL优化、读写分离、缓存方案解决性能问题,分库分表是最后的选择;
  2. 先垂直后水平:优先进行垂直分库、垂直分表,解决业务耦合和冷热数据分离问题,再通过水平拆分解决单表数据量和单库并发问题;
  3. 分片数量提前规划:分片数量建议选择2的n次方,便于后续扩容,初始分片数量建议设置为32或64,提前预留扩容空间,避免后续数据迁移;
  4. 非分片键查询统一收口:所有非分片键的查询,统一通过ES搜索引擎实现,禁止直接在数据库中进行全分片扫描查询;
  5. 分片键绝对不可更新:分片键的值一旦写入,绝对不能更新,避免数据在分片之间迁移;
  6. 分布式事务优先通过业务设计避免:能不用分布式事务就不用,优先通过字段冗余、服务内聚、异步化等方式避免跨库事务;
  7. 全链路压测验证:分库分表上线前,必须通过全链路压测验证性能和稳定性,模拟高并发场景和各种边界情况,避免上线后出现故障。

写在最后

分库分表是解决MySQL大数据量、高并发场景性能瓶颈的有效方案,但它不是银弹,而是一把双刃剑,在提升性能的同时,也引入了极高的架构复杂度和运维成本。

做好分库分表的核心,不是掌握多少复杂的分片算法和分布式事务方案,而是先想清楚是否真的需要分库分表,能不能通过更简单的方案解决问题。必须使用分库分表时,要选对分片方案,选好分片键,提前规划好扩容方案,处理好分布式事务、分页查询、全局ID这三大核心难题,才能保证系统的稳定性和性能。