随着一个项目的数据量越来越大,我们经常会遇到一种情况。那就是数据越来越查不动了。我们常见的操作就是进行sql优化。但是sql优化也会有个极限。

在考虑分库分表前,先会去考虑sql优化能不能解决问题,作为一个开发老油条,能尽量不做大动作的牵动代码就不去做,哈哈。如果问题能通过以下方式解决,就不需要拆分:

  1. SQL 本身优化:慢查询(如全表扫描、无效 JOIN)可通过加索引、重构 SQL(拆分子查询、避免 SELECT *)、优化事务粒度解决;
  2. 配置与硬件优化:MySQL 参数(如innodb_buffer_pool_size调大、log_buffer_size优化)、升级 SSD(解决 IO 瓶颈)、加 CPU / 内存(解决计算瓶颈)可提升性能(不过一般这个操作反而在公司是最难的操作,公司经费有限,很难让你畅快的使用,申请难度极大);
  3. 缓存与读写分离:高频读请求(如商品详情)用 Redis 缓存拦截,写压力用 “主从复制” 分流(主库写、从库读),能缓解 80% 以上的常规压力;(主从复制与读写分离我会单开一片文章去讲)
  4. 表结构优化:大表拆 “冷热数据”(如订单表保留近 1 年数据在主表,历史数据迁移到归档表)、冗余字段减少 JOIN,比分库分表更轻量。

只有当上述优化全部落地后,仍出现以下 4 类 “底层瓶颈”,说明单库单表架构已达上限,必须通过分库分表突破:

1. 单表数据量过大,索引 / 查询彻底 “失效”

这是最核心的触发场景,数据量超过阈值后,即使索引优化到极致,性能也会断崖式下跌。

  • 具体阈值:行业通用经验是「InnoDB 单表数据量超过 2000 万行」(或表文件大小超过 100GB),但需结合业务:
    • 若表字段少(如仅 10 个字段,无大文本),可容忍到 5000 万行;
    • 若表字段多(含大文本、多索引),1000 万行就可能出现查询慢。
  • 无法解决的问题:
    • 索引失效:数据量过大时,索引树层级变深(如 B + 树从 3 层变成 4 层),查询时磁盘 IO 次数激增,即使走索引,耗时也从 10ms 涨到 100ms+;
    • 写入卡顿:插入 / 更新时,不仅要写数据,还要维护索引(如 B + 树分裂),数据量越大,索引维护耗时越长,甚至出现 “行锁等待”;
    • 备份 / 运维困难:单表数据量过亿,全量备份需数小时,且恢复时无法快速定位问题,运维风险极高。
2. 高并发写入压力,单库 “扛不住”

当业务存在高频写入场景(如秒杀订单、实时日志、支付记录),单库的写入能力会先于读能力达到上限,优化配置也无法突破。

  • 具体阈值:单库写入并发(QPS)超过「3000-5000」(取决于 SQL 复杂度和硬件),且持续处于高负载(CPU 利用率 80%+、磁盘 IO util 90%+)。
  • 无法解决的问题:
    • 写入排队:单库的事务日志(redo log)、binlog 刷盘是串行或有限并行的,大量写入请求会排队等待刷盘,导致 “插入超时”;
    • 锁冲突加剧:高并发写入时,行锁、表锁冲突概率上升(如秒杀时多个请求更新同一商品库存),即使优化锁粒度(如用行锁替代表锁),仍会因请求量过大导致锁等待;
    • 读写互斥:部分场景(如 MyISAM 引擎、或 InnoDB 的高写入 + 高频读)会出现 “写阻塞读”,优化读写分离后,主库的写入压力仍无法缓解(从库只能分流读,不能分流写)。
3. 业务强隔离需求,单库 “拆不开”

当多个核心业务模块(如 “订单”“用户”“商品”“支付”)共用一个数据库时,某一模块的高负载会拖累其他模块,这种 “业务耦合” 问题无法通过 SQL 优化解决。

  • 典型场景:
    • 订单模块做促销活动,QPS 暴涨导致 CPU 100%,同时用户模块的 “登录查询” 也变慢(即使用户查询本身已优化索引);
    • 不同业务对数据库的要求不同(如订单库需高可靠、商品库需高并发读),单库配置无法同时满足(如订单库要高频刷盘保证数据不丢,商品库要减少刷盘提升读速度)。
  • 核心矛盾:SQL 优化只能解决 “单条查询 / 单张表” 的性能问题,无法解决 “多业务模块资源争抢” 的架构问题,必须通过分库(按业务模块拆库)实现隔离。
4. 硬件资源已到顶,无法再升级

当服务器的 CPU、内存、磁盘 IO、网络带宽已达物理上限(如已用顶配 CPU、SSD 磁盘 IO util 长期 100%、网络带宽占满),且无法通过 “加服务器做读写分离” 缓解时,只能通过分库分表跨服务器拆分资源。

  • 无法解决的问题:
    • 单服务器 CPU 瓶颈:即使优化 SQL 减少计算量,所有查询的总 CPU 消耗仍超过服务器核心能力(如 8 核 CPU 持续 100%);
    • 磁盘 IO 饱和:大量读写请求导致磁盘 IOPS(每秒输入输出次数)达上限(如 SSD 的 IOPS 约 1 万,持续跑满),优化索引、减少读写次数后仍无法下降;
    • 内存不足:innodb_buffer_pool_size已设为物理内存的 70%(如 128G 内存设 90G 缓存),但仍有大量请求需要磁盘读(缓存命中率低于 95%),且无法再升级内存。

于是引入我们今天的主角-——分库分表利器Sharding-JDBC。至于另一个工具Sharding-Proxy,我目前尚未使用过,等实际体验后再与大家分享。

分库分表大致分为4种:

  1. 垂直分库
  2. 垂直分表
  3. 水平分库
  4. 水平分表

说到分库,除了上述说的垂直分库、水平分库之外呢,我们平常为了缓解压力,会按照业务的划分将不同的业务建立不同的数据库,但都是在同一台mysql分库里面,这样或者在配置的时候方便,但其实能够缓解的压力有限。

哪些场景下有轻微优化作用?

虽然无法缓解整体流量压力,但这种拆分在特定场景下能带来一些管理或性能上的微小优化。

  • 减少表级锁冲突:如果原数据库中存在大量高并发的表级锁操作(如 MyISAM 引擎),将这些表拆分到不同数据库后,不同库的表锁不会相互阻塞,能减少锁等待时间。
  • 简化权限管理:可以针对不同业务数据库设置独立的访问权限,避免单一账号拥有所有表的权限,提升安全性和可维护性。
  • 优化备份策略:可针对不同重要性的数据库制定差异化备份计划,例如核心业务库实时备份,非核心库每日备份,降低备份资源消耗。

于是便想着同一台服务器多台mysql,把不同的业务库分配到这几台mysql中去。但是仍然受限于物理资源的上限:当所有实例的总 CPU 使用率达到 100%、磁盘 IO 达到饱和(如 iostat 显示 util 接近 100%),或网络带宽占满时,所有实例都会出现响应变慢、超时等问题,本质和 “单实例多库” 面临的 “总资源不够” 问题一致。

于是分库还有一种方式,那就是多服务器下的多mysql实例。这样是最好的解决方案,个人认为。

但是这个方案有个最大的问题就是,在公司服务器的资源没那么容易审批通过,如果说多配几套服务器是为了某个项目把服务拆分,把数据库业务库拆分。那申请的难度可想而知。

回归到我们分库分表的概念中来,什么是垂直分库、水平分库、垂直分表、水平分表呢?

  • 垂直分库

垂直分库的概念就是我们上面提到的,按照业务的不同拆分成不同的库,即使不同其他的工具,我们也已经这么做了。

  • 水平分库

针对垂直拆分后的 “订单库”(数据量增长最快、并发最高),按 “用户 ID 哈希” 拆为 3 个水平库(order_db_0/1/2),突破单库数据量和并发上限。也就是说一个订单库变成三个订单库。

分库不是今天的重点,等有机会再展开叙述。

  • 垂直分表

在一张表内按照字段进行拆分,按照字段的查询频率和字段的大小划分。这个目前按照我们所做的业务来看,这种拆法,及其的考验对业务的理解程度,以及前期和后续字段的建立是否合理。要是B端的产品业务会比较复杂,说实话,没法真的对于数据库的设计做到非常的合理,这就比较头疼,所以项目就没有采用这种做法来做。(可能做着把自己给绕进去了)

简单点来讲就是拆 “列”,表结构不同,数据关联(比如 1 张用户表拆成 “用户基础表”(存 username、phone)和 “用户详情表”(存 avatar、intro),两张表用 user_id 关联)

平常我们在做数据库设计的时候,也会这么考虑。如果是一开始就这么设计,那自然是没什么影响。但是如果是一张业务量很大且字段很多的表,此时如果进行垂直分表,那就有点危险了。

  • 水平分表

对于水平分表,就是同一张表按照业务id,进行拆分多个表(比如 1 张订单表拆成 3 张按时间的订单表,每张表都有 order_id、user_id 等字段)

今天就水平分表展开详细与大家分享一下。

我现在有一个需求就是我有一张过车记录表大概现在是1000万条,表中有字段in_time和out_time,分别是车辆进场时间和车辆离场时间。我想根据进场时间、离场时间分别来查停车场的过车记录数据。这里就不谈sql优化的事情了。在水平分表下怎么操作?

在设计的时候,我有个需求就是我设计不用管是那年的月份,只需要按月去拆分,所以在配置分片规则的时候就要注意

方案核心:主表 + 索引表双表结构(按月分表)

1. 主表设计(按in_time月分表)
  • 分表规则:按in_time的月份分表,表名 pass_record_yyyyMM(如pass_record_202410存储 2024 年 10 月进场的记录)。
  • 核心字段:
    字段名类型说明
    idbigint主键(自增)
    record_idbigint全局唯一 ID(雪花算法,跨表唯一)
    car_novarchar车牌号
    in_timebigint进场时间戳(秒级,分表键)
    out_timebigint出场时间戳(秒级,可为空)
    feedecimal费用
  • 索引:idx_in_time(in_time,分表键)、idx_record_id(record_id,唯一索引,关联索引表)。

2. 二级索引表设计(按out_time月分表)

专门为out_time查询构建索引,记录 “出场时间” 与 “主表分表” 的映射,避免out_time查询跨全量主表。

  • 分表规则:按out_time的月份分表,表名 pass_record_out_index_yyyyMM(如pass_record_out_index_202410存储 2024 年 10 月出场的记录)。
  • 核心字段:
    字段名类型说明
    idbigint主键(自增)
    record_idbigint关联主表的record_id(唯一)
    in_time_tablevarchar主表分表名(如pass_record_202410)
    out_timebigint出场时间戳(秒级,分表键)
  • 索引:idx_out_time(out_time,分表键)、idx_record_id(record_id,唯一索引)。

关键流程(写入 + 查询)

1. 写入流程(保证数据一致性)

车辆进场和出场时分别操作:

  • 进场时:
    • 此时out_time为空,不写入索引表。
    • 根据in_time确定主表分表(如pass_record_202410),写入主表(out_time为空)。
    • 生成record_id(全局唯一)。
  • 出场时:

    1. 根据record_id定位主表(通过record_id的生成时间反推主表,或直接查最近主表),更新out_time和fee。
    2. 根据out_time确定索引表分表(如pass_record_out_index_202410),写入索引表(存储record_id和in_time_table)。
    3. 事务保证:更新主表和写入索引表在同一事务中(本地事务即可,单库场景),避免数据不一致。
2. 查询流程(两种场景均高效)
场景 1:按in_time查询(如 “查 202410 进场的车辆”)
  • 直接路由到主表pass_record_202410,执行查询:

    sql

    SELECT * FROM pass_record_202410 
    WHERE in_time BETWEEN 1730256000(2024-10-01 00:00) AND 1733020799(2024-10-31 23:59);
  • 性能:单表 100 万数据,索引查询耗时 < 50ms。
场景 2:按out_time查询(如 “查 202410 出场的车辆”)
  • 步骤 1:路由到索引表pass_record_out_index_202410,获取所有record_id和in_time_table:

    sql

    SELECT record_id, in_time_table FROM pass_record_out_index_202410 
    WHERE out_time BETWEEN 1730256000 AND 1733020799;
    
  • 步骤 2:根据in_time_table(如pass_record_202409、pass_record_202410)遍历对应主表,查询具体记录:

    sql

    -- 假设in_time_table为pass_record_202410
    SELECT * FROM pass_record_202410 
    WHERE record_id IN (1001, 1002, ...);  -- 索引表返回的record_id列表
    
  • 性能:索引表仅存储关联关系(100 万条记录约占 10MB),查询耗时 < 30ms;主表查询按record_id(唯一索引),单表 100 万数据耗时 < 50ms,总耗时 < 100ms。

Sharding-JDBC 配置(适配按月分表)

spring:
  shardingsphere:
    datasource:
      names: pass-db  # 单库(月均100万,无需分库)
      pass-db:
        type: com.zaxxer.hikari.HikariDataSource
        driver-class-name: com.mysql.cj.jdbc.Driver
        url: jdbc:mysql://localhost:3306/pass_db?useSSL=false&serverTimezone=UTC
        username: root
        password: root
    rules:
      sharding:
        tables:
          # 主表:pass_record(逻辑表)
          pass_record:
            actual-data-nodes: pass-db.pass_record_${*}  # 动态匹配所有pass_record_yyyyMM表
            table-strategy:
              standard:
                sharding-column: in_time  # 分表键:in_time
                sharding-algorithm-name: in-time-month-algorithm

          # 索引表:pass_record_out_index(逻辑表)
          pass_record_out_index:
            actual-data-nodes: pass-db.pass_record_out_index_${*}  # 动态匹配所有索引表
            table-strategy:
              standard:
                sharding-column: out_time  # 分表键:out_time
                sharding-algorithm-name: out-time-month-algorithm

        sharding-algorithms:
          # 主表分表算法(in_time转yyyyMM)
          in-time-month-algorithm:
            type: CLASS_BASED
            props:
              strategy: STANDARD
              algorithm-class-name: com.example.algorithm.InTimeMonthShardingAlgorithm

          # 索引表分表算法(out_time转yyyyMM)
          out-time-month-algorithm:
            type: CLASS_BASED
            props:
              strategy: STANDARD
              algorithm-class-name: com.example.algorithm.OutTimeMonthShardingAlgorithm
    props:
      sql-show: true  # 打印SQL,验证路由
2. 自定义分表算法(按月转换)

主表和索引表的算法逻辑一致,仅分表键不同:

// 主表分表算法(in_time转yyyyMM)
public class InTimeMonthShardingAlgorithm implements PreciseShardingAlgorithm<Long> {
    private static final DateTimeFormatter FORMATTER = DateTimeFormatter.ofPattern("yyyyMM");

    @Override
    public String doSharding(Collection<String> availableTargetNames, PreciseShardingValue<Long> shardingValue) {
        Long inTime = shardingValue.getValue();  // in_time为秒级时间戳
        LocalDateTime dateTime = LocalDateTime.ofInstant(
            Instant.ofEpochSecond(inTime), ZoneOffset.ofHours(8)  // 东八区
        );
        String suffix = dateTime.format(FORMATTER);  // 如202410
        return shardingValue.getLogicTableName() + "_" + suffix;  // pass_record_202410
    }
}

// 索引表分表算法(out_time转yyyyMM,代码同上)
public class OutTimeMonthShardingAlgorithm implements PreciseShardingAlgorithm<Long> {
    // 与InTimeMonthShardingAlgorithm逻辑一致,分表键为out_time
}

  1. 索引表自动创建:每月 25 日通过定时任务创建下月的主表和索引表(如 10 月创建 11 月的pass_record_202411和pass_record_out_index_202411),避免写入时表不存在。
  2. 未出场车辆查询:out_time为空的车辆(未出场)无法通过索引表查询,需直接查最近 3 个月的主表(如查 “未出场车辆” 时,遍历pass_record_202408、202409、202410),因数据量小(300 万),性能可接受。
  3. 数据归档:超过 1 年的主表和索引表可迁移到冷存储(如 HDFS),查询时通过接口路由,不影响热表性能。

一、分片主表创建 SQL(按 in_time 年月分表)

表名格式:car_park_use_log_yyyyMM(如 car_park_use_log_202410),与原表结构完全一致:

配置分表前的按照时间从202301-202512开始建表,隐藏了真实业务表的字段,仅做展示Demo。

-- 创建2024年10月分片主表(其他月份仅需修改后缀)
CREATE TABLE IF NOT EXISTS `car_park_use_log_202301` (
  `id` varchar(64) NOT NULL DEFAULT '',
  `number_plate` varchar(16) NOT NULL COMMENT '车牌号',
  `in_time` bigint(19) unsigned NOT NULL DEFAULT '0' COMMENT '进场时间(分表键)',
  `out_time` bigint(19) unsigned NOT NULL DEFAULT '0' COMMENT '出场时间',
  KEY `in_time` (`in_time`),  -- 分表键索引,必须保留
  KEY `out_time` (`out_time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT='车辆出入记录(2023年01月)';

二、索引表创建 SQL(按 out_time 年月分表)

索引表关联主表的核心字段,表名格式:car_park_out_index_yyyyMM(如 car_park_out_index_202410):

-- 创建2024年10月索引表(其他月份仅需修改后缀)
CREATE TABLE IF NOT EXISTS `cf_car_park_out_index_202410` (
  `id` varchar(64) NOT NULL DEFAULT '' COMMENT '自增ID(同主表id)',
  `number_plate` varchar(16) NOT NULL COMMENT '车牌号(冗余,加速查询)',
  `record_id` varchar(64) NOT NULL COMMENT '关联主表的id',
  `in_time_table` varchar(64) NOT NULL COMMENT '主表分片表名(如cf_car_park_use_log_202410)',
  `out_time` bigint(19) unsigned NOT NULL DEFAULT '0' COMMENT '出场时间(分表键)',
  `car_park_id` varchar(64) NOT NULL COMMENT '停车场id(冗余,加速筛选)',
  `pay_time` bigint(19) unsigned NOT NULL DEFAULT '0' COMMENT '支付时间(冗余,加速查询)',
  PRIMARY KEY (`id`) USING BTREE,
  KEY `out_time` (`out_time`),  -- 分表键索引
  KEY `record_id` (`record_id`),  -- 关联主表的唯一索引
  KEY `number_plate` (`number_plate`),  -- 车牌号索引,支持按车牌查出场记录
  KEY `car_park_id` (`car_park_id`)  -- 停车场ID索引,支持按停车场筛选
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT='车辆出场索引表(2024年10月)';

索引表作用:通过 out_time 快速定位出场记录,避免扫描所有主表分表,同时冗余 number_plate、car_park_id 等常用查询字段,提升查询效率。

三、历史数据拆分 SQL(迁移旧表数据到分表)

假设历史表为 car_park_use_log(已经创建好分片表),按 in_time 拆分到对应年月分表:

-- 1. 先创建目标分表(以2023年1月为例,其他年月需提前创建)
CREATE TABLE IF NOT EXISTS cf_car_park_use_log_202301 LIKE cf_car_park_use_log_202410;

-- 2. 迁移2023年1月的主表数据(时间戳范围:2023-01-01 00:00:00 至 2023-01-31 23:59:59)
INSERT INTO cf_car_park_use_log_202301
SELECT * FROM cf_car_park_use_log
WHERE in_time BETWEEN 1672502400 AND 1675180799  -- 时间戳替换为目标月份的起止秒级时间戳
  AND deleted = 0;  -- 过滤已删除数据

-- 3. 创建对应索引表并迁移数据(仅迁移已出场的记录)
CREATE TABLE IF NOT EXISTS cf_car_park_out_index_202301 LIKE cf_car_park_out_index_202410;

INSERT INTO cf_car_park_out_index_202301 (
  id, number_plate, record_id, in_time_table, out_time, car_park_id, pay_time
) SELECT 
  id, 
  number_plate, 
  id AS record_id,  -- 主表id即record_id
  CONCAT('cf_car_park_use_log_', DATE_FORMAT(FROM_UNIXTIME(in_time), '%Y%m')),  -- 主表分表名
  out_time, 
  car_park_id, 
  pay_time
FROM cf_car_park_use_log_202301
WHERE out_time > 0  -- 仅迁移已出场的记录(out_time不为0)
  AND deleted = 0;

四、动态创建分表和索引表的代码(按年月递增)

每年 12 月自动创建下一年所有月份的分表和索引表,适配你的表结构:

package com.parking.shardingdemo.task;

import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;

import java.time.LocalDate;

@Slf4j
@Component
public class AnnualShardingTableTask {

    @Autowired
    private JdbcTemplate jdbcTemplate;

    // 每年12月25日0点执行,创建下一年所有月份的分表
    @Scheduled(cron = "0 0 0 25 12 ?")
    public void createNextYearTables() {
        int nextYear = LocalDate.now().plusYears(1).getYear();
        log.info("开始创建{}年所有分表和索引表...", nextYear);

        // 循环1-12月
        for (int month = 1; month <= 12; month++) {
            String yearMonth = String.format("%d%02d", nextYear, month);
            log.info("开始创建{}年{}月分表...", nextYear, month);

            // 1. 创建主表(car_park_use_log_yyyyMM)
            String mainTableSql = String.format("""
                    CREATE TABLE IF NOT EXISTS `car_park_use_log_%s` (
                      `id` varchar(64) NOT NULL DEFAULT '',
                      `number_plate` varchar(16) NOT NULL COMMENT '车牌号',
                      `in_time` bigint(19) unsigned NOT NULL DEFAULT '0' COMMENT '进场时间(分表键)',
                      `out_time` bigint(19) unsigned NOT NULL DEFAULT '0' COMMENT '出场时间'

                      PRIMARY KEY (`id`) USING BTREE,
                      KEY `number_plate` (`number_plate`),
                      KEY `in_time` (`in_time`),
                      KEY `out_time` (`out_time`)
                    ) ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT='车辆出入记录(%s年%s月)';
                    """, yearMonth, nextYear, month);
            jdbcTemplate.execute(mainTableSql);

            // 2. 创建索引表(car_park_out_index_yyyyMM)
            String indexTableSql = String.format("""
                    CREATE TABLE IF NOT EXISTS `cf_car_park_out_index_%s` (
                      `id` varchar(64) NOT NULL DEFAULT '' COMMENT '自增ID(同主表id)',
                      `number_plate` varchar(16) NOT NULL COMMENT '车牌号(冗余,加速查询)',
                      `record_id` varchar(64) NOT NULL COMMENT '关联主表的id',
                      `in_time_table` varchar(64) NOT NULL COMMENT '主表分片表名',
                      `out_time` bigint(19) unsigned NOT NULL DEFAULT '0' COMMENT '出场时间(分表键)',
                      `car_park_id` varchar(64) NOT NULL COMMENT '停车场id(冗余,加速筛选)',
                      `pay_time` bigint(19) unsigned NOT NULL DEFAULT '0' COMMENT '支付时间(冗余,加速查询)',
                      PRIMARY KEY (`id`) USING BTREE,
                      KEY `out_time` (`out_time`),
                      KEY `number_plate` (`number_plate`),
                      KEY `car_park_id` (`car_park_id`)
                    ) ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT='车辆出场索引表(%s年%s月)';
                    """, yearMonth, nextYear, month);
            jdbcTemplate.execute(indexTableSql);

            log.info("{}年{}月分表创建完成:主表car_park_use_log_{},索引表car_park_out_index_{}",
                    nextYear, month, yearMonth, yearMonth);
        }
        log.info("{}年所有分表和索引表创建完成!", nextYear);
    }


}
启动配置:

在 Spring Boot 启动类添加 @EnableScheduling 开启定时任务:

@SpringBootApplication
@EnableScheduling
public class ParkingApplication {
    public static void main(String[] args) {
        SpringApplication.run(ParkingApplication.class, args);
    }
}

Logo

北京人形旗下天工造物具身智能开源社区,聚焦具身天工与慧思开物两大平台

更多推荐