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 List<long[]> splitByPrimaryKey(long segmentCount) { Long minId = orderDao.selectMinId(); Long maxId = orderDao.selectMaxId(); List<long[]> 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 List<OrderRow> queryInParallel(List<long[]> ranges, QueryCondition cond) { List<CompletableFuture<SegmentResult>> 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]; CompletableFuture<SegmentResult> f = CompletableFuture.supplyAsync(() -> { List<OrderRow> 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); } CompletableFuture<Void> 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); } List<SegmentResult> results = futures.stream() .map(CompletableFuture::join) .sorted(Comparator.comparingInt(SegmentResult::getSeq)) .collect(Collectors.toList()); List<OrderRow> merged = new ArrayList<>(); List<String> 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 个线程,下个月数据量翻倍之后可能就不合适了。