1. 先把问题定义清楚慢在哪值不值得并发我第一次碰到“java多线程查询数据库分段查询”这个需求是在做一个离线报表导出功能的时候。一张订单明细表两千多万行业务侧要求按条件导出全量数据做对账。单线程游标扫一遍实测跑了六分多钟中间还因为单个查询返回的结果集太大应用侧内存直接顶到上限被强制退出。后来拆成分段并发查询同样的数据量压到了四十秒左右。但这个过程并不是“多加几个线程就完事”那么简单——中间踩过连接池被打爆、分页数据重复、结果顺序错乱、线程池队列无限堆积各种坑。所以这篇东西我想讲清楚的是**java多线程查询数据库分段查询**到底是什么、什么情况下值得用、线程数和连接池之间那个容易被忽略的比例关系、分段键怎么选、结果怎么合并以及并发读带来的数据一致性陷阱。适合已经会写基础 JDBC 或 ORM 查询、手上有百万级以上数据要处理的同学也适合刚接触线程池、想搞清楚参数背后逻辑的新手。先明确一点分段并发查询不是“性能优化银弹”它是用数据库的并行处理能力换应用侧的等待时间。它的本质是把一个大查询拆成 N 个互不重叠的小查询交给 N 个线程同时打出去再把结果拼回来。这里有个非常重要的前提——数据库本身有富余的处理能力。如果你的数据库 CPU 已经跑在 80% 以上或者磁盘 IO 已经饱和那么并发查询只会让每个请求都变慢整体效果可能还不如老老实实串行。我在一个读库负载本来就偏高的环境里试过一次16 个并发线程下去单段耗时从 12 秒涨到 47 秒总耗时反而变长了。这就是典型的“应用侧着急、数据库侧扛不住”。所以动手之前先问自己三个问题第一瓶颈到底在不在数据库往返如果慢的原因是应用侧序列化、或者是把几百万行全部塞进 List 造成的 GC 压力那并发查询解决不了问题你需要的是流式处理或者边查边写。第二这段数据的查询条件能不能被切成互不重叠的区间不能切就只能想别的办法。第三业务能不能接受并发读带来的弱一致性多线程分别开事务读到的数据快照时间点不完全一样如果你的对账逻辑要求严格的时间点一致那这条路本身就有问题。这三条有任何一条不满足我建议先别上并发。2. 分段策略选型四种切法分别适合什么场景“分段”这两个字是整个方案的地基。分段分得不好会出现个别线程扛了几百万行、其余线程几十毫秒就返回的情况并发等于白做。我实际用过的切法主要有四种各有各的脾气。2.1 主键游标切分均匀性最差但最稳的一种最常用的是按自增主键区间切。先查一次select min(id), max(id) from t_order where 条件拿到上下界然后按段数均分区间每个线程负责id from and id to。注意这里是左闭右开我在第一版里写成双边闭区间结果段与段的交界行被重复查了出来汇总时数量对不上排查了半天才发现是边界问题。这种切法的好处是每个子查询都能走主键索引条件极其简单where id ? and id ? order by id执行计划稳定不会因为参数不同而跑偏。坏处是均匀性依赖主键的连续性。如果这张表中间被大量删除过数据id 空洞很严重那么均分 id 区间并不等于均分数据量。我遇到过一张表 max-min 有八个亿实际只有九百万行按区间均分后有一段基本是空的另一段扛了三百多万行。解决办法有两个。一是在切分前先做一次抽样比如select id from t_order where 条件 order by id limit 1 offset N用几个分位点拿到近似的真实分布二是按主键区间先粗查 count再决定每个区间的粒度。第二种更准但多了一次全表条件扫描成本得自己权衡。我的经验是数据量在千万级以内、且删除操作不频繁的表直接均分就够用如果表被大量物理删除过老老实实做抽样。2.2 取模哈希切分均匀但代价藏在细节里按某个字段取模比如where mod(id, 8) 3或者用时间戳的哈希值分组。这种切法理论均匀性最好每个线程拿到的量基本一致。但我后来基本不用了原因有三个。第一mod()是函数运算绝大多数情况下会让索引失效变成全表扫描加过滤。八百万行的表全表扫八遍比串行一遍还慢。第二取模的段数一旦确定中途没法动态调整比如你想从 8 段改成 12 段条件全变了。第三某些数据库对函数索引的支持有限建了也用不上。如果非要用哈希思路我更倾向于在应用侧算好边界值转成区间条件而不是把函数丢进 SQL。有一类场景哈希切分确实合适查询条件是等值匹配的枚举字段比如按商户号尾号切分业务上天然能分成固定的几组而且每组数据量相对均衡。这种时候直接拼where merchant_id in (...)列表走索引也没什么问题算是一种折中的“逻辑分段”。2.3 时间维度切分报表场景的首选做报表、对账、日志分析这类业务时间字段几乎必然存在按天、按小时切片是最好用的。理由很直接业务语义清晰、数据本身按时间堆积得比较均匀、每个子查询都能命中时间索引而且切片的边界天然具有一致性含义——比如“统计昨天一整天的数据”切成 24 个小时段并发查每段之间的边界是明确的业务时间点汇总结果不存在跨快照混乱的问题。我现在的报表类需求基本都是这个套路先根据时间范围算出切片列表然后提交给线程池最后按时间顺序合并。注意切片要防两个坑。一是时区如果库里存的是 UTC 而业务按本地时间切边界会错开几个小时数据就串了。二是切片粒度不能太细切成一分钟一段、一天一千四百多个任务光线程池调度和连接获取的开销就吃掉收益了。我一般控制在单表 8 到 32 个切片之间。2.4 深分页 offset 切分我基本弃用了最早我用的就是limit offset, size分页并发拉取写法简单不用管主键分布。但很快就发现翻到后面越来越慢第 1 页 20 毫秒第 5000 页要 3 秒多。原因很朴素offset 是让数据库先扫过前面 N 行再丢掉翻得越深白扫的行数越多。并发跑 16 个线程每个线程都在做大量无用功总共扫的行数可能是数据量的几十倍。更麻烦的是数据漂移。查询期间如果有新数据插入后面页的偏移量整体后移会出现某一行被重复读到、另一行被跳过的情况。这个在对账场景里是致命的。如果确实要按页码切那就至少改成游标式where id #{lastId} order by id limit #{size}把上一次的最大 id 带进下一次查询。这样每次都是索引定位不存在白扫翻多深都是毫秒级。我后来所有的大结果集扫描全部改成了这种写法。分段方式数据均匀性索引友好度实现成本典型适用场景主要风险主键区间切分中依赖 id 连续性高低大表全量扫描、导出id 空洞导致倾斜取模哈希切分高低函数易失效中等值枚举字段分组全表扫描、段数难调整时间维度切分高高低报表、对账、日志分析时区错位、切片过细offset 分页切分高低深翻页最低小数据量、交互式分页越翻越慢、数据漂移3. 线程池与连接池的对齐这是最容易翻车的地方分段逻辑写对了配置写错了结果一样难看。我见过最多的两个事故都出在这里一是线程数配得远大于连接池上限线程全卡在等连接上二是用了无界队列任务无限堆积内存涨到崩。3.1 线程数不是越多越好一次具体的推算过程教科书上的公式是线程数 CPU 核数 × (1 等待时间/计算时间)。拿数据库查询来说绝大部分时间在等网络和等数据库返回假设等待占 90%那 4 核的机器理论值就是4 × (1 9) 40。这个数字看着很爽但它只是应用侧的理论上界不是你能用的值。真正的约束在数据库假设目标库允许你这套应用最多占 20 个连接那你开 40 个线程就有 20 个在原地空转等连接还额外消耗了线程栈内存和上下文切换。我的实际取法是先看连接池允许分配给这个功能的最大连接数记为 P线程数取P - 1或P - 2留一两个连接给主流程、监控、健康检查之类的请求避免这个功能把池子占满后整个应用其他接口全部超时。比如连接池总上限 30我为这个导出功能单独划了一个 12 连接的小池子线程数就取 10 到 11。实测下来段数控制在 8 到 16 之间对百万级数据量是性价比最高的区间。再往上加总耗时下降的曲线就明显变平了而数据库压力还在线性上升。还有一个容易被忽略的点切片数量不必等于线程数。如果你的切片是 40 个线程池只有 10 个线程那没关系任务会排队10 个线程反复取任务执行总耗时约等于“批次波长 × 4”。反而有些场景更推荐切片数多于线程数因为每个切片更小单段耗时更均匀尾部长任务的影响更小。我一般让切片数取线程数的 2 到 4 倍。3.2 连接池参数与线程池的配比关系连接池这边要盯的参数不多但每个都很关键。最大连接数要和线程数对齐前面说了线程数不能超过它。获取连接超时不要设太长我一般设 3 到 5 秒。设成 30 秒的后果是池子被打满之后线程会安静地卡半分钟你从外部看就是接口一直挂在那里不返回也不报错非常难查。空闲连接数可以适当预留避免并发任务开始时集中创建连接创建连接本身是有开销的第一波请求会明显变慢。另外提醒一句如果你用的是 ORM 框架别指望它帮你管线程安全。会话对象、延迟加载的代理对象基本都不是线程安全的把同一个会话对象丢进多个线程里用轻则报错重则数据错乱。正确做法是每个任务自己开自己的会话、自己拿自己的连接、用完自己关。我的写法是让每个分段任务在一个独立的小事务里跑完事务级别设为只读这样数据库侧还能做一些优化。3.3 队列、拒绝策略与超时设置队列我坚持用有界队列容量取“线程数 × 2”左右。有界队列的意义不是限制任务数而是让系统在过载时快速暴露问题而不是悄悄堆积到内存爆掉。无界队列在报表场景特别危险因为导出的数据量取决于用户选的时间范围用户选了个三年任务数瞬间过千无界队列照单全收最后堆在内存里全是待执行的任务对象和查询参数。拒绝策略我一般不用默认的抛异常也不喜欢丢弃。最实用的是让提交任务的线程自己执行也就是“调用者运行”策略相当于一个天然的反向限流队列满了提交方被拖慢前端请求自然变慢系统压力自动回落不会雪崩。代价是接口响应时间会抖动但对内部报表类接口来说完全可以接受。超时必须分两层设置单段查询超时和整体任务超时。单段超时交给数据库驱动或者语句级别去设比如 30 秒整体超时在汇总那一层比如 120 秒还没全部完成就直接放弃返回部分结果或者报错。少了整体超时一个卡住的段会让整个导出永远挂着占用连接不释放。4. 代码落地一个可复用的分段并发查询骨架理论讲完上一份能直接抄的骨架。我用 JDK 自带的并发工具实现不依赖额外框架逻辑上就是“算边界 → 提交任务 → 汇总结果 → 处理异常”。4.1 线程池与分段边界的计算先定义线程池。注意线程名要有辨识度出问题时能一眼从线程栈里认出来同时给线程设成守护线程还是非守护取决于你的应用容器会不会被这些线程卡住关闭流程我一般设成非守护并显式在容器销毁时关闭线程池。import java.util.concurrent.*; import java.util.concurrent.atomic.AtomicInteger; public class QueryPoolHolder { private static final int DB_THREADS 10; private static final int QUEUE_CAPACITY 20; public static final ThreadPoolExecutor QUERY_POOL new ThreadPoolExecutor( DB_THREADS, DB_THREADS, 0L, TimeUnit.MILLISECONDS, new LinkedBlockingQueue(QUEUE_CAPACITY), new ThreadFactory() { private final AtomicInteger seq new AtomicInteger(0); Override public Thread newThread(Runnable r) { Thread t new Thread(r, segment-query- seq.incrementAndGet()); t.setDaemon(false); return t; } }, new ThreadPoolExecutor.CallerRunsPolicy() ); public static void shutdown() { QUERY_POOL.shutdown(); try { if (!QUERY_POOL.awaitTermination(10, TimeUnit.SECONDS)) { QUERY_POOL.shutdownNow(); } } catch (InterruptedException e) { QUERY_POOL.shutdownNow(); Thread.currentThread().interrupt(); } } }边界计算这块如果是主键区间切分先拿上下界再均分注意处理max为空和上下界相等的情况。public Listlong[] splitByPrimaryKey(long segmentCount) { Long minId orderDao.selectMinId(); Long maxId orderDao.selectMaxId(); Listlong[] ranges new ArrayList(); if (minId null || maxId null) { return ranges; } if (minId.equals(maxId)) { ranges.add(new long[]{minId, maxId 1}); return ranges; } long span maxId - minId; long step span / segmentCount; if (step 0) { step 1; } long cursor minId; while (cursor maxId) { long next cursor step; ranges.add(new long[]{cursor, next}); cursor next; } return ranges; }这里有个细节值得说while循环的终止条件和最后的段边界容易写成死循环或者丢尾部数据。我习惯把每段定义成左闭右开最后一段的上界直接取maxId 1保证最大的那条 id 一定被包含进来。4.2 用 CompletableFuture 编排并收集异常任务编排我选CompletableFuture因为它对异常和超时的表达比原始Future清楚得多。核心思路是每段返回自己的结果并且返回时带上段序号这样合并的时候可以按序还原而不是靠join()的完成顺序去猜。public ListOrderRow queryInParallel(Listlong[] ranges, QueryCondition cond) { ListCompletableFutureSegmentResult futures new ArrayList(ranges.size()); for (int i 0; i ranges.size(); i) { final int seq i; final long from ranges.get(i)[0]; final long to ranges.get(i)[1]; CompletableFutureSegmentResult f CompletableFuture.supplyAsync(() - { ListOrderRow rows orderDao.selectByRange(from, to, cond); return new SegmentResult(seq, rows); }, QueryPoolHolder.QUERY_POOL) .orTimeout(30, TimeUnit.SECONDS) .exceptionally(ex - { log.warn(segment {} failed, range[{},{}), seq, from, to, ex); return SegmentResult.failed(seq, ex); }); futures.add(f); } CompletableFutureVoid all CompletableFuture.allOf( futures.toArray(new CompletableFuture[0])); try { all.get(120, TimeUnit.SECONDS); } catch (TimeoutException e) { futures.forEach(f - f.cancel(true)); throw new SegmentQueryException(整体查询超时已取消未完成分段, e); } catch (Exception e) { throw new SegmentQueryException(分段查询失败, e); } ListSegmentResult results futures.stream() .map(CompletableFuture::join) .sorted(Comparator.comparingInt(SegmentResult::getSeq)) .collect(Collectors.toList()); ListOrderRow merged new ArrayList(); ListString errors new ArrayList(); for (SegmentResult r : results) { if (r.isSuccess()) { merged.addAll(r.getRows()); } else { errors.add(段 r.getSeq() 失败); } } if (!errors.isEmpty()) { log.error(存在失败分段{}, errors); } return merged; }几个关键点解释一下。.exceptionally把每个段的异常就地降级成一个带失败标记的结果对象这样allOf不会因为一个段失败而整体异常是否需要“一个失败全盘失败”取决于业务对账类我倾向快速失败导出类我倾向返回部分并明确告知缺了哪几段。.orTimeout是单段超时超时后该段会抛异常并进入exceptionally分支。外层再套一个整体get超时作为最后的兜底。4.3 结果合并的顺序与内存控制合并阶段最容易被忽视的是内存。假设 200 万行、每行 1KB全塞进ArrayList就是 2GB还没算对象头和扩容时的临时数组直接 OOM。我踩过这个坑之后做了两条改动。第一条合并时按段预分配容量减少扩容过程中的临时数组复制。new ArrayList(estimatedTotal)总量可以用前面查 count 的结果估。第二条更彻底的方式是边查边写每段任务不是返回List而是直接把数据写进目标文件或目标表或者往一个有界的阻塞队列里放由单独一个消费线程负责输出。这种做法内存占用恒定代价是失去了“先拿到全量再统一处理”的便利。我的判断标准是结果总行数超过 50 万、单行超过 500 字节就走流式不攒全量。顺序保证上还有个小技巧如果业务要求的顺序就是主键升序而分段本身也是按主键升序切的那合并时按段序号排序即可不需要再做一次全量排序——全量排 200 万行在应用侧也是几秒的开销白白浪费。4.4 超时、取消与降级路径取消这块有个容易被误解的点线程被cancel(true)或者interrupt之后JDBC 查询不一定会立刻中止。很多驱动对中断的响应是把连接标记为不可用然后关闭有些甚至要等当前语句自己跑完。所以指望超时取消来释放数据库压力是不现实的它更多是让应用线程尽快回到空闲状态。因此超时时间必须设在数据库侧也能接受的范围内。我会在建连接或者建语句的时候设置查询超时让数据库自己动手杀慢查询。应用侧的orTimeout只是第二道保险。降级路径我一般准备两条一是线程池满或者整体超时时退回单线程串行查并把耗时预估告诉调用方二是允许业务指定“只查最近 N 天”用范围缩减来代替并发。这两条在线上救过急比直接抛异常让用户干等要好得多。5. 一致性与数据正确性并发读最容易忽略的坑性能问题好发现数据问题最难查。分段并发查询在正确性上有三类典型风险我在实际项目里都遇到过。5.1 快照边界不一致每个线程看到的是不同时刻的数据单线程扫一遍即使不加锁在可重复读的隔离级别下整段查询看到的是同一个快照语义上是“某一时刻的全量”。分段并发之后10 个线程各自开事务快照时间点可能相差几十毫秒到几秒如果这期间有数据被修改汇总结果里就会出现“前半段是旧值、后半段是新值”的混合状态而且没人能说清它对应哪个时间点。处理办法取决于业务。如果是事后对账我会在查询条件里加一个业务时间上界比如update_time 结算时点让所有段共享同一个时间边界虽然仍不能保证严格的快照一致但至少语义清晰、可重现。如果是实时导出我会在结果里标注“数据截止到某某时间”把弱一致性明示给使用方而不是假装它是强一致的。如果要真正强一致那只能退回来串行或者用数据库自己的导出工具这是没办法绕过的事实。5.2 分页漂移重复与遗漏是怎么产生的用偏移量分页时如果查询期间有插入或删除偏移量就会整体位移。举个具体的例子你正在查第 2 页条件是order by create_time limit 100 offset 100这时候插入了 5 条更早的记录原来第 2 页开头的 5 条就被推到了第 1 页的范围里第 2 页读到的是原来的第 105 条之后中间那 5 条被跳过了。反过来删数据就产生重复。游标式按主键递增能缓解大部分场景前提是主键单调递增且只有追加。如果存在更新主键、或者主键是 UUID 这种无序值游标法也可能漏数据。我的经验是并发分段查询一定要有一个单调且不变的分段键主键不自增就退而求其次用创建时间加主键的组合游标同时接受极小概率的边界误差并在结果行数上做一次校验——比如总数和分段数加起来对不上就打印告警而不是静默返回。5.3 事务传播别把并发查询包进同一个事务这一点如果用了声明式事务注解特别容易踩。外层方法标了事务方法内部起线程池并发查询子线程的事务上下文是不会传播过去的子线程里的查询要么自己开一个新事务要么根本没有事务。结果就是外层事务的回滚对子线程毫无影响子线程读到的东西可能正处在未提交状态取决于隔离级别。我的做法是并发查询的入口方法不加事务注解每个分段任务内部各自管理自己的只读事务边界清晰。同时在代码注释里写清楚“此处禁止外层包裹事务”避免后来的人顺手加个注解把整个逻辑搞乱。另外只读事务最好显式声明某些数据库会据此做查询优化还能避免误写。6. 常见问题速查与排查实录最后一节把我这些年遇到过的问题整理成一张表方便你出问题时对着查。现象最可能的原因排查动作处理方式接口长时间不返回、也不报错线程数超过连接池上限线程卡在等连接看连接池活跃连接数和等待线程数线程数降到连接池上限以内留出预留连接并发后总耗时反而变长数据库侧已饱和或分段严重倾斜看各段耗时分布和数据库 CPU减少线程数重新计算分段边界内存持续上涨直至 OOM全量结果攒在内存里或队列无界看堆内存直方图里的结果对象数量改流式处理队列改有界并限制分段数汇总行数比预期多或少段边界重叠或分页漂移核对边界条件是否左闭右开统一成左闭右开改用游标式分页后端慢查询日志里出现大量相同 SQL分段参数没走索引执行计划失效看执行计划的扫描行数避免在分段字段上做函数运算偶发数据前后不一致各段读到的快照时间点不同核对是否有并发写入加业务时间上界或明示弱一致性单段偶尔超时数据倾斜导致某段特别大统计各段行数分布按真实分布重新切片或做二次拆分关于观测我建议至少埋四个指标各段耗时分布不只是平均值要看 P99倾斜往往藏在尾部、连接获取等待时间、线程池队列长度、分段结果行数校验值。前三个能帮你快速定位是线程问题还是数据库问题最后一个能在数据出错时第一时间发现而不是等业务方反馈对账差了几百条。还有一个我个人的小技巧分段查询上线前先用 1 个线程跑一次、再用目标线程数跑一次对比两次的总行数和关键字段的求和值。两次结果不一致说明要么分段有问题要么数据在变这时候千万别上生产。这个动作花不了两分钟但拦住过我好几次事故。另外线程池不要全局共用一个大的。我现在的做法是给这类批量查询单独一个池子和在线接口的池子隔离一侧打满不会拖累另一侧。池子的核心参数做成配置项让它能热调整不用改代码重新发布——线上流量是变化的今天合适的 10 个线程下个月数据量翻倍之后可能就不合适了。
阅读完成 · 觉得有帮助?