实时数据统计

我们在做一些活动时(如抽奖活动),经常需要在页面上展示当前参与活动的人数,中奖人数(或者是按奖品类型统计)等的一些统计数据。本文就是介绍如何高效地展示这些统计数据的一些技巧。

使用数据库进行实时统计存在的性能问题

通常情况下,我们会在数据库中(如mysql)中记录下活动过程中的一些日志类的信息,往往会有以下几张记录表:

  • 活动参与者表(activity_participation)——维护活动与用户的关系,唯一键是(activity_id,user_id)
  • 抽奖记录表(lottery_record)—— 记录用户的抽奖记录。

当页面需要展示活动的参与人数,中奖人数时,往往使用以下的语句进行数据统计:

select count(1) from activity_participation where activity_id=1 and status=1;
select count(1) from lottery_record where activity_id=1 and status=1;

在以上的查询中,当activity_participationlottery_record 的数据量很大的时候,查询的速度会非常的慢。即使对activity_id添加索引,收到的效果也不会太大(这是由于mysql的b+tree数据结构决定的,参考MySQL索引背后的数据结构及算法原理)。

对于这类问题的解决思路,通常就是空间换时间了,空间即是存储,而我们常用的存储要么是数据库,要么是缓存。本文提供的两种解决技巧就是分别基于数据库和缓存的。有了存储空间之后,我们还需要知道何时把统计数据更新进存储空间呢?——当然是在操作这些日志类记录的时候进行了。

下面的解决技巧基于统计活动参与人数这个简单的数据统计场景进行展开。

基于数据库

基于数据库的实现方式相对简单,因为数据库本身提供了ACID事务。

新建一张活动统计表activity_statistics

create table `activity_statistics`(
	`id` int unsigned not null primary key AUTO_INCREMENT comment 'id',
	`activity_id` int unsigned not null comment '活动ID',
	`statistics_item` varchar(64) not null comment '统计项',
	`statistics_value` decimal(10,2) default 0 comment '统计值',
	`updated` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '最后修改时间',
	`created` datetime not null default CURRENT_TIMESTAMP COMMENT '创建时间',
	UNIQUE(`activity_id `,`statistics_item`)
) comment '活动统计表';

下面给出实现的伪代码:

//加入活动
@Transaction
joinActivity(activityId,userId){

	//新增活动参与记录。注意,activity_participation表存在唯一键(activity_id,user_id)
	insertLines=insert ignore into activity_participation(activity_id,user_id) values ({activityId},{userId});

	if(insertLines>0){
		addParticipationCount(activityId,insertLines)
	}

}


// 新增活动参与人数
addParticipationCount(activityId,count){
	// 直接新增记录
	updateLines=update activity_statistics set statistics_value=statistics_value + {count} where activity_id={activityId} and statistics_item='活动统计人数';

	if(updateLines<=0){
		// 实时统计当前参与人数(这步其实是多余的,只有在上线前还没有activity_statistics表的时候才有意义)
		// selectCount=select count(1) from activity_participation where activity_id={activityId}
		selectCount = 1
		// 初始化统计记录
		insertLines=insert ignore into activity_statistics(activity_id,statistics_item,statistics_value)
		values ({activity_id},'活动统计人数',{selectCount});
		if(insertLines<=0){
			// 初始化失败,则递归调用增加活动参与人数
			addParticipationCount(activityId,count);
		}

	}

}

//获取活动参与人数
getParticipationCount(activityId){
	return select statistics_value from activity_statistics where activityId={activityId} and statistics_item='活动统计人数';
}

基于数据库的实现方式就是如此的简单。

基于缓存

上面基于数据库的实现方式能如此的简单,完全是受益于数据库对事务的完整支持。但缓存却没有完整的事务支持,最多也就支持事务中的原子性(Atomic),注:这里所说的缓存都假设缓存都是有失效时间的。这就令整件事情变得复杂多了。

对于基于缓存的实现技巧,我总结出了这种实现技巧必须满足的三个原则:

  1. 必须存在一个初始化缓存key的方法initCache,并且set操作必须在整个分布式环境中是同步的(即不可能存在并发创建该缓存key的情况出现)
  2. 在每个影响统计数据的操作之前要调用initCache方法对缓存key进行初始化。
  3. 缓存系统必须提供原子性的increase,descrease操作。

下面给出伪代码的实现方式:

//初始化缓存key
void initCache(activityId){
	if(cache.exists()){
		return
	}
	
	// 分布式锁
	try{
		lock=lock()
		if(cache.exists()){
			return
		}
		// 查数据库初始化缓存数据
		count = select count(1) from activity_participation where activity_id={activityId}
		setCache(activityId,count)
	}finally{
		lock.unlock();
	}
	
}

//加入活动
@Transaction
joinActivity(activityId,userId){
	initCache(activityId)
	
	//数据库操作新增参与者
	insertLines=insert ignore into activity_participation ...
	
	if(insertLines>0){
		increaseCache(activityId, insertLines)
		
		// -- WARN:在此处系统崩机了,会导致缓存与数据库中的数据不一致(数据库回滚了)
	}
}

//获取活动参与人数
getParticipationCount(activityId){
	if(cache.exists()){
		return cache.get();
	}
	initCache(activityId)
	
	return cache.get();
}

//增加缓存统计值
increaseCache(activityId, insertLines){
	//只有在缓存存在的情况下,才进行缓存统计值的增加
	if(cache.exists()){
		// do cache increase
		increaseResult = cache.incr(); 
		// 这里是为了防止在执行incr操作的时候,缓存失效而导致数据异常的补偿措施。(这种补偿措施是为了应对缓存系统缺失事务的隔离性和耐久性)
		if(increaseResult == insertLines){
			
			// -- WARN:在此时调用了getParticipationCount方法将可能得到错误的结果
			// -- WARN:在此处系统崩机了,会导致缓存与数据库中的数据不一致(数据库回滚了)
			cache.delete()
		}
	}
}

从上面的伪代码实现中可以看出,基于缓存的实现方式是存在缺陷的(看WARN部分的注释),这些缺陷从应用的角度上是无法彻底解决的,完全是因为数据统计的过程涉及了两个存储系统,一个是数据库,一个是缓存系统,对两个缓存系统进行操作必然存在一致性问题,这种一致性问题就非常复杂了,不是业务系统可以处理的了。

虽然上面的缓存实现方式存在缺陷,但这种实现方式已经是使用缓存系统处理这类问题最好的解决技巧了。

另外,可以添加以下机制来减少WARN问题带来的影响(只是一些优化措施,并没有根本上解决问题)

  1. 令缓存尽快失效来减少这些问题带来的影响,具体缓存的失效时间如何设定,就要按实际情况进行考虑了。
  2. 在服务启动过程中,把缓存数据删除。

总结

基于缓存的实现方式是存在缺陷的,而且实现方式复杂,会有一定机率出现数据不一致的情况,但优点只有一个,是缓存的读取性能会比数据库稍优。只有应用允许出现短暂数据不一致的情况下才可以使用

基于数据库的实现方式是完美的,实现方式简单,性能略差于缓存系统。

其实从性能角度来看,数据库读取的性能并不比缓存系统低很多,因为活动统计表activity_statistics的读取完全是走唯一索引的。

基于以上所述,建议使用数据库进行实时数据统计。