先说下事情的背景,公司的某个业务系统使用的是mongodb数据库,app首页是大概十几个统计维度的一个报表,可以按照时间筛选。用户组织的层级关系有点 类似集团-大区-小区-经销商这种,上一级可以点进去看下一级的数据,最后一级是经销商的数据。当前单表数据大概60w,考虑到后期的业务增长,以及app对于接口响应要求相对高一些,决定采用定时脚本的方式每晚统计历史数据保持到一张统计表,app只能查看截至前一天的数据。
设计思路定下来之后,下面就开始写mongodb脚本了:
//统计信息数据,有10个统计维度,这里就写10个
var statInfo = [{
"collectionName":"collection1",
"condition":{},
"countKey":"countKey1"
}]
//统计字段有两个:1个日期(将日期转换为年月日的格式),1个是用户里面最底的层级(这个例子里指companyId)
var statFunc = function(info){
db.getCollection(info.collectionName).aggregate([
{"$match":info.condition},
{"$project": {"companyId": 1, "date": {"$dateToString": {"date": "$createTime", "format": "%Y-%m-%d", "timezone": "Asia/Shanghai"}}}},
{"$group": {"_id": {"companyId": "$companyId",
"date": "$date"}, "count": {"$sum": 1}}}
]).forEach(x =>{
//将统计结果一一遍历更新到统计表,依据查询条件,没有就插入,有就更新
db.getCollection("company_daily_report").update({"companyId":x._id.companyId,"date":x._id.date},{"$set":{[info.countKey]:x.count}},true,false)
})
}
//遍历统计各个维度
statInfo.forEach(info => statFunc(info))
上面这个例子是通常的mongodb脚本形式,由于之前写过很多,包括维护前面同事留下的脚本。这种形式统计的话,时间会很长:按照统计内容的不同,少则几十分钟,多则好几小时,开发调试阶段时,非常耽误工作。于是决定放弃这种写法,准备尝试用python实现下,看能不能有所改善。
import pymongo
url = 'xxxxxxxxxxxxxxxxx'
client = pymongo.MongoClient(url)
db = client.xxx
# 统计信息
statInfo = [{"collectionName":"collection1","condition":{},"countKey":"countKey1"}]
# 统计函数
def statFun(info):
pipeline = [
{"$match": info.condition},
{"$project": {"companyId": 1, "date": {"$dateToString": {"date": "${}".format(
dateKey), "format": "%Y-%m-%d", "timezone": "Asia/Shanghai"}}}},
{"$group": {"_id": {"companyId": "$companyId",
"date": "$date"}, "count": {"$sum": 1}}}
]
result = db.get_collection(info.collectionName).aggregate(pipeline)
for item in result:
filter = {"companyId": item["_id"]
["companyId"], "date": item["_id"]["date"]}
update = {"$set": {info.countKey: item["count"]}}
db.get_collection("company_daily_report").find_one_and_update(
filter, update, upsert=True)
# 遍历统计信息进行统计
for info in statInfo:
statFun(info)
上面这种形式,只是js脚本的python翻译而已,显然不会有太大性能提升,尝试执行了一次我就放弃了,十多个统计维度,十几分钟了第一个维度都还没有执行完。其实考虑用python写的一个主要原因是想用下python的多线程。下面加入多线程改善下代码:
import pymongo
import concurrent.futures
url = 'xxxxxxxxxxxxxxxxx'
client = pymongo.MongoClient(url)
db = client.xxx
# 统计信息
statInfo = [{"collectionName":"collection1","condition":{},"countKey":"countKey1"}]
def updateCompanyDailyStat(item, countKey):
filter = {"companyId": item["_id"]
["companyId"], "date": item["_id"]["date"]}
update = {"$set": {countKey: item["count"]}}
db.get_collection("company_daily_stat").find_one_and_update(
filter, update, upsert=True)
# 统计函数
def statFun(info):
pipeline = [
{"$match": info.condition},
{"$project": {"companyId": 1, "date": {"$dateToString": {"date": "${}".format(
dateKey), "format": "%Y-%m-%d", "timezone": "Asia/Shanghai"}}}},
{"$group": {"_id": {"companyId": "$companyId",
"date": "$date"}, "count": {"$sum": 1}}}
]
result = db.get_collection(info.collectionName).aggregate(pipeline)
# 加入多线程进行结果保持
with concurrent.futures.ThreadPoolExecutor(max_workers=20) as executor:
futures = [executor.submit(updateCompanyDailyStat,item, info.countKey) for item in result]
results = [f.result() for f in concurrent.futures.as_completed(futures)]
print("info.countKey:", len(results))
# 遍历统计信息进行统计
for info in statInfo:
statFun(info)
引入多线程后效果明显,十多个统计维度执行下来,合计耗费25分钟左右,这可是个人以及公司前人们都没有达到的速度,效果很满意,目标达成!
可上线没几天,问题来了,原因是pc端有个类似的报表,当时由于各种因素,做的是实时统计,看最近一段时间范围的数据的话,响应时间在1-2秒,勉强可以接受。但是问题不在这里,问题在有几个统计维度,历史数据是可能会少量变化的,于是有人就拿pc和app开始对数了,怎么两边数据不一致?解释一通理解了,但是还是得要求两边数据一致,结果就是app也得做成实时统计。好了,前面工作白费。。。不情愿归不情愿,工作还得做!
最直接的肯定是把app的对应接口重写一遍,改成实时统计,不过这个工作耗时相对长一点,短期内工作排期有点安排不开。于是想着能不能再把脚本优化下,缩短下执行时间,从每天执行一次,改成每天执行多次。
很多时候,从0做到90很简单,从90做到100却很难。
下面的优化,直接写答案吧,艰难过程不表,耗时大半天时间。
1、把多线程的最大线程数从20改成了100,执行时间从25分钟降到了 20分钟左右;
2、在统计表创建索引{"company":1,"date":-1},一般我们可能是在有数据后才创建索引,但是这里如果先创建索引的话,会提升upsert的速度;
3、把管道里面的$project省略掉,日期转换表达式写在$group里面,把一个聚合查询从3.6s优化到了2.6s左右,节约时间1s:
import pymongo
import concurrent.futures
url = 'xxxxxxxxxxxxxxxxx'
client = pymongo.MongoClient(url)
db = client.xxx
# 统计信息
statInfo = [{"collectionName":"collection1","condition":{},"countKey":"countKey1"}]
def updateCompanyDailyStat(item, countKey):
filter = {"companyId": item["_id"]
["companyId"], "date": item["_id"]["date"]}
update = {"$set": {countKey: item["count"]}}
db.get_collection("company_daily_stat").find_one_and_update(
filter, update, upsert=True)
# 统计函数
def statFun(info):
pipeline = [
{"$match": info.condition},
{"$group": {"_id": {"companyId": "$companyId",
"date": {"$dateToString": {"date": "$createTime", "format": "%Y-%m-%d", "timezone": "Asia/Shanghai"}}}, "count": {"$sum": 1}}},
]
result = db.get_collection(info.collectionName).aggregate(pipeline)
# 加入多线程进行结果保存
with concurrent.futures.ThreadPoolExecutor(max_workers=100) as executor:
futures = [executor.submit(updateCompanyDailyStat,item, info.countKey) for item in result]
results = [f.result() for f in concurrent.futures.as_completed(futures)]
print("info.countKey:", len(results))
# 遍历统计信息进行统计
for info in statInfo:
statFun(info)
4、其实整个脚本耗时占比最大的不是聚合查询,是将查询结果遍历更新到统计表,下面使用了内置函数$merge,省掉了遍历,整个脚本耗时从20分钟降到了10秒,服务器资源占用率也极大降低,执行前需要创建一个唯一索引{"company":1,"date":-1},否则会报错:
import pymongo
import concurrent.futures
url = 'xxxxxxxxxxxxxxxxx'
client = pymongo.MongoClient(url)
db = client.xxx
# 统计信息
statInfo = [{"collectionName":"collection1","condition":{},"countKey":"countKey1"}]
# 统计函数
def statFun(info):
pipeline = [
{"$match": info.condition},
{"$group": {"_id": {"companyId": "$companyId",
"date": {"$dateToString": {"date": "$createTime", "format": "%Y-%m-%d", "timezone": "Asia/Shanghai"}}}, "count": {"$sum": 1}}},
{"$project": {"_id": 0, "companyId": "$_id.companyId",
"date": "$_id.date", info.countKey: "$count"}},
{'$merge': {
"into": "company_daily_report",
"on": ["companyId", "date"],
"whenMatched": "merge",
"whenNotMatched": "insert"
}}
]
return db.get_collection(info.collectionName).aggregate(pipeline)
# 加入多线程进行统计
with concurrent.futures.ThreadPoolExecutor(max_workers=20) as executor:
futures = [executor.submit(statFun,info) for info in statInfo]
results = [f.result() for f in concurrent.futures.as_completed(futures)]
print("size:", len(results))
到这里,本次脚本调优就算结束了。虽然后面还是需要编写实时统计相关的接口,不过本次调优的历程及相关优化方法,为以后的开发积累了宝贵经验。