在
记一次大数据量处理案例(运用多线程完成数据快速处理)-----上(事件渊源)
中已经详细介绍了这次二次重构的起因和模块大致功能,
本次重构可分为:
1.数据库表重新设计以及业务逻辑重构
数据表重新设计
首先一改原有的数据结构表,将数据由一条电文对应生成N条数据修改为一条电文永远只生成一条数据,通过配置表进行字段翻译.字段含义在配置表中体现,其类似于有如下两个不同电文:
电文1:{
msgId:'XGF001',
name:'张三',
sex:'男',
phone:'138888888'
},
电文2:{
msgId:'PGF001',
userName:'李四',
userSex:'女',
userPhone:'139999999'
}
电文1为A公司发送电文,电文2为B公司发送电文 其电文号不同,字段名也不同,通过配置对照关系将其存储在新的数据承接表中,
数据承接表结构如下:
字段名 | 类型 | 注释 |
---|---|---|
ID | NUMBER | 数据ID |
MSG_ID | VARHCAR | 电文ID |
QIP001 | VARHCAR | 字段1 |
QIP002 | VARHCAR | 字段2 |
QIP003 | VARHCAR | 字段3 |
QIO004 | VARCHAR | 字段4 |
在配置表中就有数据:
数据ID | 电文号 | 字段名称 | 字段含义 | 对应电文字段 |
---|---|---|---|---|
1 | XGF001 | QIP001 | 姓名 | name |
2 | XGF001 | QIP002 | 性别 | sex |
3 | XGF001 | QIP003 | 电话号码 | phone |
4 | PGF001 | QIP001 | 姓名 | userName |
5 | PGF001 | QIP002 | 性别 | userSex |
6 | PGF001 | QIP003 | 电话号码 | userPhone |
通过配置表配置字段信息 在获取到电文之后 加载配置信息,利用反射原理即可将电文数据转换为数据承接表中的数据,在打印PDF模块中亦只需要读取对应的一条数据和配置表即可轻松完成原有的打印.
业务逻辑重构
因该接口需要对接不同厂家的电文数据,电文来源不一,数据结构各不相同,所以针对不同的数据结构有不同的业务逻辑,但其所有电文都会经过:
每一次接收电文都需要去加载配置表,并翻译数据,所以将这部分操作解耦,与数据生成的业务逻辑分开.
在配置表中数据改动频率非常非常小,所以配置中心的配置数据通过redis缓存,尽量减少对后台数据库的重复查询.
在业务逻辑部分对业务代码进行了优化为了方便以后可能针对CDEFG等更多的公司数据接入在业务处理部分灵活运用了模板方法模式
最后整个模块的结构如下:
在业务部分的重构大体就如上所说,具体的代码就不贴了,也没啥技术难度,感兴趣的小伙伴可down下相关模块直接阅读代码可能更直观点.
本次重构涉及到的历史数据割接才是这次重构的重点以及难点.下面将详细阐述这次历史数据处理的具体流程.
2.历史数据割接迁移
在完成本次业务模块重构之后,因为在接口开发时就对原始电文做了保存,所以处理历史数据时不需要对那冗余度非常高的2个亿的数据进行再次转译,直接通过读取原始电文调用对外接口即可.
但通过测试用例发现,单线程的情况下,循环读取效率极其低下,单线程情况下一分钟大约只能够生成60-80条数据,而原始电文有两百多万条,如此低下的处理效率作为开发人员是绝对不能允许的,必须得上多线程!
针对本次数据迁移,最终决定将电文读取与数据生成进行解耦,并分别启用多线程
将电文读取看做 producer
将数据生成看做 consumer
producer每次都将读取到的数据缓存到redis,然后consumer每次从redis上取数据
producer 实现代码如下:
注:因其它原因只展示大致实现思路,部分地方为伪代码实现
/**
* 电文读取类
* @author whz
*
*/
public class Producer implements Callable<String> {
public static final String DATA_KEY = "quality:qualityList";
public String call() throws Exception {
Jedis jedis = initJedis();//初始化jedis连接
while (true) {
//获取未处理数据的总数
Long allSize = jedis.llen(DATA_KEY);
//当redis累积到超过1万条数据未处理时,让线程等待 每3秒轮询一次
if (allSize >10000) {
System.err.println(Thread.currentThread().getName()+"----redis数据超过1万条,现场休眠等待消费者处理....");
Thread.sleep(30000L);
continue;
}
//充分利用redis单线程,操作原子性的特点,缓存分页页数,保证每次查询的准确,避免了多线程情况下的脏读情况
Long incr = jedis.incr("count");//分页语句的 页数
/*
selectList() 为分页查询 每次查询1000条
*/
List<QualityData> ms = selectList(1000,incr);
System.out.println(Thread.currentThread().getName()+"查出数据:"+ms.size()+" 正在推送redis...");
for (int j = 0; j < ms.size(); j++) {
// 数据推送redis
jedis.rpush(DATA_KEY,com.alibaba.fastjson.JSONArray.toJSONString(ms.get(j)));
}
dataSize = dataSize + ms.size();
if (MyUtils.isEmpty(ms)) {
break;
}
jedis.close();
return "线程"+Thread.currentThread().getName()+"共查询出数据:"+dataSize;
}
}
}
/**
* 多线程读取数据
*/
public void produceData () {
//获取CPU线程数 根据机器配置灵活配置线程数
int cpuPoolSize = Runtime.getRuntime().availableProcessors();
ExecutorService newFixedThreadPool= Executors.newFixedThreadPool(cpuPoolSize );
List<Future<String>> result = new ArrayList<Future<String>>();
for (int i = 0;i < cpuPoolSize ;i++) {
Future<String> future = newFixedThreadPool.submit(producer);
result.add(future);
}
for (Future<String> future : result) {
try {
System.out.println(future.get());
} catch (InterruptedException e) {
e.printStackTrace();
} catch (ExecutionException e) {
e.printStackTrace();
}
}
newFixedThreadPool.shutdown();
}
producer设计的核心之处就是利用redis的特性保证每个线程每次进行分页查询的时候读取到的数据不会出现脏读,
多线程这里采用newFixedThreadPool定长线程池是为了保证线程不会因为闲置超时而被销毁.
consumer实现代码如下:
/**
* 数据生成
* @author whz
*
*/
public class QualityConsumer implements Callable<String>{
private Logger logger = Logger.getLogger("quality");
private List<Quality> messages;
@Override
public String call() throws Exception {
Long bt = System.currentTimeMillis();
Jedis jedis = initJedis();
Integer countSize = 0;
Long beginTime = System.currentTimeMillis();
String threadName = Thread.currentThread().getName();
QualityVoucherMessage me = null;
while (true) {
//通过jedis提供的blpop方法采用阻塞式读取数据,即仅队列里有数据时才读取,如果producer还未将数据推送过来则阻塞,该方法也可用于简易的消息队列实现.
List<String> list = jedis.blpop(60*60*10, QualityProducer.DATA_KEY);
if (MyUtils.isEmpty(list)) {
continue;
}
try {
me = JSONArray.parseObject(list.get(1), Quality.class);
//加载配置中心,选择数据处理模板
QualityTemplateMethodService operater = factory.createQualityFactory(me);
//执行数据生成逻辑
operater.createQuality(me.getId().toString());
countSize = countSize + 1;
}
catch (Exception e) {
//保存处理错误的数据....
jedis.lpush("errorData",com.alibaba.fastjson.JSONArray.toJSONString(me));
logger.error(e.getMessage(), e);
e.printStackTrace();
}
//每十分钟记录一次每个线程处理的数据总量
if ((System.currentTimeMillis() - beginTime)/1000 > 600) {
logger.info(threadName+"已生成数据:"+countSize);
//重置起始时间
beginTime = System.currentTimeMillis();
}
}
jedis.close();
return threadName+"处理完毕,耗时:"+((System.currentTimeMillis()-bt)/1000+"共处理数据:"+countSize);
}
}
/**
多线程生成数据
*/
public void createQualityInfoJob () {
int cpuPoolSize = Runtime.getRuntime().availableProcessors();
ExecutorService newFixedThreadPool= Executors.newFixedThreadPool(cpuPoolSize);
List<Future<String>> result = new ArrayList<Future<String>>();
for (int i = 0;i<cpuPoolSize+1;i++) {
Future<String> future = newCachedThreadPool.submit(consumer);
result.add(future);
}
for (Future<String> future : result) {
try {
logger.info(future.get());
} catch (InterruptedException e) {
e.printStackTrace();
} catch (ExecutionException e) {
e.printStackTrace();
}
}
newFixedThreadPool.shutdown();
}
consumer设计的核心之处就是利用redis的阻塞队列保证每次取出的数据不重复并且不会有并发问题,在没有数据时程序阻塞直至有电文推送过来
因为充分利用了redis读写快,单线程的特点,所以在实际使用过程中,不光可以运用多线程处理数据,还可以根据需要进行多服务部署,大大的加快了数据处理速度,最终整体结构如下:
在实际使用中,通过监测redis中存储的数据,以及根据日志实时打印的consumer数据处理速度发现在多线程的情况下采用分页查询每次只取1000条,数据读取速度远高于consumer的处理速度,然后通过利用公司闲置测试服务器以及其它备用电脑,架设多个consumer服务,最终200多万条电文数据,大小在10-20GB 通过架设多个服务最终在较短的时间内完成了历史数据的割接,保证了功能的正常上线与使用.
总结
本次处理案例中运用到的核心知识点:
1.利用redis特性保证多线程环境下分页查询不出错
2.通过redis保证了数据的读写效率,并将数据通过第三方缓存,方便进行多机部署,使服务灵活扩展性大大提高,读取慢就多架设producer,处理慢就多架设consumer
3.充分利用多线程的性能优势加快数据处理速度.
4.因redis缓存了分页页数和数据,在有其它突发情况下可暂停处理,不会出现程序的不可逆性(突然断电断网或程序异常导致服务宕机)
其它踩过的坑
因 对象的实例化交由了Spring来托管,在多线程的情况下,我用复用对象field时会有并发问题:
例:
@Service
public class QualityOperateServiceImpl implements QualityOperateService{
private String msgId;//电文ID
private List<ConfigDetails> configs//配置中心
private String content;//电文内容
}
在多线程的情况下假如A线程读取到的电文为PGF001,在调用数据生成逻辑之前 因为QualityOperateServiceImpl 对象为单例,此时B线程将其field又设置为了XGF001的电文,此时A线程重新读取该对象field就会拿到B线程的值,及有概率出现并发问题.
为解决这个问题只需要将这几个field设置为Threadlocal ,即每个线程都持有一份它的本地变量便可轻松解决.
最后
唯有良好的编码习惯,开发前充分思考,充分考虑到程序的扩展性,梳理好模块结构,尽可能的做到灵活配置,方能做到最大的提高自身的工作效率,并应该学会融合贯通,充分的发挥自身所学,将其应用到实际项目中,才能提高产品质量,提高自我修养,希望各位小伙伴看后能够对其有所帮助,在日后的开发中能够编写质量更好的代码.