多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

MySQL分库分表实战:ShardingSphere架构拆解与避坑指南

MySQL分库分表实战:ShardingSphere架构拆解与避坑指南 我接手过的MySQL项目里最让人头疼的往往不是SQL写得烂而是数据量真的大到一定程度后再优秀的索引和调优都很难救回来。去年我们做订单系统改造单表存量接近7000万行日增超过百万晚高峰几条慢查询直接把报警群刷屏。当时讨论过应用层手写分库分表也试过换中间件最后完整落地的是Apache ShardingSphere。这篇作为“深入解析MySQL”系列的第10篇我想把基于ShardingSphere做高性能架构的完整拆解、配置实操和踩坑记录都整理出来。内容不适合零基础读者入门MySQL但如果你正在评估分库分表方案或者已经把ShardingSphere跑起来却时不时碰见诡异问题那这篇文章应该能帮你少走不少弯路。1. 单库单表撑不住之后ShardingSphere在MySQL体系里到底扮演什么角色1.1 什么时候才真正需要动分库分表这步棋很多人有个误区觉得表过了一两百万行就该分片这是典型的焦虑式架构演进。分库分表是有代价的查询链路变长、事务边界变复杂、二次分片困难运维成本显著上升。我的经验是先盘清楚几个硬指标再决策。单表数据量是否进入5000万到1亿的量级且数据还在快速上涨大字段或热点行导致的页分裂、锁竞争是否已经明显索引高度是否到了3层以上备份和恢复时间是否已经无法满足业务RTO写入TPS是否已经压垮单个实例的磁盘IO能力慢查询是否已经占比超过某个阈值比如5%以上且通过索引优化、归档都救不回来。如果你只是有一张2000万行的表但查询和写入压力都一般优先考虑冷热数据归档、分区表、读写分离完全没必要一上来就分片。反过来说像订单、流水、日志这类天生按时间或用户维度增长的数据迟早要面对分布式方案。我们当时判定订单表已经无法继续扩容才决定引入ShardingSphere。另一个容易忽略的信号是合并查询场景。比如后台需要跑跨天报表单表加上时间分区还能用但一旦业务上需要多维度聚合MySQL单实例的算力就成了瓶颈。这种场景分片的意义不只是水平扩展数据量而是把计算压力也分散到多个节点。1.2 JDBC、Proxy、Sidecar三种形态怎么选ShardingSphere现在已经不是单纯的分库分表中间件而是一套生态。核心分成三块ShardingSphere-JDBC、ShardingSphere-Proxy、ShardingSphere-Sidecar。ShardingSphere-JDBC是客户端形态以jar包方式嵌入应用Java代码里配置数据源和规则SQL由应用侧解析、路由、改写和执行。它的优势是性能损耗小因为不需要经过代理转发应用和数据库链路最短。适合Java技术栈、微服务架构里各服务自己掌控数据访问层的场景。我们订单服务最终选的就是这种形态理由有两个一是团队全是Java规则可以跟着服务走二是JDBC形态对开发人员透明Debug时可以直接看到路由到哪个库哪个表。ShardingSphere-Proxy是独立部署的代理服务对应用表现为一个MySQL协议端口。应用不需要改代码只需要改数据库连接地址。优点是多语言友好、运维隔离缺点是多一跳网络开销性能损耗比JDBC形态高一些。如果你有多个异构服务都要访问同一套分片数据或者DBA团队有强管控诉求Proxy更合适。Sidecar形态目前还是探索阶段相当于以Agent方式部署在每个数据库节点旁边更偏向未来Service Mesh数据库化方向生产环境大规模使用的案例还少普通业务团队不建议现在押注。三种形态的取舍可以看下面这张表对比项ShardingSphere-JDBCShardingSphere-Proxy部署位置应用内独立代理节点应用改动需要引入依赖和规则配置只需改JDBC连接地址性能开销低链路短高多一次转发多语言支持仅Java任意可连MySQL的语言运维成本低跟着应用走高需独立部署与监控适合场景Java微服务、强业务逻辑控制异构系统统一接入、DBA托管我个人的建议是如果你们是Java技术栈、有自研框架或中台能力选择JDBC形态可干预空间最大。如果服务成分复杂或者DBA团队需要黑盒管理选Proxy。两者未来会走向统一内核但至少现阶段选错了后续迁移成本不低。2. 分片引擎的核心运作机制一次SQL查询是怎么被拆解的2.1 分片键、分片算法与分片策略先分清这三层很多配置写错的人其实是没有分清“分片键”“分片算法”“分片策略”三者之间的层次关系。分片键是指路由时依赖的字段比如订单表按user_id分库、按order_id分表那这两个字段就是分片键。分片算法则决定给定键值映射到哪个分片常见的有哈希取模、自定义表达式、时间范围等。分片策略则是把分片键和算法组合起来、加上对SQL条件等值、范围、IN的判断规则。ShardingSphere中常见的分片算法类型包括INLINE基于Groovy表达式的行表达式例如t_order_${order_id % 2}最轻量MOD取模哈希适合数字型分片键HASH_MOD对分片键先算哈希再取模适合字符串型分片键RANGE按区间路由适合时间、自增序列INTERVAL按时间间隔自动生成分片多用于日志类数据CLASS_BASED自定义实现类灵活但需要写代码。分片策略上标准分片策略只支持单个分片键的等值和范围路由复合分片策略支持多个分片键联合路由Hint分片策略则是强制不走分片键由调用方指定目标数据源。我们线上支付链路支付状态查询就是典型的Hint分片因为可能只知道商户号而表是按订单号分片的。理解这个层次后配置才不会乱。我见过有人把分片键配置成非查询条件字段结果大部分SQL都走了全路由等于没分片也有人的分片键本身是高基数字段但分布不均匀导致数据倾斜。分片键的选择是路由正确性和数据均衡的前提不要盲目选择业务主键。2.2 从解析到归并一条查询的完整旅程分片引擎处理一条SQL的完整过程抽象成四步解析、路由、改写、执行归并。我用我们线上一条真实查询来说明。SELECT order_id, amount, status FROM t_order WHERE user_id 1024 AND status 1 ORDER BY gmt_created DESC LIMIT 0, 20;第一步是SQL解析。ShardingSphere把这条SQL解析成语法树识别出查询的表名、条件字段、排序字段、分页信息。这里要注意SQL解析不是简单字符串匹配而是基于Antlr生成AST这也是为什么一些MySQL特殊语法可能不兼容。我们曾经因为SQL里用了Oracle风格的CONNECT BY写法导致解析失败后来才知道ShardingSphere的SQL方言支持需要匹配版本。第二步是路由。基于解析结果判断user_id是分片键1024按路由算法定位到具体的数据源和表。此时SQL被改写成只面向目标节点的形式。如果是没有分片键条件的查询就会做全路由把SQL广播到所有分片执行性能差异巨大。第三步是改写。比如上面这条SQL由于各分片的gmt_created排序是局部的最终结果必须跨所有分片归并所以引擎会自动把LIMIT 0, 20改写成LIMIT 0, 20并对每个分片执行取前20条最后在归并节点做全局排序再取前20。这个过程的代价是数据的多倍消耗。第四步是执行归并。归并类型分为流式归并和内存归并。ORDER BY LIMIT这种典型场景ShardingSphere会采用流式归并通过优先队列从各个分片依次拉取数据避免全部加载。但如果是GROUP BY聚合或者JOIN产生的中间结果就得内存归并数据量大时内存压力很大。这条链路看着不复杂实际跑起来如果你不了解归并规则很容易被“数据好像少了/多了”的问题骗到。我们曾经排查过一个分页跳页问题就是因为跨分片LIMIT深度翻页时DROP掉的数据在各分片间不稳定导致结果集偏移。深度分页在分库分表后基本是无解的只能靠游标或滚动ID方案绕开。2.3 分布式场景下订单号这类主键不能再靠自增单机MySQL用自增主键很舒服但分片后如果还用自增多个分片生成的ID必然冲突。即使每个分片设置了不同步长也有扩容时步长需要重新调整以及数据迁移后主键重复的风险。分布式主键要满足全局唯一、趋势递增、插入性能高的要求。ShardingSphere内置了几种主键生成器UUID、SNOWFLAKE以及默认的KeyGenerateAlgorithm接口。UUID实现简单但无序作为InnoDB主键会引发页分裂性能很差不推荐。SNOWFLAKE雪花算法是默认且推荐的选择64位长整型包含时间戳、机器ID和序列号趋势递增性能也足够。雪花算法有几个需要注意的细节worker-id必须保证不同实例唯一否则分布式环境下可能出现ID碰撞时钟回拨问题如果服务器NTP校时导致时钟倒退同一毫秒内可能生成重复ID对趋势递增要求严格的场景雪花算法不是严格单调递增跨毫秒时偶尔会出现“当前ID比上一毫秒小”的情况。我们生产环境的做法是自定义了一个基于雪花变体的KeyGenerateStrategy把worker-id放进注册中心管理同时在做数据库迁移时对老数据和新数据之间做了ID映射。如果你只是临时用默认配置务必确认worker-id没有重复尤其是多个服务实例部署在同一台机器时很多人就是在这里栽跟头。3. 读写分离与分片同时落地一套可复用的配置实战3.1 数据源、分片规则、读写分离规则的YAML配置很多用户在ShardingSphere 5.x落地时卡在配置上。5.x版本把规则统一成了YAML和DistSQL两种方式。我给出一个经历过生产验证的最小配置骨架你可以直接在此基础上改。dataSources: ds_0: dataSourceClassName: com.zaxxer.hikari.HikariDataSource driverClassName: com.mysql.cj.jdbc.Driver url: jdbc:mysql://192.168.1.10:3306/order_0 username: order_user password: ****** ds_1: dataSourceClassName: com.zaxxer.hikari.HikariDataSource driverClassName: com.mysql.cj.jdbc.Driver url: jdbc:mysql://192.168.1.11:3306/order_1 username: order_user password: ****** ds_0_slave: dataSourceClassName: com.zaxxer.hikari.HikariDataSource driverClassName: com.mysql.cj.jdbc.Driver url: jdbc:mysql://192.168.1.12:3306/order_0 username: order_user password: ****** rules: - !SHARDING tables: t_order: actualDataNodes: ds_${0..1}.t_order_${0..1} databaseStrategy: standard: shardingColumn: user_id shardingAlgorithmName: database_inline tableStrategy: standard: shardingColumn: user_id shardingAlgorithmName: table_inline keyGenerateStrategy: column: order_id keyGeneratorName: order_snowflake bindingTables: - t_order, t_order_item broadcastTables: - t_dict shardingAlgorithms: database_inline: type: INLINE props: algorithm-expression: ds_${user_id % 2} table_inline: type: INLINE props: algorithm-expression: t_order_${user_id % 2} keyGenerators: order_snowflake: type: SNOWFLAKE props: worker-id: 1这里有个关键点actualDataNodes的写法是ds_${0..1}.t_order_${0..1}表达的是两个数据源、每个数据源各两张表。如果你分库和分表使用的是不同字段比如按user_id分库、按order_id分表配置逻辑会复杂一些尤其是跨库查询绑定表时路由组合会成倍增加。读写分离规则需要单独配置rules: - !READWRITE_SPLITTING dataSources: order_rw: writeDataSourceName: ds_0 readDataSourceNames: - ds_0_slave loadBalancerName: round_robin loadBalancers: round_robin: type: ROUND_ROBIN需要注意读写分离的数据源节点名称和分片规则的数据源名称需要能对应上。如果你同时在分片库配置了读写分离建议先在ShardingSphere层面封装一层逻辑数据源避免规则间引用出现环路。我们改造初期就犯过这个错分片规则引用了物理主库和从库读写分离又指向同一套物理库启动时数据源加载顺序出问题报了一堆Circular dependencies。3.2 绑定表和广播表解决关联查询和字典表的性能隐患分库分表之后最尴尬的问题就是JOIN。如果t_order和t_order_item都按user_id分片但你没有声明绑定关系ShardingSphere进行关联查询时会做笛卡尔积路由也就是每张表的所有分片组合都可能被访问。两个库、每库两张表一次JOIN最多可能访问4组数据节点数据量被放大查询性能完全不可控。绑定表的作用就是告诉引擎这两张表的分片规则完全一致JOIN时只路由到相同分片。比如t_order_0只需要和t_order_item_0关联不需要再跨到t_order_item_1。配置方式就是我们在上面YAML里写的bindingTables。这个配置直接影响JOIN查询的命脉强烈建议所有具备相同分片键的关联表都声明为绑定表。广播表解决的是另一类问题字典数据。国家、币种、商户状态这类低频变化的小表在分片环境中如果每张分片表都放一份数据一致性很难维护如果只放在某个分片那其他分片上的查询会因为跨库JOIN失败或性能差而无解。广播表是把整表数据复制到所有分片数据源查询时本地路由即可写入时会广播到所有节点。配置方式就是broadcastTables。注意广播表的写入不再是单点事务应用层需要考虑幂等和部分失败的情况。我们维护的t_dict字典表就是广播表修改时通过admin后台先停服写入再用脚本逐节点核对。坦白讲广播表不适合高频更新数据如果字典表一天要改几十次建议用配置中心缓存替代别把修改压力打到数据库。3.3 读写分离后的主从延迟不能只靠加大Slave配置分片和读写分离叠加后第一个坑是主从延迟带来的脏读。订单支付成功后立即查询订单状态如果路由到从库而主从延迟还没追平可能查到未支付状态用户端立即投诉。这里没有一个万能开关能一劳永逸只能从路由策略和业务层同时下手。ShardingSphere提供了基于Hint的强制主库路由能力。凡是强一致要求的读请求可以设置HintManager.setWriteRouteOnly()让该线程内的SQL全部走主库。这个方案简单粗暴但它不是银弹如果全站都强制主库从库就废了。更常见的做法是结合业务做容忍度设计。我们订单查询接口分成两类用户端实时查询走主库或最多容忍2秒延迟管理后台报表查询走从库延迟十几秒可以接受。还有一个参考做法是延迟阈值判断比如从库落后超过N秒则临时切换到主库但这个在ShardingSphere里需要自己实现官方没有开箱即用的策略。主从延迟本身也要配套监控。MySQL的SHOW SLAVE STATUS里Seconds_Behind_Master是基础指标但我们实测这个值在某些大事务回放时可能显示0但实际延迟仍然很高最好再结合从库当前时间和主库binlog位点的差值做校准。生产环境我们使用PrometheusGrafana按主从复制秒级延迟画图告警阈值设多少取决于业务容忍度一般5秒以上就要重点排查。4. 分布式事务从本地事务到Seata边界该怎么划4.1 本地事务、XA事务、柔性事务的适用边界ShardingSphere分片之后原来单库的ACID事务被拆散到多个物理节点分布式事务问题随之而来。ShardingSphere没有自己造一个完整的事务引擎而是规范了一整套事务管理器SPI本地事务、XA事务、基于Seata的柔性事务。本地事务就是每个分片各自提交不做跨节点协调。它只保证单分片内的原子性一旦业务涉及跨两个分片更新一部分成功一部分失败数据就会出现不一致。这类事务适合接口要么单分片访问、要么事后能接受对账补偿的场景。我们的用户积分累计接口就是纯本地事务因为每个用户的积分都在同一个分片内天然不会跨节点。XA事务是强一致方案基于两阶段提交ShardingSphere可以接入Atomikos、Narayana等事务管理器。代价是性能损耗显著锁等待时间变长尤其是分片数多、关键链路热点高时XA的commit协调会成为瓶颈。线上交易链路我们基本不碰XA只在日终批处理这种对实时性要求不高、但数据一致性要求极高的场景使用。柔性事务核心是最终一致性Seata是ShardingSphere官方支持最好的实现。通过AT模式自动补偿业务上表现为先执行分支事务、再全局提交或回滚。性能介于XA和本地事务之间但对中间状态的业务容忍度有要求。比如下单减库存如果扣减成功了但后面优惠券核销失败整个事务回滚期间用户会看到一笔短暂“异常”的记录但最终会恢复。三种方案的取舍直接对照下面这张表就不会选错事务方案一致性性能损耗适合场景本地事务弱无单分片访问、可异步补偿XA事务强一致高批处理、低频强一致Seata柔性事务最终一致中高频核心交易、业务可中间态4.2 我们项目里对Seata的实际取舍订单服务刚拆分的头三个月我们对“下单”这个入口用的是Seata AT模式。流程涉及订单分片表、账户余额表、库存表三个数据源任何一步失败都需要整体回滚。Seata集成ShardingSphere本身不难引入对应的starter配置事务组和Seata Server地址即可。但真实跑起来会碰见几类问题。AT模式需要为相关数据表创建undo_log表并且所有分片库都要建扫库漏建就会出现“branch transaction rollback failed”的一堆脏数据。另外全局锁竞争是个大坑AT模式在一阶段提交时会持有全局锁热点商品库存只有一条记录大促时大量事务都等这行锁Seata Server所在机器CPU直接被打满。我们后来把库存扣减从数据库事务里挪了出来改成异步消息Redis预扣减主流程只保留订单和账户的一致性账户扣款又经常是单分片本地事务所以最终整个下单链路从Seata全局事务里剥离了大部分节点。这件事给我的教训是不要为了分布式事务而分布式先把事务边界缩小能设计成单分片本地事务的绝不强上全局事务。如果非要给一个评估建议先用业务逻辑梳理能不能做到“大部分操作单分片完成跨分片操作异步补偿”这是成本最低的确认搞不定再考虑Seata或XA。分布式事务本身是最后的保险丝不是第一选择。5. 实测中的性能表现与调优经验5.1 结果归并是分库分表后最大的隐形开销分库分表看似把数据拆小了但实际上如果查询没有严格命中分片键引擎要把所有分片的结果汇总后再做全局排序、过滤、分页这个归并过程往往是性能黑洞。我们对比过同一业务查询在拆分前后的表现单表1000万行时按索引查询响应约120ms拆分到4个分片后带分片键的等值查询降到35ms左右这是理想情况但不带分片键的跨分片聚合查询响应从原来的300ms直接跳到800ms以上分片越多越明显。所以性能调优第一条永远是让业务查询尽量命中分片键。RD和架构评审的时候要反复强调任何不带分片键的核心查询都是潜在的慢查询。哪怕你是按用户ID分片但要按商户查历史订单这就是跨分片扫描数据量和分片数几乎线性放大。针对必须跨分片的聚合场景两个优化方向。一个是让ShardingSphere做部分下推比如把SELECT字段收窄、提前过滤掉不需要的分片另一个是业务侧做预聚合定时把报表数据汇总到单表再供查询。我们在商户订单报表里就是这么干的离线任务每5分钟聚合一版数据报表查询走汇总表避免实时跨分片聚合。5.2 分片数、连接池和路由缓存的关系分片数和数据库连接池的配置强相关。ShardingSphere-JDBC是应用内直连每个逻辑数据源背后都对应一组物理数据源连接池大小如果还按单库的经验配置分片后连接数会成倍膨胀。我们运维踩过的坑就是默认HikariCP连接池50但分片后每个服务实例可能会创建多个物理数据源50乘以分片数连接数很快就打满MySQL的max_connections。经验公式是每个分片的最大连接数控制在10到20之间总连接预算乘以服务实例数要小于数据库max_connections的一定水位比如最大使用到80%。配置要结合QPS的毛刺来调不是越多越好。路由缓存也是容易被忽视的点。ShardingSphere的路由结果默认会缓存如果分片规则在运行期不变化缓存命中率很高但如果你的分片键算法依赖外部参数比如商户等级动态切换路由目标那么缓存可能造成路由过期。我们当时为了做数据迁移把一个分片的数据临时导到另一个库结果由于路由缓存没有刷新部分流量还是打到老库。这个问题在5.x版本可以通过DistSQL动态刷新规则但要确认缓存清理机制是否符合业务预期。5.3 一次线上慢查询抖动最后定位到分片不均有一次线上报警系统显示订单查询接口P999持续恶化从200ms涨到1.2秒。初步排查各分片负载发现ds_1明显比ds_0高SQL数量和CPU都差了一倍多。当时第一反应是某个热点商家在做活动但追踪后发现实际是分片键选择导致的数据倾斜。我们的表设计里t_order按user_id分片但平台有很多B端大客户一个user_id下挂了几百万订单这些订单全部落在同一个分片相当于分片键基数很高但分布极不均衡。大客户一活动热点分片就被打满其他分片闲得发慌。这个架构性问题的修复不是加机器能解决的因为单分片内部本身还是单库压力。最后的处理流程分两步先给大客户做拆分压测把客户维度的订单单独抽出来放到冷表实时热表再按user_idorder_id双键重新路由然后把分片键从纯user_id改为user_id和order_id的复合策略保证单客户数据也能打散到多个分片。调整之后才把P999降回400ms以内。这个案例最值得警惕的是分库分表能在物理上扩展数据量但不代表它能解决所有热点问题。分片键设计之前先做数据分布评估特别是二八分明的业务不要想当然按单字段分片。6. 避坑清单ShardingSphere配置与运维里那些不说你会踩的坑6.1 字典表、历史表这类非分片表忘了广播会导致查询结果残缺分片库的规则配置里如果你新建了一张维表但没有把它加到broadcastTables那么涉及这张表的SQL会默认尝试全路由结果可能表现为数据缺失。我们有一次新上线一个活动标签配置表运维同事只在其中一个分片库里手动建了表没走分片规则也没有广播配置结果所有跨分片查询直接报“table not exists”。更坑的是部分老接口因为SQL路由命中了建表成功的那一个分片看起来一切正常只有命中其他分片时才报错。正确做法是分片环境中任何新表都要明确其归属——是分片表、绑定表还是广播表上线SQL必须统一通过自动化脚本在所有目标节点执行。每次变更前先跑一遍SHOW TABLES对比所有分片的数据结构。这种事情排查成本很低但出问题时的表象很迷惑。6.2 5.x版本升级里最容易忽略的配置兼容变化ShardingSphere 4.x到5.x的配置风格变化很大。4.x用逻辑表名加${}表达式5.x默认规则由JSON改成YAML且带!SHARDING这种类型标签。我们踩过的坑是升级后遗留的4.xspring.shardingsphere配置前缀被5.x直接忽略应用启动后没有加载任何分片规则所有SQL直连了第一个数据源差点酿成数据错乱事故。升级建议给三条第一先在一个测试环境完整回归所有SQL尤其关注JOIN、子查询、分页这些复杂语句ShardingSphere 5.x的SQL解析能力虽然更强但改写规则有差异第二5.x的读写分离与数据加密规则不再共用一套配置结构原来一把梭的写法会直接报错第三用DistSQL在线修改规则后必须检查新旧规则是否一致DistSQL不会自动做向下兼容的默认值填充。另外注意5.x的时序数据库、联邦查询这类新特性不要盲目启用先看是否匹配你的存储层版本否则容易引入非预期的路由行为。6.3 监控要按分片维度做而不是只盯整个集群分库分表之后DBA如果还按“一个MySQL集群”的视角做监控早晚要出事。因为一个分片慢不代表整个集群慢一个分片的连接数打满也不会体现在集群平均值里。我们的监控体系最后落地的三个维度是按物理分片的MySQL关键指标、按逻辑表/数据源维度的ShardingSphere指标、按业务接口维度的分片路由耗时。具体指标包括每个分片的QPS、慢查询数、临时表创建数、连接池活跃数、主从延迟。ShardingSphere 5.x提供的metrics接口能暴露每个逻辑表的查询总数、路由到每个物理数据源的次数这些能直观反映路由是否均衡。如果你发现某个分片的路由次数长期明显高于其他分片尽早检查分片键设计不要等线上被打爆了再排查。提示分片环境下所有告警阈值都要按分片单独设置比如某个分片的活跃连接数超过MAX的70%就该预警而不是等整个集群平均到30%才报警。最后说一个我现在的个人底线引入ShardingSphere这类中间件一定要把“分片键不可变”“SELECT必须默认带分片键”“新表上线走统一脚本”这三条写进开发规范。倒不是技术做不到更灵活而是分库分表属于高维护成本架构业务侧约束越严格线上踩坑的概率越低。这套架构我们跑了一年多最深的感受是ShardingSphere本身非常能打定位问题往往都出在业务使用姿势上。希望这篇把自己项目里的细节都交代清楚后能让你在MySQL高性能架构这条路上走得稳一点。
返回列表