☰
十亿数据下的ShardingJDBC实战:从路由卡顿到性能润滑的调优指南
2026/10/17 14:00:52 网站建设 项目流程

十亿数据这个体量,绝大多数团队不是一口气碰上,而是像我们一样,某天看数据库容量报表才发现单表已经涨到三亿、五亿,查询开始明显变慢,然后才把ShardingJDBC请进来。当时我们推进的是一个"模拟项目X"的资金流水库,存量数据来到十亿之后,ShardingJDBC在面儿上能跑,但线上时不时冒出一两条慢查询日志,凌晨跑批还会卡十几分钟。这篇文章就是整理我们这段时间是怎么把ShardingJDBC从"能跑"调到"润滑"的,分享一些踩坑和调优思路,尤其适合那些正在做分库分表但没想清楚下一步怎么优化的团队。

1. 十亿数据面前,ShardingJDBC真正卡住的三个环节

先说结论:十亿数据量下,ShardingJDBC的慢,绝大多数不是数据库引擎本身慢,而是下面三件事拖了后腿——路由计算、连接复用、归并排序。很多人一开始都盯着SQL消耗,结果把系统里里外外扒了一遍才发现瓶颈全在前面这几层。

1.1 路由计算:一条SQL到底要打多少个分片

ShardingJDBC和MyCat最大的区别在于它是客户端分库分表,应用进程直接和多个数据库实例建连接。ShardingJDBC拿到一条SQL之后,会先做解析、改写、路由,最后生成真正要发往各个分片的执行SQL。分片键一旦被函数包裹、参与运算,或者SQL里压根没带分片键,那它就只能走全路由——把所有分片都扫一遍。

我们刚开始有段时间,业务查询很多是"根据用户ID查最近订单",这个没问题。但有部分运营查询是"按商户查询今日流水",分片键是user_id,商户ID不是分片键,结果每来一次这样的查询,16个分片全被拉一遍。虽然每个分片的数据量在3000万左右,单库查询也就一百多毫秒,但16个分片并发查完再归并,整体要一秒钟以上。这种查询在非高峰期偶尔用还好,一旦白天运营高频点击,慢查询日志瞬间被刷满。

这里要提醒的是,ShardingJDBC的路由日志非常重要。打开配置里的sql-show开关,或者让DBA把连接日志拉出来看,能看到每一条逻辑SQL实际拆成了多少条真实SQL。如果一条查询被拆出十几个分片,那就是需要重点优化的信号。

1.2 连接管理:分片一多,连接池先被卡住

ShardingJDBC自己管理多数据源,但它还是复用底层连接池。我们最初用的是默认配置,连接池最大大小写得偏小,单个分片也就10个连接。结果促销活动一上来,并发请求合理地分散到16个分片后,感觉每个分片压力不大,但连接池却在疯狂等待获取连接,很多请求直接超时。

这个道理有点像商场结账,收银台数量固定,但购物车太多,每个顾客都要排队。你在每个收银台分一个人看着不多,可人流全涌进来之后,队伍就开始堵在门口。连接池的等待时间会反映在Tomcat线程上,线程池再被占满,整个应用都会跟着卡顿。

后来我们把HikariCP的maximumPoolSize调大,同时把连接池的最小空闲连接也保持在一个合理水位。不能用默认值硬扛16个数据源,每个数据源至少要根据平时TPS估算连接数。我们调整之后,高峰期基本不再出现获取连接超时。

1.3 归并排序与深度分页:真正的无底洞

这是十亿数据下最容易被忽视的坑。单表数据少时,普通的order by + limit 100走索引很快;但在分片环境下,order by需要每个分片先把符合条件的记录排序,然后把top N结果返回到ShardingJDBC的内存中再整体排序。数据量一大,这层"归并排序"就特别吃内存和CPU。

我们的案例是用户端的分页查询,一页只显示20条,用户点了第100页之后,MySQL这边在每个分片都limit 2000,然后ShardingJDBC把总量几个分片乘以2000条记录全部放在内存里排序,最后再取20条。点得越往后,内存中堆积得越多,偶尔还会碰见内存溢出的情况。

这个问题的解法不是调大JVM内存,而是尽量避免深度分页。后面会单独写我们怎么用游标和自定义分页参数来绕开这个坑。

2. 分片键与分片策略:前期定错的成本是后期最大的债

聊调优之前,必须把分片策略这块掰开揉碎。因为后面踩的很多坑都能回溯到"分片键选得不够准"和"分片数规划得太随意"上。如果你还没上线,请多花一周时间来验证分片键;如果已经上线了,也请认真评估是否要做迁移。这比调一百个参数都值。

2.1 怎么选分片键:访问模式比数据均匀更重要

当时给我们这个资金流水库选分片键时,团队里第一反应是"用流水号ID做hash,数据肯定均匀"。均匀性确实好,但业务访问却并不总是带流水号来查。用户最常做的是"查我名下最近的流水"和"查某笔订单关联的流水",几乎都围绕user_id和order_id。

如果按流水号分片,一次"查用户最近流水"的请求就会落到所有分片,系统需要等所有分片查完再做归并。数据均匀的收益全被路由压力抵消了。所以最终我们把分片键换成了user_id,虽然不同用户的数据量不见得完全均等,但绝大多数查询都能直接定位到某几个分片,路由效率立刻高了一个量级。

关于数据倾斜,可以通过二次分片或者把"热门大商户"的数据单独放一个分片集来处理。倾斜不可怕,可怕的是所有查询都在全分片路由。

2.2 分片数量与容量规划:用"膨胀系数"估算

分片数量不是越大越好。很多团队以为分片越多,未来扩容越方便,结果把库分成了64片、128片。实际上分片越多,连接数越多,路由和归并成本也越高,甚至可能因为某些单分片故障导致整体可用性下降。

我们的做法是用一个"膨胀系数"来做估算。假设单表数据量涨到1亿,单库能健壮支撑的合理数据量是2000万到3000万,那么分片数大致等于 1亿 / 2500万 ≈ 4。考虑未来三年的增长,再乘上一个系数,最终定到8到16个分片。不要一上来就搞64个,这样运维成本和硬件投入都会失控。

具体到ShardingJDBC,分片数量配置在分片算法里。如果你用inline表达式,可以直接写ds_$->{0..15},共16个分片。如果未来需要扩容,sharding算法改成自定义的哈希取模即可,但对存量数据的迁移要提前准备好方案。我们当时选择16个分片,有两个原因:一方面是数据量算出来的,另一方面是16个分片对应4台物理实例,每台上4个库,迁移和扩缩容都留有余量。

2.3 分片算法选型:hash、range还是时间分片

这三种模式各有适用场景:

算法优点缺点适用场景
hash取模数据相对均匀,写入分散范围查询需路由全分片用户维度的点查和更新为主
range分片方便范围扫描,可按段归档热点容易集中在新范围按时间归档、流水明细追加
时间分片天然贴合历史数据,数据可冷热分离热数据集中在最近分片日志、订单、流水时间维度明显

我们的资金流水表最终用的是"以user_id hash为主、以create_time做二级路由"的复合思路。ShardingJDBC支持多分片键,但并不是所有查询都能同时命中,所以一定要设置一个"必须路由键"的兜底规范。比如所有查询都必须带上user_id,否则拒绝执行;运营要按时间查,就走专门的数据分析库,不直接打到分片库上。

这里有个关键细节:分片键在SQL中的写法会影响路由。例如WHERE date_format(create_time, '%Y%m%d') = ?这种写法,ShardingJDBC是没法识别出分片键的,因为分片键被函数包住了。同理,在字段上做隐式类型转换,也会导致匹配失败。这正好引到后面的踩坑案例。

3. 连接池、批量写入与事务边界:让引擎本身"热"起来

分片策略解决的是"查哪"的问题。再往下,连接池和事务配置解决的是"怎么让ShardingJDBC的底层执行更顺畅"的问题。这部分改起来最琐碎,但收益往往立竿见影。

3.1 数据源参数调优:HikariCP与多数据源的配合

ShardingJDBC底层走的是标准DataSource接口,我们用的连接池是HikariCP。多数据源模式下,每个分片库都会创建一套连接池,参数如果沿用单库时代的配置,很容易出现"每个分片都缺连接"的假象。

几个关键参数可以这样调:

  • maximumPoolSize:单分片并发不高时,建议设在CPU核心数的2倍+1左右,不要盲目设到50、100。每个连接都是资源,分片多时总量会指数放大。
  • minimumIdle:设为maximumPoolSize的一半到三分之二,避免突发流量时才去建连。
  • connectionTimeout:通常保持默认30000ms,但如果应用侧超时时间更短,需要同步调低,否则连接池还在排队,上层线程已经放弃了。
  • maxLifetime:建议小于数据库的wait_timeout,比如设1800000(30分钟),防止数据库端主动断开后连接池还在复用失效连接。

改这些参数时最好做一次压测。我们当时在测试环境模拟了高峰期并发,用Jmeter把用户查询、写入、批跑三种流量都打了一遍,对比不同连接池配置下的P99耗时,最后确定每分片maximumPoolSize=20,16个分片一共320个连接。这个数字不高,但足够用了,因为大部分操作都能精准路由到一两个分片。

3.2 批量写入的正确姿势:几步就提升十倍性能

十亿数据不只是查出来的,更是一条条写出来的。导入历史数据那段时间我们最痛苦,每天要写几百万条流水到16个分片。刚开始用的逐条insert,每秒只能写几千条。后来把连接地址加上rewriteBatchedStatements=true,同时把应用里的批量操作改成真正的批量SQL,写入速度直接翻了十倍。

这里有个很容易踩的坑:MySQL驱动需要开启rewriteBatchedStatements,否则addBatch只是伪装成批量,实际还是逐条发SQL。而ShardingJDBC对批量SQL的处理比普通JDBC更复杂,分片键必须在批量语句中保持一致,否则一次批量操作会被拆散到多个分片。我们后来统一要求批量写入时按user_id分组,同一用户的数据打包成一个batch,既保证路由稳定,又能充分利用驱动优化。

批量写入还有一点和普通单库不同:ShardingJDBC会把一条insert语句按照分片结果改写成多段SQL,分别发往对应分片。如果我们自己计算好分片,也可以选择直接通过分片数据源直连数据库做低层写入,把ShardingJDBC只作为路由层。不过这种姿势对代码侵入很大,后期维护成本高,除非是跑批,否则不推荐。

3.3 跨分片事务:能用最终一致性就别用强一致

分布式事务是分库分表下最头疼的问题。ShardingJDBC集成了Seata、ShardingSphere事务管理器,但跨分片强一致事务的性能损耗很大。我们刚上线时有个"创建流水并更新余额"的神操作,天天跑出分布式事务异常,后来发现业务本身并不需要严格实时一致,改为先写流水,再异步补偿余额,问题立刻消失。

如果你的业务确实需要强一致,至少要理解:ShardingJDBC中的分布式事务是通过XA协议实现,在本地事务基础上多加了协调流程,网络开销翻倍。能用本地事务绑定的数据,尽量放在同一个分片内。比如"某用户的所有订单和余额"都按user_id分片,那该用户的多条更新其实都落在同一个分片,天然是单库事务,根本不需要跨分片。这个设计看起来老套,但在十亿数据场景下特别重要。

4. 查询优化:把"全分片扫描"打成"得分片快扫"

分片键和连接层都顺了之后,还可以在查询路径上做几层优化。核心思路就一句话:让每条SQL尽可能多地命中分片键,让落不到分片键的查询走旁路。

4.1 强制分片键的SQL规范与限制

我们在应用层做了一个"SQL路由规范"的拦截,主要做了三件事。一是按用户ID查询时,强制带上user_id_ = ?,否则打回重写;二是按订单ID查询时,同时在映射表里找到对应的user_id,再拼进SQL;三是不带任何可路由条件的操作,禁止直接调用分片库,统一走只读分析库。

有人会担心多一次映射表查询不是更慢吗?实际上这个开销远小于全分片路由。映射表在最开始设计时就作为分库分表的"路由元数据",存在Redis里,查询一个user_id对应的分片号基本是亚毫秒级别,比全库扫描快几个数量级。

如果团队一开始没有维护映射表,也可以使用ShardingJDBC的hint功能强制路由。比如在ThreadLocal里设置分片值,然后执行SQL,ShardingJDBC会优先使用hint里的分片路由。比如:

HintManager hintManager = HintManager.getInstance(); hintManager.addDatabaseShardingValue("order_flow", 42); hintManager.addTableShardingValue("order_flow", 42); // 执行查询 hintManager.close();

这种方案适合分片键字段在SQL里被隐式隐藏的场景,但别滥用,hint里写死了分片,后续一旦分片规则调整,这里也要跟着改。

4.2 Like查询和模糊匹配:别让ShardingJDBC硬扛

十亿数据的模糊查询是任何分库分表方案都绕不过的坎。ShardingJDBC不像搜索引擎那样维护倒排索引,like '%关键词%'这种语句只能用全分片扫描。我们这边有个合作的运营系统经常按"收款方名称模糊查流水",刚开始直接查询分片库,慢到几乎不可用。

我们尝试过两种处理方式。一种是把查询条件加上前缀匹配,比如like '张%',配合分片键user_id,可以把扫描范围缩到几个分片。另一种干脆把模糊搜索全部迁移到Elasticsearch里,只保留用户ID、金额、时间等稳定字段,然后回表到分片库捞原文。前者适合简单的名字匹配,后者适合复杂全文搜索,两者不冲突。

这块还要注意一个细节:ShardingJDBC在做结果归并时,跨分片的聚合函数、去重和排序都是内存操作。类似select count(*) from order_flow where remark like '%活动%'这种查询,每个分片都要遍历一遍,再把计数汇总。表面上SQL挺小,实际背后的IO和CPU非常可观。如果想做这种统计,建议先离线把数据同步到分析型数据库,再跑分析任务。

4.3 分页、排序的工程处理:游标代替limit

前面提到深度分页的问题,我们最终采用了两套方案。

第一套是业务允许的情况下,用"上一页最后一条记录的ID"代替偏移量。比如用户想看第100页,客户端会传入上一页最后一条的主键ID,SQL变成where id > ? order by id limit 20,这样每个分片只需要取20条,归并压力瞬间降低。这种方案适合数据按时间或ID有序排列的场景,比如流水列表、通知列表。

第二套是做一些运营数据导出场景时,不提供精确分页,改用分批拉取。写一个批量任务,按create_time区间把数据分成一小块一小块处理,每块几千条,处理完之后记录游标位置。整个过程不依赖ShardingJDBC的归并排序,性能稳定,也不会把内存撑爆。

如果实在无法避免深度分页,后台可以通过LIMIT的offset做粗略估算,但在十亿数据量下,还是建议产品上做交互改造,比如"加载更多"按钮取代页码,或者只允许查看前N页。这不是技术妥协,而是在海量数据场景下合理的体验设计。

5. 实战踩坑:路由失效、广播表没同步、分页慢到30秒

前面讲了很多理想方案,但真实运营中一定会遇到几个"看起来配置没问题但还是慢"的案例。这里挑三个我们实际踩过、排查了挺久的坑,完整还原一下排查链路。

5.1 案例一:字段类型不一致导致分片路由失败

有次线上加了一个查询"根据手机号查最近流水",用了user_id作为分片键,理论上没问题。结果一上线,所有这类查询都走了全分片路由,日志里明明打了where user_id = 135xxxx,分片数还是16。

排查链路:先在ShardingJDBC配置里打开路由日志,发现逻辑SQL被拆成了16条真实SQL,说明分片键没有被识别。然后检查表结构,发现order_flow表里user_id列是varchar,但应用里面传的是Long类型。ShardingJDBC对分片键做路由判断时,会把SQL中的值和分片键表达式做比对,类型不一致导致精确路由失效,最终退化成全路由。

把应用侧参数从Long改成String,重新测试后,路由日志立刻变成1条。这个坑很隐蔽,因为普通查询传错类型时数据库也能正常处理,无非是隐式转换,但到了分片路由层就会被无情打回。建议所有分片键字段保持同一个Java类型和数据库类型,最好直接定义成一个公共常量。

5.2 案例二:广播表数据不一致

我们的系统里有一张"商户信息表",行数不多但有更新,配置成广播表。最初测试环境一切正常,到了生产环境却偶尔出现"用户A在分片3查到商户名字是旧名称,用户B在分片12查到新名称"的情况。

排查时惊觉,直接原因不是ShardingJDBC的广播逻辑失效,而是部分内部工具脚本为了省事,跳过ShardingJDBC连接直连了某个分片库,只更新了那一张表。广播表在ShardingJDBC中的正常更新会被同步到所有分片,但绕过它之后,数据自然就裂了。

我们后来做了两层防护:一是所有核心配置表的更新只能走同一个管理服务,不允许研发直接连库改数据;二是加了一个巡检任务,定时对比各分片间广播表的数据指纹,发现不一致立刻告警。如果你也在用广播表,记得把"绕过路由直连数据"这个场景写进规范里,否则数据不一致几乎必然会出现。

5.3 案例三:深度分页让归并层内存吃紧

有一次凌晨跑批任务卡了30秒,定位到是一条分页查询:order by amount desc limit 100000, 20。这条SQL把所有分片都拉起来,每个分片先取100080条,再return到ShardingJDBC内存排序,最后丢掉外面的100000条。当时把JVM堆从8G调到12G也只是缓解,没有根治。

而且这个任务本身是"导出前10万+条结果",产品认知是导出,不关心页数。于是我们把导出逻辑改成分批流式拉取,用游标每次取2000条,循环拉完。没有深度分页,内存占用一下降到几百兆,跑批时间也缩到几秒。经验总结:一旦看到分页查询的offset特别大,立刻想能不能改成游标或流式;不要试图靠内存硬扛。

5.4 排查链路:慢SQL→执行计划→路由日志→底层连接

上面这些坑,排查时有一个通用链路可以复用:

  1. 先抓慢SQL,看应用里是哪个接口发出来的。
  2. 用ShardingJDBC的路由日志确认这条逻辑SQL被拆成了多少条物理SQL。
  3. 如果拆出来的分片数大于预期,去看分片键是否匹配、类型是否一致、有没有函数包裹。
  4. 如果分片数正常但依然慢,去底层数据库看执行计划,确认是不是索引失效、锁等待甚至磁盘IO在高位。

这套流程最好固化成自动化工具。我们在生产环境做了一套简单的"慢SQL自动路由分析",每次抓到慢SQL后自动回放路由日志,并附上分片数和底层执行计划,研发同学省去了手工对比的步骤。排查效率至少翻倍。

6. 容量预警与巡检脚本:让十亿数据持续"润滑"

优化不是一锤子买卖,十亿数据之后还在涨。我们需要建立一套持续的健康检查机制,否则数据量继续翻倍,前面这些调优点会逐渐失效。

6.1 分片数据倾斜监控

数据倾斜是个慢变量,可能某天某个商户集中上传,某个分片的数据量突然涨到其他分片的1.5倍。这种倾斜如果不处理,单个分片会先达到瓶颈,连带影响这个分片上的所有用户。

我们每个小时会跑一次巡检,收集各分片表的行数、容量、自增ID水位,然后用一个简单的阈值来判断倾斜度。比如最大分片行数 / 平均分片行数 > 1.2就告警。倾斜后一般有两种处理:暂时性的活动高峰可以通过限流或缓存顶过去;持续性的倾斜则要考虑"热点商户单独建表"或者做迁移。不要等到磁盘报警才去看。

6.2 只读扩展与读写分离

十亿数据读多写少时,可以考虑把只读流量迁到从库。ShardingJDBC支持读写分离配置,但要注意的是"主从延迟"。写入时刚提交的事务,读从库可能看不到。我们为这个专门做了一个短时的"写后读一致性"控制:刚写入的数据,如果需要立刻读取,走强制主库路由;等延迟窗口过去之后,再正常走从库。

这种配置看着简单,实际需要业务接口配合。我们用一个注解标识"必须主库读",比如订单支付后立刻查支付结果。别的查询普通走从库,性能会好很多。

6.3 数据归档与冷热分离

十亿数据里真正被频繁访问的,通常只有最近三个月的数据,再往前的历史数据偶尔会被业务方翻出来对账。所以我们在分片库之上又加了一层"归档策略":定期把超过半年的流水迁移到冷库,冷库里可以是单库大表,也可以是另一个分片数更少的集群。

归档时的坑在于,跨库迁移必须保证全局一致性,否则对账对不上。我们用的是一个简单的"双写标记"方案:迁移任务扫描数据,把记录标记为迁移中,写进冷库,校验条数和金额,最后再删除原分片数据。整个过程对业务无感,但还是建议先小范围灰度跑几次。

6.4 巡检脚本里我最后悔没早做的事

我们上线两个月后,才发现巡检脚本里最该监控的其实是"慢路由数",而不是慢SQL数。有些SQL本身不慢,但被路由到了全分片,导致整体资源消耗高。我们后来把"命中分片数"写进日志,并按天统计平均值。一旦某天某个查询的平均命中分片数异常上升,基本上就是在提醒分片键开始失效了——要么数据倾斜,要么SQL写法被改动,要么分片键字段类型被调整。这是早期发现问题最有效的一个信号。

最后,说点我自己的体会

用了大半年ShardingJDBC处理十亿数据,最大的感触是:这个框架在中小数据量下很轻盈,但数据量一旦上来,能不能跑得润滑,关键是看我们有没有把"路由规范"和"运维体系"建起来。分片键、分片数、连接池、SQL规范,这些东西像轴承里的滚珠,单个看着不起眼,少了任何一颗都会卡壳。

我们也曾想过要不要换更重的中间件,但后来发现,大多数慢并不是框架不行,而是我们对业务查询模式的理解还不够彻底。把分片键真正切进业务访问路径里,把路由日志当成日常观测指标,把批处理和游标用熟练,十亿数据并没有想象中那么可怕。

如果你们也正在这个仓位上挣扎,我建议先从路由日志开始看。把每一条慢SQL的分片命中情况拉出来,你会很快知道该从哪儿下手。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询