1. 分库分表到底在解决什么问题
1.1 单库单表的性能天花板在哪里
很多团队在项目初期都是单库单表一路跑到底,业务量小的时候完全没问题,查询快、维护简单、开发也省心。但数据量一旦上来,问题就会集中爆发。我经历过的一个典型场景是:一张订单表跑到三千万行左右,日常的列表查询开始出现明显延迟,带分页的复杂条件查询甚至要好几秒。加了索引之后好了一阵,但数据继续涨,索引本身也变得越来越重,写入性能跟着往下掉。
这里面的核心矛盾在于:单表数据量越大,B+树索引的层级越深,磁盘IO次数越多。同时,单库的连接数、CPU、内存、磁盘IO都是有限的,所有读写压力都压在一个实例上,迟早会触顶。一般来说,业内比较公认的经验值是:单表行数超过500万到1000万,或者单表数据文件超过2GB,就要开始认真考虑分库分表了。当然这不是绝对数字,具体还要看行的宽度、查询模式、硬件配置。
分库分表要解决的就是这个天花板问题。它把原本集中在一个库、一张表里的数据,按照某种规则拆散到多个库、多张表里,让每个数据节点只承担一部分压力,从而把整体的存储容量和吞吐能力横向扩展出去。
1.2 垂直拆分与水平拆分的区别和选择
分库分表严格来说分两个维度:垂直拆分和水平拆分。
垂直拆分是按业务或按字段来切。按业务切,就是把订单、用户、商品这些不同业务的数据放到不同的库里,这其实就是微服务架构下常见的“每个服务一个库”。按字段切,就是把一张宽表里不常用的字段、大字段拆到另一张表,比如商品表里把详情描述这种大文本单独拆出去。垂直拆分的优点是业务边界清晰、维护简单,缺点是它解决不了单表数据量过大的问题——同一张表的数据还是那么多。
水平拆分才是真正解决单表数据量问题的方案。它按照某个分片键(比如用户ID、订单ID),把同一张表的数据分散到多张结构相同的表里,这些表可以分布在同一个库,也可以分布到不同的库。水平拆分之后,每张表只存一部分数据,单表体量下来了,查询和写入都能保持在一个稳定的水平。
实际项目中,这两种拆分往往是配合使用的:先按业务垂直拆库,再对数据量大的核心表做水平拆分。我在做电商类系统的时候,通常会把订单、用户、商品拆到不同的库,然后对订单表再按用户ID做水平分片,这样既保证了业务隔离,又解决了单表膨胀。
1.3 什么阶段该动手,别等到扛不住
这里我要强调一个很多人踩过的坑:分库分表一定要提前规划,不要等到数据库快撑不住了才动手。因为一旦线上数据量已经很大,再做拆分就意味着要迁移海量数据、要改大量SQL、要处理数据一致性问题,风险极高,往往需要停机或者做复杂的双写方案。
我的建议是,在项目设计阶段就评估未来一到两年的数据增长趋势。如果预判某张核心表会突破千万级别,就提前把分片方案设计好,哪怕一开始只用一个分片,把分片逻辑预留出来,后面扩容会从容很多。这个“预留”的成本,远比事后改造要低。
2. 分片方案的核心设计思路
2.1 分片键怎么选才不踩坑
分片键的选择直接决定了整个分库分表方案能不能用、好不好用。选分片键的核心原则是:尽量让查询能带上分片键,避免跨库跨表扫描。
以订单场景为例,最常见的分片键是用户ID或者订单ID。如果选用户ID,那么“查询某个用户的所有订单”这类请求就能精准定位到一个分片,效率很高;但“按订单号查询”就会变成跨分片查询,因为订单号和用户ID不一定在同一个分片规则下。反过来选订单ID也是类似的问题。
所以实际中经常采用基因法或者冗余字段来解决。基因法的思路是:生成订单ID的时候,把用户ID的一部分“基因”编进订单ID里,这样按订单ID分片和按用户ID分片能落到同一个分片,两种查询都能精准命中。冗余字段则是把常用的查询条件冗余存储,或者建立映射表来辅助路由。
提示:分片键一旦确定,后期修改成本极高,因为它涉及到所有历史数据的重新分布。选之前一定要把核心查询场景列全,逐一验证。
2.2 分片算法的取舍:取模、范围还是哈希
常见的分片算法有三种:
- 取模:
shard = id % shardCount。优点是数据分布均匀,缺点是扩容时几乎所有数据都要重新分布,迁移量巨大。 - 范围:按ID区间或时间区间分片。优点是扩容方便,新数据自然落到新分片;缺点是容易出现热点,比如按时间分片时最新分片压力最大。
- 一致性哈希:在扩容时只影响相邻节点,迁移量小,但实现相对复杂,且可能分布不够均匀。
我在实际项目里用得最多的是取模配合成倍扩容,或者范围分片配合预分片。所谓预分片,就是一开始就分成比如1024个逻辑分片,物理上先放在少数几个库,扩容时把逻辑分片整体搬迁到新库,这样不用重新计算每条数据的归属,迁移成本可控。
2.3 分库还是只分表,数量怎么定
分几个库、几张表,需要结合数据总量和未来增长来算。假设一张订单表预计三年后达到3亿行,单表控制在1000万行以内,那么理论上需要30张表。如果每个库放10张表,那就是3个库。
但这里还要考虑数据库实例的承载能力。一个库放太多表,连接数、文件句柄、元数据管理都会有压力。一般单库表数量控制在几十到一百张以内比较稳妥。同时分片总数最好是2的幂次或者便于后续翻倍的数,方便扩容。
我一般的做法是:先按当前数据量估算一个基础分片数,然后预留一倍到两倍的扩容空间。比如现在需要8个分片,那就直接规划成16个,初期可以只部署8个物理库,每个库挂两个逻辑分片,将来扩容时把逻辑分片拆出去即可。
3. SpringCloud Alibaba 下的落地实现
3.1 技术选型:ShardingSphere 与 MyCat 的对比
在 SpringCloud Alibaba 体系里做分库分表,主流方案有两个:Apache ShardingSphere和MyCat。
ShardingSphere 是JDBC层面的增强,以jar包形式嵌入应用,对应用透明,支持分库分表、读写分离、数据加密等,社区活跃,和SpringCloud集成非常顺滑。MyCat是代理层方案,应用连MyCat就像连一个普通MySQL,MyCat再转发到后端真实库。代理层的好处是应用几乎不用改,语言无关;缺点是多了网络跳转,MyCat本身可能成为瓶颈和单点。
我个人的选择倾向是:新项目、Java技术栈、追求灵活性和性能,优先用 ShardingSphere。它没有额外的网络开销,配置灵活,而且和SpringCloud Alibaba的Nacos、Sentinel等组件配合得很好。MyCat更适合老系统改造、多语言混布的场景。
3.2 引入依赖与基础配置
在SpringCloud Alibaba项目里集成ShardingSphere,首先在业务模块的pom里引入依赖。以ShardingSphere-JDBC为例:
<dependency> <groupId>org.apache.shardingsphere</groupId> <artifactId>shardingsphere-jdbc-core-spring-boot-starter</artifactId> <version>5.3.2</version> </dependency>然后在application.yml里配置数据源和分片规则。这里要注意,ShardingSphere会接管数据源,所以原来的单数据源配置要改成它自己的格式:
spring: shardingsphere: datasource: names: ds0,ds1 ds0: type: com.zaxxer.hikari.HikariDataSource driver-class-name: com.mysql.cj.jdbc.Driver jdbc-url: jdbc:mysql://localhost:3306/order_db0 username: root password: xxx ds1: type: com.zaxxer.hikari.HikariDataSource driver-class-name: com.mysql.cj.jdbc.Driver jdbc-url: jdbc:mysql://localhost:3306/order_db1 username: root password: xxx rules: sharding: tables: t_order: actual-data-nodes: ds$->{0..1}.t_order_$->{0..1} database-strategy: standard: sharding-column: user_id sharding-algorithm-name: db-inline table-strategy: standard: sharding-column: user_id sharding-algorithm-name: table-inline sharding-algorithms: db-inline: type: INLINE props: algorithm-expression: ds$->{user_id % 2} table-inline: type: INLINE props: algorithm-expression: t_order_$->{user_id % 2}这段配置的意思是:t_order表被拆成 ds0.t_order_0、ds0.t_order_1、ds1.t_order_0、ds1.t_order_1 四个物理表,分片键是user_id,库和表都按 user_id % 2 来路由。
3.3 分片算法与路由规则详解
上面用的是INLINE表达式算法,适合简单的取模场景。但生产环境往往需要更复杂的逻辑,比如按时间范围分片、按多个字段组合分片。ShardingSphere提供了多种算法类型:
| 算法类型 | 适用场景 | 特点 |
|---|---|---|
| INLINE | 简单取模、简单表达式 | 配置简单,性能好 |
| INTERVAL | 按时间区间分片 | 适合日志、流水类数据 |
| HASH_MOD | 哈希取模 | 分布均匀 |
| COMPLEX_INLINE | 多字段组合分片 | 灵活,支持多列 |
| HINT_INLINE | 强制路由 | 用于无法从SQL提取分片键的场景 |
如果内置算法不够用,还可以实现StandardShardingAlgorithm接口自定义算法。我做过一个按“用户所属区域+用户ID”组合分片的场景,就是自定义算法实现的,路由时先按区域定位到库,再按用户ID定位到表。
路由规则的核心逻辑是:ShardingSphere解析SQL,提取分片键的值,然后根据算法计算出目标分片,改写SQL中的表名为真实物理表名,最后分发到对应数据源执行。对于不带分片键的查询,它会广播到所有分片再归并结果,这也是为什么前面强调分片键要尽量出现在查询条件里。
3.4 分布式主键的生成方案
分库分表之后,原来的自增主键就失效了,因为多个表各自自增会产生重复ID。必须换成分布式主键方案。常见的有:
- 雪花算法(Snowflake):生成64位长整型ID,包含时间戳、机器ID、序列号,趋势递增,性能高。ShardingSphere内置了雪花算法,配置一下就能用。
- 号段模式:从数据库批量取一段ID缓存在内存里,用完再取,减少数据库交互。
- Redis自增:利用Redis的INCR生成,简单但依赖Redis可用性。
我在项目里最常用的是雪花算法,配置如下:
spring: shardingsphere: rules: sharding: tables: t_order: key-generate-strategy: column: order_id key-generator-name: snowflake key-generators: snowflake: type: SNOWFLAKE props: worker-id: 1注意:雪花算法依赖机器时钟,如果服务器时钟回拨,可能生成重复ID。生产环境要开启NTP时间同步,并选择对时钟回拨有处理的实现版本。
4. 实操过程中的关键环节与避坑
4.1 数据迁移怎么做才安全
存量数据迁移是分库分表里最惊心动魄的一步。我的经验是分三步走:全量迁移、增量同步、数据校验。
全量迁移用工具把老表数据按新分片规则导入新表,可以用ShardingSphere的Scaling组件,也可以自己写迁移程序。增量同步是在全量迁移期间,老表还在写入,需要把这段时间的变更同步到新表,通常用binlog订阅的方式。数据校验是迁移完成后,对比老表和新表的数据一致性,抽样或者全量比对。
整个过程建议在业务低峰期做,并且准备好回滚方案。我踩过的一个坑是:迁移时没考虑大事务,一次性导入太多数据导致主从延迟飙升,影响了线上读请求。后来改成小批量分批导入,每批几千行,问题就解决了。
4.2 跨分片查询与分页的处理
不带分片键的查询会广播到所有分片,这在数据量大、分片多的时候性能很差。对于必须支持的跨分片查询,有几个应对策略:
- 建立异构索引表:把常用的非分片键查询条件,单独建一张索引表,用另一种分片规则存储,查询时先查索引表拿到主键,再回原表查详情。
- 使用Elasticsearch等搜索引擎:把需要多维度检索的数据同步到ES,复杂查询走ES,明细回数据库。
- 限制查询范围:产品层面引导用户加上时间范围等条件,减少扫描的分片数。
分页查询尤其麻烦,因为limit offset, size在每个分片上的语义不一样。ShardingSphere会改写为在每个分片取 offset+size 条,然后在内存里归并排序再截取,分片多、翻页深的时候内存消耗很大。所以深分页要尽量避免,可以用“上一页最大ID”的游标方式来替代offset分页。
4.3 分布式事务如何保证一致性
跨库操作就涉及到分布式事务。SpringCloud Alibaba体系里有Seata,可以配合ShardingSphere使用。Seata的AT模式对业务侵入小,通过undo log实现回滚,适合大多数场景。
配置上,需要在ShardingSphere的数据源之上再包一层Seata的代理数据源,并在业务方法上标注@GlobalTransactional。不过要注意,Seata AT模式在分库分表场景下,undo log的存储和性能也需要评估,高并发下建议用TCC或者可靠消息最终一致性方案来替代。
我的经验是:能避免跨库事务就避免。通过合理的分片键设计,让一个业务操作尽量落在同一个库内,从根上减少分布式事务的需求。比如订单和订单明细用同一个分片键,就能保证它们落在同一个库,本地事务就够了。
4.4 扩容时怎么做到平滑迁移
业务增长后需要加库加表,这是必然的。如果一开始用的是取模算法,扩容时数据要大规模重分布,非常痛苦。所以我在设计阶段就会考虑扩容友好性。
方案一是成倍扩容:从2个库扩到4个库,从4个扩到8个。这样原来id % 2的数据,在id % 4下只会落到原来的库或者对应的新库,迁移量减半。方案二是预分片:逻辑分片数固定,物理库数量可变,扩容时搬迁整个逻辑分片。
无论哪种方案,扩容都要配合双写或者binlog同步,保证迁移期间数据不丢。迁移完成后切流量,观察一段时间再下线老库。
5. 常见问题排查与经验速查
5.1 分片后查询变慢的排查思路
分片后查询反而变慢,通常有几个原因。第一是广播查询,SQL里没带分片键,导致扫了所有分片。排查方法是打开ShardingSphere的SQL日志,看实际执行了几条SQL、路由到了哪些分片。第二是归并排序开销大,尤其是order by加limit的深分页。第三是索引没建对,分片后的每张物理表都要单独建索引,别以为在老表建了就行。
我一般的排查顺序是:先看SQL日志确认路由结果,再看执行计划确认索引使用,最后看归并阶段的内存和耗时。
5.2 分布式主键重复或乱序问题
主键重复多半是雪花算法的worker-id配置冲突,多个实例用了同一个worker-id。解决方法是给每个实例分配唯一的worker-id,可以借助Nacos或者Redis来分配。主键乱序则是因为雪花算法生成的ID虽然趋势递增,但不同机器生成的ID会有交错,如果业务对严格递增有要求,就得换号段模式。
5.3 事务与连接池的典型故障
分库分表后,一个逻辑操作可能占用多个数据库连接,连接池很容易被打满。我遇到过一次线上故障:一个批量操作跨了多个分片,每个分片都要拿连接,结果连接池耗尽,整个服务卡死。后来限制了单次批量操作的分片数量,并且调大了连接池,才稳定下来。
另外,Seata的全局事务会持有连接直到事务结束,高并发下连接占用时间变长,也要相应调整连接池配置。
| 常见问题 | 可能原因 | 解决方向 |
|---|---|---|
| 查询变慢 | 广播查询、深分页、索引缺失 | 补分片键、改游标分页、逐表建索引 |
| 主键重复 | worker-id冲突 | 唯一分配worker-id |
| 连接池耗尽 | 跨分片操作过多 | 限制批量分片数、调大连接池 |
| 数据不一致 | 迁移遗漏、事务未覆盖 | 数据校验、引入分布式事务 |
| 扩容困难 | 取模算法、无预分片 | 成倍扩容或预分片设计 |
5.4 几个我踩过的坑和对应技巧
第一个坑是分片键类型不一致。有一次分片键在配置里是字符串,但实际数据是数字,导致路由结果和预期不符,数据写到了错误的分片。后来统一了类型,问题消失。所以分片键的字段类型一定要和算法表达式匹配。
第二个坑是DDL变更。分片后加字段,要在所有物理表上执行,漏一张就出问题。建议用统一的DDL管理工具,或者写脚本遍历所有分片执行。
第三个坑是统计类查询。count(*)、sum()这类聚合,ShardingSphere会在内存里归并,数据量大时很吃内存。对于这类需求,我一般会单独建汇总表,定时任务预计算,而不是实时查分片表。
第四个坑是测试环境分片数和生产不一致。测试时用2个分片,生产用8个,结果路由逻辑在两边表现不同,上线才发现问题。所以测试环境的分片配置要尽量贴近生产。
6. 一些延伸的思考
分库分表不是银弹,它带来了扩展性,也带来了复杂度。在动手之前,其实可以先考虑一些成本更低的方案:比如先做读写分离,把读压力分摊出去;比如优化SQL和索引,很多时候慢查询是SQL写得不好;比如引入缓存,把热点数据挡在数据库前面。这些手段都试过了还不够,再上分库分表。
另外,现在也有一些新的选择,比如分布式数据库产品,它们在底层帮你做了分片,应用层感知不到。但这类方案往往有厂商绑定、成本、运维等考量,需要结合团队实际情况评估。
我个人在实际操作中的体会是:分库分表最难的不是技术实现,而是前期的方案设计和后期的运维管理。分片键选错、扩容方案没预留、监控没跟上,后面都是无尽的麻烦。所以宁可前期多花时间论证,把查询场景、数据增长、扩容路径都想清楚,再动手写代码。真正落地的时候,先用小流量灰度,验证路由、事务、性能都符合预期,再全量切换。这套流程走下来,基本能避开大部分坑。