1. 背景
最近一个项目要求能够近实时快速统计出来操作组和操作员工当天的操作数量。具体业务情况是:一个操作部会分若干个操作组,每个操作组有几个操作员工,如果有货物过来会平均分配给操作组进行操作,当再次有货物进来时,会分配给货物量最少的操作组处理,这样会造成有些偷懒的人,每天的工作量少,工作不平衡。因此业务部门建立一个考核机制,每天实时统计员工操作量(重复操作不算),根据操作量对操作员、组进行考核,所有需要我们能够快速的统计出来每个员工具体的操作量。
2. 设计方案
由于操作数据需要实时传送给后台服务的,所以决定,在后台接收到操作数据的时候,直接将操作数据推送大mq里面,实时消费数据,并将统计结果保存至redis,从而达到实时统计的目的。为防止统计数据数据的丢失,单独启动一个job每隔5分钟将统计好的数据保存到数据库中。
redis存储
redis存储,共分两部分存储数据,
1 存储操作员工的操作记录
使用 hash 存储数据 key :userid hash的key为货物的唯一标识id,value为货物的重量 (存储重量是为以后统计数量考虑)
2. 存储操作组每个员工的操作数量
同样使用hash存储 hash的key为员工编号 value为该员工的操作数量
之所以这样设置redis的存储,是因为 对于员工来说 我可以 hash_put 直接记录操作的货物id,从而保证重复消费时同一员工操作的货物记录唯一,同时可以直接通过 计算hash的大小就可以直接获取到操作货物的数量且不会重复。
对于操作组 只是为了方便获取值,取值的时候可以直接 获取整个hash对象 就可以知道这个操作组有哪些员工操作了那些货物(当然没有操作的员工将不会显示出来了)
3. 实现
本项目的实现是从开始消费mq里面的数据开始,
为确认数据一定会被消费统计,消费的每一条消息,必须使用手动确认消费成功
创建消费者
// 1. 创建消费者(Push)对象
defaultMQPushConsumer = new DefaultMQPushConsumer(groupName);
defaultMQPushConsumer.setInstanceName(UUID.randomUUID().toString());
try {
// 2. 设置NameServer的地址,如果设置了环境变量NAMESRV_ADDR,可以省略此步
defaultMQPushConsumer.setNamesrvAddr(NAME_SERVER_ADDR);
// 消费重试次数 -1代表16次
defaultMQPushConsumer.setMaxReconsumeTimes(-1);
//设置最大线程数量和最小线程数量
defaultMQPushConsumer.setConsumeThreadMax(1);
defaultMQPushConsumer.setConsumeThreadMin(1);
/**
* 3. 设置消息模式,默认是CLUSTERING
* MessageModel.BROADCASTING 广播消费模式
* MessageModel.CLUSTERING 集群消费模式
*/
defaultMQPushConsumer.setMessageModel(MessageModel.CLUSTERING);
// 3. 订阅对应的主题和Tag
defaultMQPushConsumer.subscribe(topic, tag);
// 4. 设置消息批处理数量,即每次最多获取多少消息,默认是1 (非必填)
// 本次设置一次抽取数量是5条,因为本程序只是为了收到消息,触发检查表统计数量并不关心每一条消息的暗送具体内容
defaultMQPushConsumer.setConsumeMessageBatchMaxSize(1);
// 4. 注册消息接收到Broker消息后的处理接口
defaultMQPushConsumer.registerMessageListener(consumerOutMessageListener);
// 5. 启动消费者(必须在注册完消息监听器后启动,否则会报错)
defaultMQPushConsumer.start();
@Component
public class ConsumerOutMessageListener implements MessageListenerConcurrently {
@Resource
private StaticService staticService ;
@Override
public ConsumeConcurrentlyStatus consumeMessage(List<MessageExt> list, ConsumeConcurrentlyContext consumeConcurrentlyContext) {
try {
MessageExt messageExt = list.get(0);
String messageBody = new String(messageExt.getBody(), RemotingHelper.DEFAULT_CHARSET);
if(StringUtils.isBlank(messageBody))
{
return ConsumeConcurrentlyStatus.CONSUME_SUCCESS;
}
String result = staticService .monitor(detail,messageExt.getKeys());
if(StringUtils.isBlank(result))
{
return ConsumeConcurrentlyStatus.CONSUME_SUCCESS;
}
return ConsumeConcurrentlyStatus.RECONSUME_LATER;
} catch (Exception e) {
log.error("consumeMessage异常",e);
return ConsumeConcurrentlyStatus.RECONSUME_LATER;
}
}
}
统计并保存至redis
String keyPre = staticNamespace+date1+":"+opDeail.getCreateSite()+":";
String rediskey = keyPre+opDeail.getOpUser();
String rediskeyGroup = keyPre+opDeail.getOpGroup();
OpDeail existDetal = null;
Object existObj = stringRedisTemplate.opsForHash().get(rediskey,opDeail.getGoodId());
if(existObj!=null)
{
existDetal = JSON.parseObject(existObj.toString(),OpDeail.class);
}
if(existDetal == null)
{
existDetal = new OpDeail();
existDetal.setCreateTime(opDeail.getCreateTime());
existDetal.setWeight(opDeail.getWeight());
String saveString1 = JSON.toJSONString(existDetal);
stringRedisTemplate.opsForHash().put(rediskey,opDeail.getGoodId(),saveString1);
}
else
{
if(existDetal.getCreateTime().compareTo(opDeail.getCreateTime())>=0)
{
//同一条记录无需再统计
return null;
}
else
{
existDetal.setCreateTime(opDeail.getCreateTime());
existDetal.setWeight(opDeail.getWeight());
String saveString1 = JSON.toJSONString(existDetal);
stringRedisTemplate.opsForHash().put(rediskey,opDeail.getGoodId(),saveString1);
}
}
Long length = stringRedisTemplate.opsForHash().size(rediskey);
stringRedisTemplate.opsForHash().put(rediskeyGroup,opDeail.getOpUser(),String.valueOf(length));
//记录下是否需要保存到数据库
stringRedisTemplate.opsForHash().put(keyPre+date1+":needSaveDb",opDeail.getCreateSite()+"-"+opDeail.getOpUser(),String.valueOf(true));
4. 同步落库
同步落库比较简单 就是启动一个job 每隔一段时间去检查下keyPre+date1+":needSaveDb" 是否需要保存至数据库 如果需要,获取下当天所在分组的统计结果直接将分组统计的结果保存至数据库中。
job只是为了将统计好的结果保存至数据库中,如果遇到redis宕机,仍然需要再次从数据库中统计结果
所以为了保证历史数据的正确性,需要启动另外一个job 每天凌晨 统计昨天的员工操作量,与以保存的昨天的员工数据量做校验,如果不符合则更新记录(具体实现简单,此处就不在叙述了)
5. 结果及缺陷
本方法能够快速将操作员工的操作量统计出来。
但是所有的统计结果均保存在redis里面,实时查询时,必须访问redis,从redis里面获取结果。假如redis宕机,则必须从记录操作的的数据库中统计出操作结果存入redis中,方可继续后续的操作。需要多一个job每天统计一次进行数据的校验操作