Featured image of post 当数据量起来之后:一个老程序员的认知升级

当数据量起来之后:一个老程序员的认知升级

2020 年初,我在某健康集团第一次面对"数据量大到单机装不下"的真实业务。这篇随笔记录了 4 个业务场景和 5 条方法论。

当数据量起来之后:一个老程序员的认知升级

前置:这篇写于 2020 年 3 月。我在某健康集团做健康管理平台,第一次面对"每天 80 万条健康数据、几百万条 Excel 导入"这种规模。

为什么写这篇

2019 年底,我从某跨境支付公司跳到某健康集团。前一家做的是日均几万订单的跨境支付,后一家做的是日均几十万次健康检查、几百万条报告数据的健康平台。

我天真地以为,量变到质变只是性能调优

来了 3 个月,我被打脸了。

数据量级一旦变了,思维方式必须变。 写单机代码 10 年的经验,在百万级数据面前几乎要全部推翻重来。这篇随笔记录了我被打脸后,悟到的 4 个真实业务场景 + 5 条方法论 + 4 个常见坑

4 个经典业务场景

场景 1:40 亿个 QQ 号去重,1GB 内存

这是面试题里的"老八股",但真要落地,思路和工程取舍完全不一样

直接上 HashSet<Long>? 40 亿 × 8 字节 = 32 GB,直接 OOM

核心思路

  • Bitmap(位图):把"存数值"换成"存 1 个比特位"。43 亿个 bit = 512 MB,刚好装进 1GB 内存
  • 布隆过滤器(Bloom Filter):如果数据是字符串、不能直接转 long,用多个 hash 函数映射到固定大小的 bit 数组,有微小误判率但内存极省
  • 分桶 + 外排序:如果内存连 512MB 都没有,按 hash % 100 把数据分到 100 个小文件,单机顺序处理
1
2
3
4
5
6
7
8
9
// Bitmap 核心思想:用 1 个 bit 标记"在不在"
public boolean add(long qq) {
    int index = (int) (qq / 64);     // 在哪个 long 里
    int position = (int) (qq % 64);  // 在该 long 的哪一位
    long mask = 1L << position;
    if ((bits[index] & mask) != 0) return true; // 已存在
    bits[index] |= mask;                          // 标记
    return false;
}

512 MB 内存,无误差,O(1) 查询。 我当时看到这个解法的第一反应是——“我以前写的代码,全是 O(N) 的笨办法”。

场景 2:百万级 Excel 导入数据库

这个场景我踩过最大的坑——上来就用 WorkbookFactory.create(file),结果 100 万行 xlsx 内存直接爆掉

核心思路

  • 流式解析(EasyExcel / POI SAX 模式):内存里只保留当前行,永远不一次性加载。
  • 批处理 + 多线程:每 2000 行一批,主线程解析,子线程批量写库。MySQL JDBC 一定要加 rewriteBatchedStatements=true,否则不是真批处理
  • 二阶段校验:本地校验(格式、长度) + DB 校验(外键是否存在)。不要在循环里查 DB,先批量拉一份到内存里比对
  • 失败策略:别用大事务,用 TaskID 异步 + 错误行回写 error.xlsx。用户能下载、自己改、二次上传。

生产环境小贴士:导入是 IO 密集型,线程数可以开大(2 × CPU核数),但拒绝策略必须用 CallerRunsPolicy——队列满了让主线程亲自写,自动反压。

场景 3:2GB 文件找出高频 Top 100

一开始我用 Files.readAllLines()——直接把 2GB 文件读成字符串列表,内存放大 3-5 倍直接 OOM

两个标准解法

解法 A:流式读取 + 最小堆(单机 4GB 内存场景)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
// 1. 流式读取 + 内存哈希计数
try (BufferedReader reader = new BufferedReader(
        new FileReader(file), 8 * 1024 * 1024)) { // 8MB Buffer
    String line;
    while ((line = reader.readLine()) != null) {
        // 增量更新 HashMap
        wordCounts.put(word, wordCounts.getOrDefault(word, 0L) + 1);
    }
}

// 2. 最小堆拿 Top 100
PriorityQueue<Map.Entry<String, Long>> minHeap = new PriorityQueue<>(
    100, Comparator.comparingLong(Map.Entry::getValue)
);
// 遍历完 map 之后,堆里留下的就是 Top 100

解法 B:分治法(百 GB 文件也能用)

  1. Hash 分流:按 hash(word) % 64 写 64 个小文件
  2. 局部 Top 100:每个小文件独立算 Top 100
  3. 全局归并:64 × 100 = 6400 个候选,再筛一次

解法 C:Linux 命令行"降维打击"

1
2
3
4
5
6
7
cat large_file.txt \
  | tr -cs 'a-zA-Z' '[\n*]' \
  | tr 'A-Z' 'a-z' \
  | sort \
  | uniq -c \
  | sort -nr \
  | head -n 100

5 个 tr/sort/uniq 搞定,30 秒出结果。 Linux 工具链是 C 写的,性能比我写的 Java 还快。

场景 4:给第三方提供接口

我以前做内部系统,接口爱怎么写怎么写。做对外接口才发现,光"防刷"就是个大工程。

4 个必做项

  1. RESTful + 统一响应格式{code, message, data} 三段式,错误码统一,优雅降级
  2. 限流:Redis + Lua 脚本实现原子化的分布式令牌桶
  3. 设备指纹 + 请求签名:防脚本 + 防伪造。
  4. 结构化日志:所有外部接口调用记日志,敏感信息过滤(手机号、身份证打码)。

最关键的接口契约先行(YApi / OpenAPI),不要后端写完了再补文档。前后端联调周期能从一周压到一天。

5 条方法论

方法论 1:内存估算先行

写任何大数据代码前,先算一下

场景数据量朴素方案优化方案
40 亿 QQ 号去重40 亿 longHashSet → 32 GB ❌Bitmap → 512 MB ✅
2GB 文本 Top 100200 万独立词readAllLines → 8GB+ ❌流式 + HashMap → 几百 MB ✅
百万行 Excel100 万行WorkbookFactory → OOM ❌EasyExcel SAX → 几 MB ✅

不算这一笔,后面所有优化都是瞎调。

方法论 2:流式 > 一次性加载

这是大数据处理的"第一性原理"。文件大到内存装不下,唯一的解法是分块流式处理。EasyExcel、BufferedReader、Kafka Consumer、Log Tail,底层都是这个思想

方法论 3:分治是万金油

“凡是单机装不下的,就分桶;凡是单线程跑太慢的,就并行;凡是单点会挂的,就冗余。”

MapReduce 是工业级模板。40 亿 QQ 号去重能分桶,2GB 文件 Top 100 能分桶,百亿条日志分析也能分桶

方法论 4:业务约束 > 技术最优

我曾经追求过"性能 100% 压榨",后来发现业务允许 1% 误判率时,内存能省 10 倍。比如布隆过滤器。

先问业务"我能不能接受 1% 误差?",比问"用 Bloom 还是 Roaring"重要 10 倍。

方法论 5:批处理 + 异步 + 拒绝策略

百万级导入、批量发通知、批量同步数据——三件套必须配套:

  • 批处理:1000~5000 条一批
  • 异步:返回 TaskID,不阻塞用户
  • 拒绝策略:队列满了让上游等(CallerRunsPolicy)或丢弃非关键任务

4 个常见坑

坑 1:误用分布式事务

百万级导入用分布式事务 → 长事务锁表 → 整个库卡死。

解法:最终一致性 + 补偿 + 错误行回写。99% 的场景不需要分布式事务

坑 2:MySQL 伪批处理

JDBC 写 addBatch() 但没加 rewriteBatchedStatements=true——还是一条条 insert,性能比单条还慢。

坑 3:HashMap 装下"全部数据"

我早期写的代码里,到处都是 Map<userId, User> 全量缓存。几万用户没事,百万用户 OOM。

解法:本地缓存(Caffeine)+ 过期时间 + 最大条目数;分布式缓存(Redis)+ 淘汰策略。

坑 4:忽略 GC 停顿

高频写入场景,JVM Full GC 一次几秒——前面的调优全废了

解法:堆外内存(DirectBuffer)/ 零 GC 语言(Rust)/ 异步批写(攒一批再 flush)。

个人反思

回头看 2020 年那个"被打脸"的我,最大的认知升级是:

“单机思维"和"分布式思维"之间,隔着一道叫"数据量级"的墙。

以前我以为:

  • 性能 = 算法 + 数据结构
  • 调优 = JVM 参数 + SQL 索引

现在我明白:

  • 性能 = 内存估算 + 流式处理 + 分治并行
  • 调优 = 业务约束 + 异步批处理 + 拒绝策略

数据量级改变了思维范式。 任何人没被"大数"打过一次脸,都不会真正理解这句话。

后记

2020 年下半年,我离开了某健康集团,去了一家更硬核的物联网公司(代号 某物联网项目)。那家公司每天处理 3 万台设备、12,000 QPS 的实时数据。

但如果没在 2020 年初被"百万级 Excel"和"40 亿 QQ 号"打脸过,我大概率接不住后面的挑战。

数据量级这道墙,早撞比晚撞好


关联阅读

  • 面试随笔:写给 2013 年那个第一次当面试官的我
使用 Hugo 构建
主题 StackJimmy 设计