跑批任务的处理思路
1 背景
合规要求将数据库中的敏感用户信息脱敏,账号中心和账户中心的数据库都有明文手机号。
2 解决思路
分两部分看,存量数据和增量数据,其中增量数据要先处理。
增量数据,可以通过 Getter、Setter 来实现加解密。另外 Dao(Repository)可能包含 findByPhone 的查询,需要调整为先根据密文查询,如果结果为空,那么根据再明文查询一遍。
存量数据,需要加密数据库中存量的明文手机号,因为加密是一个CPU密集的操作,数据库不适合做这种要动脑的处理,所以要写Java定时任务来跑批。
3 第一版实现
定时任务线程作为主线程,如果只用主线程加密手机号,一批次1000个PO,每个PO get 出来一个明文,然后 set 回去,setter 负责加密,最后 save。
这样一个批次处理1000个PO,实测耗时5s。尝试过一批次处理10000个PO,实测耗时50s,虽然访问数据库的次数少了,但性能没提升。
简单做个算术,开发环境的账号中心有一千万的数据,全部跑下来需要 10000s,折合2.78小时。生产的数据是开发环境的好几倍,这样的效率肯定不行。
4 第二版实现
第二版做了两个改进:
- 多线程处理。手机号加密是CPU密集型操作,我们机器的CPU资源有很大富余,所以用 Executors 创建了一个固定大小线程池,只负责加密,不负责插入等IO操作。
- 批量插入。开启事务、提交事务都要走一次网络IO和数据库交互,本场景中有大量的短时间写入操作,可以放到同一个事务中进行,减少开启提交事务的开支。现通过 Future.isDone() 判断当前批次脱敏完成后,一次性保存一个批次的记录。但要注意这个事务不能太大,不然 redo_log 会炸掉,需要做一个权衡。
这个实现中,一个开始为了减少批量插入失败回滚的损失,还做了个分片,就是把一个批次的记录尽可能公平分派给各线程,有分片就会想到 work-steal (Java的实现是 ForkJoinPool),满脑子的骚操作,最后还不如 FixedThreadPool 简单高效。
在查询明文手机号记录的时候,sql 越跑越慢,是因为 LIMIT 0,100 要比 LIMIT 10000,100 快上不少,后者要遍历 10000 个对象。最好别用。
4.1 线程池大小
Executors 创建出来的 FixedThreadPool,coreSize 和 maxSize 是一样的。线上机器4个单核CPU,UAT由于是多服务部署,有8个单核CPU,每个环境都不一样,需要动态设置 maxSize,这里 maxSize 设置为 Runtime.getRuntime().availableProcessors() * 3。
4.2 一个批次处理多少数据
FixedThreadPool 用的 LinkedBlockingQueue 是一个无界队列,稍不注意会炸掉JVM,因此需要谨慎评估内存占用。
跑个 show table 观察一下单条记录多大:
SHOW TABLE STATUS WHERE `name` = 't_user'
看到 Index_length为409600(b),Rows为1655,那么1条记录就是247(b),加载到内存中会更大,假设300(b)。(只能算个大概,看内存占用建议用 MAT)
在本地开发机器上执行 Runtime.getRuntime().freeMemory() 得到 225973632(b),一批次可以处理753245条数据,内存是足够了。
数据库层面上,phone这个字段带有二级索引(KEY),但由于查询的时候用到了函数 length(phone),对索引列使用函数会导致索引失效,进而全表扫描,所以查询压力较大。
上文说到,大事务会撑爆 redo_log,哪怕不考虑IO性能,一个事务的批量插入都不能太大。
最后很怂地一个批次跑 20000 条数据,记住 jdbc参数 开启 rewriteBatchedStatements。
Reference
[1] limit查询慢的原因及优化方法
[2] 一个Java对象到底占用多大内存?
[3] MySQL之rewriteBatchedStatements