mobile wallpaper 1mobile wallpaper 2mobile wallpaper 3mobile wallpaper 4mobile wallpaper 5mobile wallpaper 6mobile wallpaper 7mobile wallpaper 8mobile wallpaper 9mobile wallpaper 10mobile wallpaper 11mobile wallpaper 12
907 字
2 分钟
优化日志分析店铺推荐方案:用户范围的精确度以及ES与MySQL的查询效率差异
2025-07-07

目录

一、旧版方案

二、分析

修改方案

(一)问题1

(二)问题2

三、具体流程图

四、代码实现


一、旧版方案#

点餐场景下:分析实现十万级用户日志的店铺推荐方案-CSDN博客


二、分析#

旧版方案的分析数据是通过近三天的全部日志数据,再查询近30天的全部日志数据,建立用户与日志的Map映射后逐一遍历进行分析。

这样会导致几个问题:

一是推荐用户对象范围不精确,应该推荐给频繁登录的用户,但旧版方案为平衡计算开销,只选择了近三天登陆过的用户。

二是分析的数据量级过大,可能导致,因此旧版方案采用了分片并行处理的方案,但是也正因如此导致耗时过大。

因此,我们需要进行修改。

修改方案#

(一)问题1#

首先针对第一个问题,我们应该记录用户一个月内登录的天数,当天数到达一定值的时候,将其标记为热点用户,作为日志分析的对象。

那如何记录呢,很多同学可能会想到使用的BitMap,因为其占据内存小。

但我们并没有采用位图,而是采用的,因为我们并不需要知道用户哪些天是登陆过的,我们只需要知道用户登录了几天就好了。

由于这是小程序项目,用户登陆数据是会存储到本地的,所以用户并不会每天都调用登录接口,我们应该寻找别的办法。

既然我们分析的日志是用户进店时产生的日志,那不妨我们将用户当天产生的第一次进店行为视作登录行为,在第一次进店时在Redis存储对应的数据,键名为UserId+当天日期,为离当天23:59:59时刻的时间戳,每次进店都进行SETNX操作,这样就只有第一次进店会成功缓存到Redis,并且在缓存的同时对该用户该月的登陆天数Key做自增操作。以上都放在脚本中保障原子性。

如此这般,我们就只需要获取该月登陆天数超过一定值的用户Id即可。

(二)问题2#

对于第二个问题,旧版方案是将热到ES,但其实对于10w量级来说,ES和的效率差别并不大,但是如果MySQL建立了索引的情况下,反而MySQL的效率更高。

所以在修改方案中,我们将热数据存放到了MySQL,而将冷数据存到ES进行备份。

旧版是建立用户id与日志的映射关系,修改后改为了用户id与当月进过的店铺id的映射关系,节省了空间,而且操作的效率也大大提高。


三、具体#


四、代码实现#

-- 用户ID
local uid = ARGV[1]
-- 当前月份 格式 "YYYY:MM"
local nowMonth = ARGV[2]
-- 当前日期 格式 "YYYYmmDD"
local nowDate = ARGV[3]
-- 构建 Key
local dateKey = 'report:enterStoreDaysInMonth:' .. nowDate .. ':' .. uid
local monthKey = 'report:enterStoreDaysInMonth:' .. nowMonth .. ':' .. uid
-- 判断是否为当日首次进店
if redis.call('SETNX', dateKey, 1) == 1 then
-- 获取当前redis服务器时间
local time = redis.call('TIME')
local currentSeconds = tonumber(time[1])
-- 计算当天结束时间戳(即23:59:59的时间戳)
local endOfDay = currentSeconds - (currentSeconds % 86400) + 86399
-- 计算剩余过期时间
local ttl = endOfDay - currentSeconds
-- 如果刚好再到23:59:59,则设置为1秒后过期
if ttl < 0 then
ttl = 1
end
-- 设置过期时间
redis.call('EXPIRE', dateKey, ttl)
-- 增加当月累计进店天数
redis.call('INCR', monthKey)
end
@RequiredArgsConstructor
@Slf4j
@Component
public class ReportStoreRecommendTask {
private final RestHighLevelClient restHighLevelClient;
private final StringRedisTemplate stringRedisTemplate;
private final Top10MarkStoreMapper top10MarkStoreMapper;
private final StoreMapper storeMapper;
private final EnterStoreUserLogMapper enterStoreUserLogMapper;
private final ThreadPoolExecutor threadPoolExecutor;
@XxlJob( value = "store-recommend")
public void processStoreRecommend() {
LocalDateTime start = LocalDateTime.now();
log.info("开始执行用户进店日志分析,执行时间:{}", start);
// 获取热门榜的作品,只拿前4个
List<Top10MarkStore> top10MarkStoreList = top10MarkStoreMapper.selectList(new QueryWrapper<Top10MarkStore>()
.orderByDesc("mark_score")
.last("LIMIT 4"));
// 如果db没有数据则需要抛出异常
if (top10MarkStoreList == null || top10MarkStoreList.isEmpty()){
throw new Top10MarkStoreException(MessageConstant.TOP10_MARK_IS_NULL);
}
// 打乱顺序
Collections.shuffle(top10MarkStoreList);
List<Top10MarkStoreVO> top4MarkStoreList = new ArrayList<>();
// 添加排序
int index = 1;
for(Top10MarkStore markStore : top10MarkStoreList){
Store store = storeMapper.selectById(markStore.getStoreId());
Top10MarkStoreVO vo = BeanUtil.copyProperties(store, Top10MarkStoreVO.class);
vo.setStoreId(markStore.getStoreId());
vo.setMarkScore(markStore.getMarkScore());
vo.setSort(index++);
top4MarkStoreList.add(vo);
}
// 备用方案
String cacheAllKey = STORE_RECOMMEND_ONE + "all";
// 删除所有旧数据(包括用户的推荐数据)
String all = STORE_RECOMMEND_ONE + "*";
Set<String> keys = stringRedisTemplate.keys(all);
if (keys != null && !keys.isEmpty()) {
stringRedisTemplate.delete(keys);
}
// 缓存数据
List<String> allStoreJsons = top4MarkStoreList.stream()
.map(top4MarkStoreVO -> JSON.toJSONString(top4MarkStoreVO, SerializerFeature.WriteDateUseDateFormat))
.toList();
stringRedisTemplate.opsForList().rightPushAll(cacheAllKey, allStoreJsons);
// 检查当前redis有没有上个月热点用户的id集合
String lastMoth = LocalDateTime.now().minusMonths(1).format(DateTimeFormatter.ofPattern("yyyy:MM"));
String idsKey = RedisConstant.MONTH_HOT_USER_KEY + lastMoth;
// 如果不存在,则查询上个月的热点用户
if (Boolean.FALSE.equals(stringRedisTemplate.hasKey(idsKey))) {
// 创建ids集合
Set<String> hotUserIds = new HashSet<>();
String hotUserKey = RedisConstant.REPORT_ENTER_STORE_DAYS_IN_MONTH_KEY + lastMoth + ":*";
// 若对应的值大于等于14,则加入集合
Set<String> hotUserKeys = stringRedisTemplate.keys(hotUserKey);
if (hotUserKeys != null && !hotUserKeys.isEmpty()) {
for (String key : hotUserKeys) {
if(Integer.parseInt(Objects.requireNonNull(stringRedisTemplate.opsForValue().get(key))) >= 14) {
String[] split = key.split(":");
String userId = split[split.length - 1];
hotUserIds.add(userId);
}
}
// 缓存到redis
stringRedisTemplate.opsForSet().add(idsKey, hotUserIds.toArray(new String[0]));
}
}
// 读取缓存
Set<String> hotUserIds = stringRedisTemplate.opsForSet().members(idsKey);
// ids为空则代表上个月没有热点用户,则都采用备用方案即可
if (hotUserIds == null || hotUserIds.isEmpty()) {
return;
}
// 分批查询
List<Long> userIds = hotUserIds.stream().map(Long::valueOf).collect(Collectors.toList());
List<List<Long>> partitionedUserIds = ThreadPoolUtil.slicingData(userIds, 400);
List<Map<String, Object>> logList = new CopyOnWriteArrayList<>();
// 批次并行处理
partitionedUserIds.forEach(batch -> {
List<CompletableFuture<Void>> futures = new ArrayList<>();
batch.forEach(userId -> {
CompletableFuture<Void> future = CompletableFuture.runAsync(() -> {
List<Map<String, Object>> batchResult = enterStoreUserLogMapper.selectMaps(new QueryWrapper<EnterStoreUserLog>()
.select("user_id", "store_id")
.in("user_id", Collections.singletonList(userId))
.ge("create_time", LocalDateTime.now().minusMonths(1)));
logList.addAll(batchResult);
}, threadPoolExecutor);
futures.add(future);
});
ThreadPoolUtil.allFuturesWait(futures);
});
// 分组
Map<Long, List<Long>> userIdAndStoreIds = logList.stream()
.collect(Collectors.groupingBy(
map -> ((Number) map.get("user_id")).longValue(),
Collectors.mapping(
map -> ((Number) map.get("store_id")).longValue(),
Collectors.toList()
)
));
// 权重系数配置
final double STORE_WEIGHT = 0.5;
final double CATEGORY_WEIGHT = 0.3;
final double CLASSIFICATION_WEIGHT = 0.2;
// 将用户数据分片(每 500 用户一批)
List<Map.Entry<Long, List<Long>>> userEntries =
new ArrayList<>(userIdAndStoreIds.entrySet());
List<List<Map.Entry<Long, List<Long>>>> batches =
ThreadPoolUtil.slicingData(userEntries, 500);
// 批次并行处理
batches.forEach(batch -> {
List<CompletableFuture<Void>> futures = new ArrayList<>();
batch.forEach(entry -> {
CompletableFuture<Void> future = CompletableFuture.runAsync(() -> {
Long userId = entry.getKey();
List<Long> storeIds = entry.getValue();
try {
analyseLog(userId, storeIds, STORE_WEIGHT, CATEGORY_WEIGHT, CLASSIFICATION_WEIGHT, top4MarkStoreList);
}catch (Exception e){
// 即使有一个用户进店日志分析失败,也不影响其他用户的分析
log.error("用户id为{}的进店日志分析失败,失败时间:{}",userId,LocalDateTime.now(),e);
}
}, threadPoolExecutor);
futures.add(future);
});
// 等待当前批次完成
ThreadPoolUtil.allFuturesWait(futures);
});
log.info("用户进店日志分析结束,结束时间:{},花费时间:{}s", LocalDateTime.now(), Duration.between(start, LocalDateTime.now()).getSeconds());
}
private void analyseLog(Long userId, List<Long> storeIds, double STORE_WEIGHT, double CATEGORY_WEIGHT, double CLASSIFICATION_WEIGHT, List<Top10MarkStoreVO> top4MarkStoreVOList) throws Exception {
// 记录店铺、分区、分类的次数
Map<Long, Integer> storeCountMap = new HashMap<>();
Map<Integer, Integer> categoryCountMap = new HashMap<>();
Map<Integer, Integer> classificationCountMap = new HashMap<>();
// 记录店铺对应的分类和分区
Map<Long, Integer> storeCategoryMap = new HashMap<>();
Map<Long, Integer> storeClassificationMap = new HashMap<>();
// 统计基础数据
storeIds.forEach(id -> {
// 获取店铺对应的分类和分区
String storeKey = RedisConstant.STORE_ALL_STORE_LIST_KEY + id;
Store store = null;
if(Boolean.FALSE.equals(stringRedisTemplate.hasKey(storeKey))){
// 如果redis没有店铺信息,则从mysql中查询
store = storeMapper.selectById(id);
}else {
// 如果redis有店铺信息,则从redis中查询并转化成对象
String storeJson = stringRedisTemplate.opsForValue().get(storeKey);
store = JSONUtil.toBean(storeJson, Store.class);
}
// 店铺不存在则记录日志,并跳过该次循环,不影响后续操作
if(store == null){
log.error("店铺id为{}的店铺不存在,请检查数据库",id);
return;
}
Integer categoryId = store.getCategoryId();
Integer classificationId = store.getStoreClassificationId();
// 统计次数
storeCountMap.merge(id, 1, Integer::sum);
categoryCountMap.merge(categoryId, 1, Integer::sum);
classificationCountMap.merge(classificationId, 1, Integer::sum);
// 记录店铺元数据(首次出现时记录)
storeCategoryMap.putIfAbsent(id, categoryId);
storeClassificationMap.putIfAbsent(id, classificationId);
});
// 计算店铺得分
List<StoreScore> scoreList = new ArrayList<>();
storeCountMap.forEach((storeId, count) -> {
// 获取店铺对应的分类和分区
Integer categoryId = storeCategoryMap.get(storeId);
Integer classificationId = storeClassificationMap.get(storeId);
//获取店铺实体
Store store = storeMapper.selectById(storeId);
StoreVO storeVO = new StoreVO();
BeanUtils.copyProperties(store, storeVO);
// 获取对应计数
int categoryCount = categoryCountMap.getOrDefault(categoryId, 0);
int classificationCount = classificationCountMap.getOrDefault(classificationId, 0);
// 计算加权得分
double score = STORE_WEIGHT * count
+ CATEGORY_WEIGHT * categoryCount
+ CLASSIFICATION_WEIGHT * classificationCount;
scoreList.add(new StoreScore(storeId, storeVO, score));
});
// 按得分降序排序
scoreList.sort((a, b) -> Double.compare(b.getScore(), a.getScore()));
// 生成推荐列表
List<StoreVO> recommendedStores = new ArrayList<>(scoreList.stream()
.limit(4)
.map(StoreScore::getStoreVO)
.toList());
// 去重(当个性化推荐和热门榜补充时可能会出现重复店铺)
Set<Long> existStoreIds = recommendedStores.stream()
.map(storeVO -> storeVO.getId().longValue())
.collect(Collectors.toSet());
String cacheKey = STORE_RECOMMEND_ONE + userId;
// 将去重后的店铺数据存到集合里
List<String> storeJsons = new ArrayList<>();
for(int i = 0; i < recommendedStores.size(); i++){
StoreVO store = recommendedStores.get(i);
Top4MarkStoreVO top4MarkStoreVO = new Top4MarkStoreVO();
BeanUtils.copyProperties(store, top4MarkStoreVO);
top4MarkStoreVO.setStoreId(Long.valueOf(store.getId()));
top4MarkStoreVO.setMarkScore(store.getStoreRating());
top4MarkStoreVO.setSort(i + 1);
storeJsons.add(JSONUtil.toJsonStr(top4MarkStoreVO));
}
// 补充热门店铺
int count = 4 - recommendedStores.size();
for (int i = 0; i < top4MarkStoreVOList.size() && count > 0; i++) {
Top10MarkStoreVO candidate = top4MarkStoreVOList.get(i);
if (!existStoreIds.contains(candidate.getStoreId())) {
candidate.setSort(recommendedStores.size() + i + 1);
storeJsons.add(JSONUtil.toJsonStr(candidate));
count--;
}
}
// 缓存数据
stringRedisTemplate.opsForList().rightPushAll(cacheKey, storeJsons);
}
}

如果你有更好的方案,请在评论区告诉我!

码文不易,点个赞再走吧


原文链接: 优化日志分析店铺推荐方案:用户范围的精确度以及ES与MySQL的查询效率差异 作者: Yilena

分享

如果这篇文章对你有帮助,欢迎分享给更多人!

优化日志分析店铺推荐方案:用户范围的精确度以及ES与MySQL的查询效率差异
https://blog.csdn.net/2401_88959292/article/details/148618437?spm=1001.2014.3001.5501
作者
Yilena
发布于
2025-07-07
许可协议
CC BY 4.0

部分信息可能已经过时

相关文章 智能推荐
1
优化:将针对单一日志表的冷热数据分离类改造成通用类
业务拆解 本文记录了将针对单一日志表的冷热数据分离逻辑重构为通用类的优化过程。为解决旧版方案中代码冗余及复用性差的问题,新方案通过参数化固定逻辑、引入泛型机制适配不同数据实体,并为不同日志表提供专属的Elasticsearch插入重载函数,实现了高度定制化与通用性的统一。文章详细展示了重构后的具体流程图及Java代码实现,包括利用CompletableFuture进行多线程并行处理、基于游标的批量数据查询与迁移、以及完善的重试与异常处理机制,有效提升了海量日志数据冷热分离任务的执行效率与代码可维护性。
2
有关慢查询SQL优化的思路
技术笔记 本文系统总结了MySQL慢查询SQL的定位方法与优化思路。首先介绍了如何通过命令行或配置文件开启慢查询日志,并使用mysqldumpslow工具进行日志分析。随后,从六个核心维度深入剖析了慢查询的常见原因及优化策略:包括索引的合理设置与失效规避、SQL语句的精简与重构(如避免冗余字段、优化JOIN与排序)、针对不同数据量级的查询方案(如延迟关联与游标查询)、表结构设计的规范(如主键设置、分库分表)、业务逻辑的批量与并行处理,以及关键系统参数的调优建议,为开发者提供了一套全面且实用的数据库性能提升指南。
3
优先队列流式处理 + 多路归并排序:轻松实现DB分表严格有序的分页查询
业务拆解 本文针对在MySQL分表架构下且磁盘空间紧张的场景,提出了一种高效实现严格有序分页查询的解决方案。面对跨表查询带来的深分页难题,文章摒弃了建立庞大映射表的空间换时间策略,转而采用“优先队列流式处理 + 多路归并排序”的创新方法。通过虚拟线程并行游标查询各分表数据,并利用容量固定的优先队列在内存中实时维护Top-N结果,有效控制了内存占用并避免了OOM风险。文章详细解析了方案的设计思路,并提供了完整的Java代码实现,经测试在万级分表数据下响应时间可达毫秒级。
4
点餐场景下:分析实现十万级用户日志的店铺推荐方案
业务拆解 本文针对点餐小程序十万级用户进店日志场景,设计并实现了一套高效的个性化店铺推荐方案。通过冷热数据分离策略,利用Elasticsearch存储近30天热数据,结合MySQL与Redis优化查询性能。文章详细阐述了基于访问频次、分区及分类偏好的加权评分排序算法,并规划了每日定时执行的离线分析任务以平衡系统开销。最后,提供了完整的实体类定义、ES数据迁移定时任务及多线程并发日志分析的Java代码实现,为海量日志分析与推荐系统落地提供了实践参考。
5
116秒→6秒:Redis管道+批处理优化用户好友关系校验的方案
业务拆解 本文针对社交平台中用户好友关系数据一致性问题,提出了一种高效的定时任务解决方案。通过分析初版方案的性能瓶颈(单线程串行处理导致116秒耗时),逐步优化为多线程并行处理(70秒)和最终版批量预加载策略(6秒)。终版方案的核心改进包括:1)预加载所有关注关系并建立内存映射;2)批量处理好友数据更新;3)使用Redis管道技术减少网络请求。最终将请求次数从35万次降至常数级,同时提供了完整的Java实现代码,包含分片处理、批量数据库操作和Redis管道更新等关键优化技术。

目录

封面
Sample Song
Sample Artist
封面
Sample Song
Sample Artist
0:00 / 0:00