分钟级插入百万数据

894 阅读5分钟

开始之前吹吹水

还记得当年懵懂的你对百万年薪的渴望吗
还记得当年青涩的你对百万数据的追求吗
还记得当年迷茫的你对百元日薪的无奈吗
还记得当年疲惫的你对百万编程的执着吗

还记得意气风发的少年在网吧爆肝的时光吗
还记得初入社会的青年在社会挣扎的日子吗
还记得三十而立的中年在人间烟火的每天吗
还记得步入暮年的前辈在世间万物的回忆吗

伊始

对于某些需求来说,需要百万数据的支持更能接近实际场景,对日常开发和日常练习也有好处。于是老铁我网上查了产生大量测试数据的方法,大概有以下几种
1.filldb,这个web提供多种正则生成测试数据,但其正则没有具体说明,对于正则不熟悉的同学来说会有点难上手,并且啊铁我实际使用来看,一万数据量容易503
2.存储过程(过,我不会。囧!!!)
3.程序
因为铁子要生成的测试数据是用户表,username, email, phoneNu需要做唯一检查,并且password也需要加密处理,这样看来通过java逻辑才能实现需求,在保证逻辑正确的基础上,我们不要忘记对性能的追求,对效率的执着,下面跟着铁子开干!

表结构

  • CREATE TABLE member_0 (
  • id bigint(20) NOT NULL DEFAULT '0',
  • username varchar(24) NOT NULL COMMENT '用户名',
  • nickname varchar(32) DEFAULT '' COMMENT '昵称',
  • password char(63) NOT NULL COMMENT '用户密码',
  • phone_no char(24) DEFAULT NULL COMMENT '手机号码',
  • email_add varchar(127) DEFAULT NULL COMMENT '邮箱',
  • icon varchar(255) DEFAULT '' COMMENT '头像',
  • gender enum('SECRET','MALE','FEMALE') DEFAULT 'SECRET' COMMENT '用户性别',
  • level tinyint(1) DEFAULT '0' COMMENT '用户等级',
  • reward_point int(11) DEFAULT '0' COMMENT '积分',
  • status enum('INACTIVE','ACTIVE','LOCKED') DEFAULT 'INACTIVE' COMMENT '用户状态',
  • birthday datetime DEFAULT '1970-01-01 00:00:00' COMMENT '用户生日',
  • deleted bit(1) DEFAULT b'0',
  • created_time datetime DEFAULT CURRENT_TIMESTAMP,
  • updated_time datetime DEFAULT CURRENT_TIMESTAMP,
  • created_by varchar(32) DEFAULT '',
  • updated_by varchar(32) DEFAULT ''
  • ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

如你所见,除了主键索引,没有唯一索引,意味着需要在代码逻辑保证username, email, phoneNu的唯一。

目的

随机生成五百万条测试数据,统计耗时。

开搞

内存优化

需要检查3个字段的唯一性,五百万条数据就是1500万个字符串需要做唯一检查,如果把1500万个字符串怼到内存,会导致电脑卡顿,CUP疯转,现在天气这么热,电脑吹的热风和空调的冷风形成冷热交替,热风膨胀向上,冷风收缩向下,到时候你的头吹的是热风,脚吹的是冷风,头发发热,手脚冰冷,女朋友肯定会嫌弃你(女生都喜欢手脚暖的男朋友)
无论基于什么理由,都不能直接让内存撑着。从两方面优化,加内存和加内存。

字段检查

足够的内存保证电脑的舒适,也能保证我们的舒服,三个字段的检查,分别创建三个hash结构,利用hash的特点(相同字符串hash值相同,相同hash值的字符串可能相同),实现O(1)完成数据唯一性检查。基本做法是,每生成一条数据,往hash结构插入。

image.png 创建一个validate方法,判断待检查的数据是否符合要求。HashSet底层用的是HashMap,只不过它的value是null,省点内存。

再提内存问题

内存是珍贵资源,五百万条数据可能不能在一起怼到内存,所以生成一批测试数据就得批量插入,并统计成功插入数量。老铁用的是mybatis,关于mybatis批量插入有3种方式,记得使用那个sql较短的方式,减少传输数据量。

优化插入速度

折腾完java,不能漏掉mysql,两兄弟一起揍!

仔细思考,单次插入mysql的数据量在一万条左右,传输一万条数据少说也有几M吧(听我瞎猜),mysql解析数据也需要时间。数据传输时,mysql的网络io忙着,工作线程闲着(这部分涉及到多路复用,但不确定mysql是不是多路复用),使用并行传输数据,充分利用mysql cup。

涉及多线程,得考虑线程安全问题。还记得需求要求统计成功插入数据总量吗,使用原子对象,保证线程安全。 image.png 并且把三个ColumnSet改成分段锁模型,提高锁效率

image.png 最后重点在并发模型,这里的executor是线程池,开了10个线程,每次随机生成一千条条数据,循环五百次,最后一条线程完成任务打印总耗时。(这里涉及到AQS,有兴趣看铁子刚入行写的AQS文章,随便看看当年铁子多傻逼!)

image.png CompletableFuture牛逼!

到这里已经很ok了,再注意几个细节就更完美。
1.mysql的innodb_buffer_pool_size参数,写操作超过内存阈值,发生磁盘交换。限制速度,需要实际测试合适的值。
2.整个过程尽量避免打印日志
3.长时间耗时循环会延长GC时间,可以考虑safePoint,把count改成long类型就好
4.铁子电脑是m1 mac,战绩还行,如果重启电脑,啥都不开,150秒就搞定。当然线上环境肯定不止。

image.png

最后附上插入代码

public int batchInsert(Set<String> columnUsername, Set<String> columnPhoneNo,
                       Set<String> columnEmailAdd, List<MemberPo> memberPoList) {

    if (memberPoList.isEmpty()) {
        return 0;
    }
    logger.info("{} pieces of data filter remain {} pieces of data are going to insert", memberPoList.size(), memberPoList.size());
    List<MemberPo> memberPoList2Db = new ArrayList<>(memberPoList.size());
    
    for (MemberPo memberPo : memberPoList) {
        if (this.validate(memberPo, columnUsername, columnPhoneNo, columnEmailAdd)) {
            memberPoList2Db.add(memberPo);
        }
    }

    return BatchInsertUtils.insertData(memberPoList2Db, memberRepository::batchInsert);
}
/**
 * 插入方法
 *
 * @param list     插入数据集合
 * @param function 消费型方法,直接使用 mapper::method 方法引用的方式
 * @param <T>      插入的数据类型
 */
public static <T> int insertData(List<T> list, ToIntFunction<List<T>> function) {
    if (list == null || list.isEmpty()) {
        return 0;
    }

    int totalCount = 0;
    for (int i = 0; i < list.size(); i += 1000) {
        int j = Math.min((i + SIZE), list.size());
        List<T> subList = list.subList(i, j);
        totalCount += function.applyAsInt(subList);
    }

    return totalCount;
}