Java基础强化(三):多线程并发 ------ AI 数据批量读取性能优化
------别让单线程成为你特征管道的瓶颈
大家好,我是那个总在深夜盯着线程池监控、又在数据库连接池里调参数的老架构。今天不聊模型蒸馏,也不谈向量索引------我们解决一个更现实的问题:
当你需要从电科金仓 KingbaseES(KES)中批量拉取 500 万条用户行为记录用于离线训练,是用一个线程慢慢读,还是用多个线程并行跑?
很多人第一反应是:"开 10 个线程,速度翻 10 倍!"
但现实往往骨感:线程争抢连接、数据库 CPU 打满、GC 频繁、甚至触发 KES 的连接数限制------最后反而比单线程还慢。
所以,今天我们不谈理论,只讲实战:如何用合理的并发策略,安全、高效地榨干 KES 的读取能力,而不把它压垮。
一、为什么批量读取需要并发?
在 AI 工程中,特征数据往往具有两个特点:
- 体量大:百万级用户 × 百维特征 = GB 级数据;
- 读密集:训练前需全量加载,推理前需批量缓存。
而 KES 作为企业级数据库,单连接的吞吐受限于网络带宽和单核处理能力。即使你的服务器有 32 核,单线程 JDBC 也只能用 1 个核。
📌 举个真实案例:国家电网的智能调度系统(案例参考)每天需从 KES 读取上亿条设备状态,若用单线程,光读取就要 6 小时;引入分片并发后,压缩到 40 分钟。
所以,并发不是"可选项",而是"必选项"------但必须可控、可测、可回退。
二、核心原则:分片读取 + 连接池隔离
并发读取的关键不是"多开线程",而是 将数据逻辑分片(sharding),每个线程负责一片,互不干扰。
假设你的 user_behavior 表有主键 id(BIGINT 自增),我们可以按 ID 范围分片:
sql
-- 分片 0: id BETWEEN 0 AND 999999
-- 分片 1: id BETWEEN 1000000 AND 1999999
-- ...
这样,每个线程执行独立的 SQL,无锁竞争,数据库可并行扫描。
步骤 1:估算总记录数和分片大小
java
private long getTotalCount(Connection conn) throws SQLException {
try (PreparedStatement ps = conn.prepareStatement("SELECT COUNT(*) FROM ai_features.user_behavior");
ResultSet rs = ps.executeQuery()) {
rs.next();
return rs.getLong(1);
}
}
private List<Range> calculateRanges(long totalCount, int threads) {
long perThread = totalCount / threads;
List<Range> ranges = new ArrayList<>();
for (int i = 0; i < threads; i++) {
long start = i * perThread;
long end = (i == threads - 1) ? totalCount : (start + perThread - 1);
ranges.add(new Range(start, end));
}
return ranges;
}
💡 提示:如果表无自增 ID,可用
ROW_NUMBER()或业务字段(如 user_id 哈希)分片。
三、实战:用 ExecutorService + HikariCP 实现安全并发读取
我们复用之前配置好的 HikariCP 连接池 (驱动下载),确保每个线程独占一个连接。
java
public class ConcurrentFeatureReader {
private final HikariDataSource dataSource;
private final int threadCount = 8; // 根据 KES 服务器 CPU 和负载调整
public List<UserFeature> readAllFeatures() {
try (Connection conn = dataSource.getConnection()) {
long totalCount = getTotalCount(conn);
List<Range> ranges = calculateRanges(totalCount, threadCount);
// 创建线程池(固定大小,避免资源耗尽)
ExecutorService executor = Executors.newFixedThreadPool(threadCount);
List<CompletableFuture<List<UserFeature>>> futures = new ArrayList<>();
for (Range range : ranges) {
CompletableFuture<List<UserFeature>> future = CompletableFuture
.supplyAsync(() -> readRange(range), executor)
.exceptionally(ex -> {
log.error("分片读取失败: {}-{}", range.start, range.end, ex);
return Collections.emptyList(); // 容错:返回空列表,不中断整体
});
futures.add(future);
}
// 合并结果
List<UserFeature> all = futures.stream()
.map(CompletableFuture::join)
.flatMap(List::stream)
.collect(Collectors.toList());
executor.shutdown();
return all;
} catch (SQLException e) {
throw new RuntimeException("批量读取失败", e);
}
}
private List<UserFeature> readRange(Range range) {
String sql = """
SELECT user_id, embedding, created_at
FROM ai_features.user_behavior
WHERE id BETWEEN ? AND ?
ORDER BY id
""";
List<UserFeature> features = new ArrayList<>();
try (Connection conn = dataSource.getConnection(); // 每个线程独占连接
PreparedStatement ps = conn.prepareStatement(sql)) {
ps.setLong(1, range.start);
ps.setLong(2, range.end);
ResultSet rs = ps.executeQuery();
while (rs.next()) {
features.add(new UserFeature(
rs.getString("user_id"),
parseEmbedding(rs.getBytes("embedding")),
rs.getTimestamp("created_at").toInstant()
));
}
} catch (SQLException e) {
throw new RuntimeException("分片读取异常", e);
}
return features;
}
private static class Range {
final long start, end;
Range(long start, long end) { this.start = start; this.end = end; }
}
}
✅ 关键设计点:
- 每个线程通过
dataSource.getConnection()获取独立连接,避免共享连接的线程安全问题; - 使用
CompletableFuture+ExecutorService,比原始Thread更易管理; - 异常隔离:单个分片失败不影响其他分片;
- 分片边界闭区间
[start, end],确保全覆盖、无遗漏。
四、性能调优:线程数不是越多越好
很多人以为"线程越多越快",但在数据库场景中,最优线程数 ≈ 数据库 CPU 核数 × (1 + 等待时间/计算时间)。
对于 I/O 密集型的读取任务(等待网络 + 磁盘),通常 4~16 线程 即可打满 KES 的读能力。
📊 实测建议:
- 先用 4 线程跑一次,记录耗时;
- 再试 8、12、16;
- 当耗时不再下降甚至上升时,就是拐点。
同时,务必监控 KES 服务器的:
- CPU 使用率(
top) - I/O 等待(
iostat) - 活跃会话数(
SELECT COUNT(*) FROM sys_stat_activity;)
如果 KES 出现高负载,优先减少客户端线程数,而不是加数据库资源。
五、与 KES 协同:利用其高并发读能力
电科金仓 KES 在 V9 版本中针对国产芯片(鲲鹏、飞腾)做了深度 I/O 优化,单实例可支撑 55600+ TPS 的读写混合负载(产品详情)。
但要发挥这一能力,客户端必须:
- 使用连接池(如 HikariCP);
- 避免长事务(读取用
READ COMMITTED); - 合理分片,避免热点(如全表扫描同一分区)。
我们的并发读取方案,正是为此设计。
结语:并发是艺术,不是暴力
AI 的落地,不在模型多深,而在数据管道多稳。
而稳定的管道,来自对资源的敬畏与对并发的克制。
多线程不是"开得越多越好",而是"用得恰到好处"。
当你能用 8 个线程安全、高效地从 KES 拉出千万级特征,而不引发任何告警,你就掌握了工程化的真谛。
下一期,我们会讲:Java 内存模型与 GC 调优 ------ 让 AI 服务远离 Full GC 停顿 。
敬请期待。
------ 一位相信"性能是设计出来的,不是压测出来的"的架构师