浅聊一个三毛钱的首页图片的推荐算法
前言
简单说,这篇文章就是我在实现过程中的一个解决思路,并不是什么高深理论;主要就是怎么根据图片的信息把热度相对较高的图片在首页进行更好的展示。
里面用到了一点点数学公式,不过核心思想其实很简单。
说白了,这个推荐算法就是数学函数的应用,想直接看计算方法的,可以从 数据标准化 那部分开始看。如果你想自己动手实现或者优化一下,那就好好看看这篇文章。
PS:这只是我个人思考和实践的记录,写的可能不够完美,有啥不对的地方欢迎指点哈!
图库项目地址:GitHub 欢迎大家给个 star ⭐⭐ 支持一下!
暴龙图库地址:暴龙图库
大概思路和为啥要这么做
图片那么多,怎么把优质的图片或者大家比较喜欢的图片放首页,是个挺重要的问题。光按上传时间排,肯定不行,新上传的不一定好,老图片不一定不好。
所以,这个推荐算法也就来了。说到底也是从三个主要方面来考虑:
- 图片大家怎么互动:比如点赞了没?收藏了没?下载了没?分享了没?看了多少次?这些直接说明这张图片受欢迎程度咋样。
- 图片啥时候传的:也就是新鲜度。刚传的图片通常大家更关注,放的时间越长,热度应该慢慢降下来。
- 图片本身质量咋样:比如是不是高清、看着舒服不?这个方面这次先不细说,但思路跟处理互动数据差不多。
基本想法就是:
- 给这几个方面(互动、时间、质量),以及互动里的具体动作(点赞、收藏等)分个重要性(权重)。
- 因为这些数据差别太大了(比如浏览量可能几万,点赞可能就几百),得把它们弄到差不多的尺度上(标准化)。
- 然后根据权重和标准化后的数据,给每张图片算一个综合的“热度”分数。
- 最后,谁分数高,谁就排在前面推荐给大家。
遇到的问题和怎么解决的
❓可能会有哪些问题
光有思路还不够,真写到代码里,能有不少麻烦事儿:
- 这些指标咋分配重要性(权重)?点赞、收藏、下载、分享、浏览,哪个更重要?时间呢?怎么定才合理?
- 数据差别太大了:比如浏览量几万,点赞才几十,直接算,那浏览量就把其他都盖过去了,权重等于白设。
- 时间咋算:图片放上去越久,热度降得越快?用什么计算表示这个“降”?
- 图片咋查:如果每次都去数据库查,然后实时算分数,特别是可能后面页有图片比当前页的还热,这就不好了。
- 实时算太慢:每次有人点赞、浏览,都去算一遍分数,服务器肯定受不了。
- 数据更新也慢:交互数据老变,每次都往数据库里写,数据库也吃不消。
⭐⭐ 分析和解决办法(重点来了)⭐⭐
1、权重咋定(我随便定的,你看着办)
权重这东西,没啥绝对标准,得凭感觉和试。我图库中用的下面的数据:
- 互动指标权重:
- 点赞:15% (0.15)
- 收藏:15% (0.15)
- 下载:15% (0.15)
- 分享:10% (0.10)
- 浏览:15% (0.15)
- 发布时间权重:30% (0.30)
2、数据标准化(把数据弄到差不多一个水平)
原始数据差距太大了,不能直接用,直接加权求和会导致数值大的指标(如浏览量)完全主导结果。因此,必须进行标准化,将所有指标映射到相近的数值范围(例如 [0, 1] 或一个合理的区间)。
- 交互数据标准化:用
Math.log1p(data_value)这个函数。- 函数解释:用于计算
1 + data_value的自然对数,对应的数学函数为log1p(x)=ln(1+x)。 - 为啥这么干:
log能把特别大的数变平滑一点,范围小点。+1是怕数据是0的时候,log(0)没意义。- 用
Math.log1p()而不是Math.log(1 + data_value)是因为电脑算的时候,1 + 一个特别小的数可能直接就等于1了,那log(1)就是0,信息就丢了。log1p这个函数专门处理小数,不会丢信息。
- 效果:把点赞、收藏这些数,变得范围差不多,没那么极端了。
- 函数解释:用于计算
- 时间衰减标准化:用
Math.exp(-time * hours)这个函数。- 函数解释:用于计算自然常数 e 为底的指数函数,对应的数学函数为
time_score = e^(x)。 - 应用解释:
hours:图片发布出来到现在过了多少小时,肯定大于等于0。time:衰减速率,控制降得快不快的一个数,得大于0。time越大,降得越快。
- 咋回事:
time * hours这个数,图片放得越久越大。- 前面加个负号
-,就保证e的指数是越来越小的负数。 - 所以,图片刚传出来 (
hours接近0),这个值接近e^0 = 1,最热。 - 放得越久 (
hours增大),这个值按指数规律越来越小。
- 效果:算出个“新鲜度”分,新图高,老图低。
- 函数解释:用于计算自然常数 e 为底的指数函数,对应的数学函数为
- 图片质量:这个我没具体做,但思路跟处理互动数据一样。比如找个评分,然后用
log1p之类的弄一下。
3、分数计算
把标准化后的分,乘以对应的权重,然后全加起来,就是最终的热度分。公式如下:
▼text复制代码score = time_normalized + (view_normalized * views_weight) + (likes_normalized * likes_weight) + (collect_normalized * collect_weight) + (download_normalized * download_weight) + (share_normalized * share_weight)
📑实现细节和优化(咋写到代码里)
上面分析完了,问题1-3(权重、标准化、时间)就算解决了。下面说说问题4-6咋整。
图片咋查?排序咋整?
查询接口谁不会写啊?SELECT * FROM picture LIMIT 10 这不就写好了。
- 查一批图片,拿到 Java 里,用上面公式算分,再按分从高到低排个序,返回。
确实如此,那么你可以不看了哈😀😀😀
问题:如果第11页、第12页有图片比当前第1页的某些图片分还高,那第1页就不“最优”了。用户想看的是当前最热的,不是“第一页”的。
解决思路:
- 在数据库表里加个新字段,叫
recommend_score。 - 把算好的分存到这个字段里。
- 查询的时候,直接让数据库按
recommend_score从高到低排。
▼sql复制代码SELECT * FROM picture ORDER BY recommend_score DESC LIMIT 10;
新的思考🤔:实时算和更新,太慢了!
互动数据都是实时变化的,发布时间也是实时推移的,怎么处理啊?
思路:每次查询前都先计算一遍呗,然后再查出来!其实也没毛病,但是这样性能别提有多“好”了👋
解决办法:给个定时任务,每 5 分钟统计并且计算一遍,查的时候就不用再计算了,不过就是会有 5 分钟的延迟。(你别说,我现在线上版本用的还就是这个)反正我能接受这 5 分钟延迟。
- 任务负责把所有图片的
recommend_score重新算一遍。 - 算完更新到数据库。
- 查询的时候就用数据库里存好的分去排。
其实到这里已经完事了,文章写完了,时期主要的重点就是 分析 里面的内容。。。。。。
但是呢,你要接着往下看的话会有新的发现⁉️
交互数据是事实的,难不成我每一次交互我都去更新一下数据库吗?哎,又来一个重点了!
这就不得不说我是怎么做的了,嘿嘿嘿
进一步优化:别老写数据库了
问题:每次用户点赞、浏览,都去更新数据库里的计数(like_quantity += 1),数据库压力山大。而且这些更新还得被抓住,去重新算分,算分也不能每次更新都算。
咋整?上 Redis!
看过我代码的都知道哈~ 代码里面有一个类 InteractionCacheInitializer.java ,见名知意:互动缓存初始化。仔细一看,实现了 ApplicationRunner 接口,不知道这个接口的去补一下基础知识要挨打的
我这里简单说一下:
- 这个接口会在应用程序启动完成后执行一些自定义的初始化逻辑;
- 其中定义了一个
run方法并且接受一个ApplicationArguments参数; - 其他类似的:
CommandLineRunner接口、@PostConstruct注解、ApplicationListener接口等等;
回归正题,这个方法里面只调用了一个方法 pictureInteractionCache.init(); 初始化,方法如下:
▼java复制代码@Service public class PictureInteractionCache { @Resource private PicturePersistenceService picturePersistenceService; @Resource private RedisCache redisCache; public void init() { log.info(">>> 初始化[图片互动]数据缓存..."); Set<String> keys = redisCache.getKeys(CacheKeyConstant.PICTURE_INTERACTION_KEY_PREFIX); if (!keys.isEmpty()) { log.info("<<< [图片互动]数据缓存已存在!"); return; } int size = 1000; long total = 0; while (true) { // 分批加载所有需要缓存的图片数据 Page<PictureDO> page = picturePersistenceService.page(new Page<>(total, size), new LambdaQueryWrapper<PictureDO>() .eq(PictureDO::getReviewStatus, PictureReviewStatusEnum.PASS.getKey()) .ne(PictureDO::getExpandStatus, PictureExpandStatusEnum.YES.getKey()) ); List<PictureDO> pictures = page.getRecords(); // 批量写入Redis pictures.forEach(pic -> { String key = CacheKeyConstant.PICTURE_INTERACTION_KEY_PREFIX + pic.getId(); Map<String, Object> interactions = Map.of( "0", pic.getLikeQuantity(), "1", pic.getCollectQuantity(), "2", pic.getDownloadQuantity(), "3", pic.getShareQuantity(), "4", pic.getViewQuantity(), "5", pic.getCreateTime().getTime() ); redisCache.hSets(key, interactions); }); total += pictures.size(); log.info("--- 已初始化 {} 条图片互动数据", total); if (pictures.size() < size) { break; } } log.info("<<< [图片互动]数据缓存初始化完成,共加载 {} 条记录", total); } }
有Java基础的写过业务逻辑的都知道,这段代码很简单:应用一启动,把所有图片的初始点赞、收藏、浏览数、创建时间,都存到 Redis 里。用 hash 结构存,键 picture:interaction:图片ID,里面存 like, collect, download, share, view, create_time 这些。
其他的同步修改:
- 互动操作改 Redis:用户点赞、浏览啥的,直接操作 Redis 里的对应值就行(比如让
like这个值加1)。别写数据库了! - 定时同步 + 算分:那个定时任务,现在不光要同步数据到数据库,还得用 Redis 里最新的数据,把
recommend_score算出来,再存回数据库。
好处:
- 数据库不用老写了,压力小了。
- 算分也不用每次互动都算,定时任务批量算就行。
- Redis 快,适合这种高并发的计数。
貌似这一步好像计算分数没有任何关系哎~
那么请继续,看过我代码的都知道哈~ 代码里面有一个类 PictureTask.java ,见名知意:图片任务;
PS:这里的定时任务的实现是动态的增删改的,如需学习请自行查询开源代码。
然后就是一个定时任务呗,就是定时跑这个分数然后计算出来再插入数据库中。代码如下:
▼java复制代码@Component public class PictureTask { @Resource private PicturePersistenceService picturePersistenceService; @Resource private RedisCache redisCache; /** * 批量同步图片互动数据 */ @Bean public Task batchSyncInteractions() { return () -> { log.info("开始同步互动数据到数据库"); Set<String> keys = redisCache.getKeys(CacheKeyConstant.PICTURE_INTERACTION_KEY_PREFIX); if (keys == null || keys.isEmpty()) { log.info("没有需要同步的互动数据"); return; } // 2. 将Set转为List以便分批 List<String> keyList = new ArrayList<>(keys); int totalSize = keyList.size(); int batchSize = 1000; // 每批数量 int totalBatches = (totalSize + batchSize - 1) / batchSize; // 计算总批次数 log.info("开始同步互动数据,共 {} 条,分 {} 批处理", totalSize, totalBatches); // 3. 分批处理 for (int i = 0; i < totalBatches; i++) { int fromIndex = i * batchSize; int toIndex = Math.min((i + 1) * batchSize, totalSize); List<String> batchKeys = keyList.subList(fromIndex, toIndex); processBatch(batchKeys, i + 1, totalBatches); } }; } /** * 处理一批数据 * * @param batchKeys Redis键 * @param batchNumber 当前批号 * @param totalBatches 总批数 */ private void processBatch(List<String> batchKeys, int batchNumber, int totalBatches) { log.info("正在处理第 {}/{} 批数据,数量 {}", batchNumber, totalBatches, batchKeys.size()); try { // 准备批量更新 List<PictureDO> updates = new ArrayList<>(batchKeys.size()); batchKeys.forEach(key -> { try { Long pictureId = extractPictureId(key); Map<String, Object> interactions = redisCache.hGet(key); if (interactions == null || interactions.isEmpty()) { return; } PictureDO pictureDO = buildPictureUpdate(pictureId, interactions); updates.add(pictureDO); } catch (Exception e) { log.error("处理key {} 失败: {}", key, e.getMessage()); } }); if (!updates.isEmpty()) { // 执行更新推荐分数的方法 this.calculateRecommendScore(updates); // 批量更新到数据库 picturePersistenceService.updateBatchById(updates); log.info("第 {}/{} 批同步成功,更新 {} 条", batchNumber, totalBatches, updates.size()); } } catch (Exception e) { log.error("第 {} 批处理失败", batchNumber, e); } } /** * 提取图片ID * * @param key Redis键 * @return 图片ID */ private Long extractPictureId(String key) { String idStr = key.substring(key.lastIndexOf(":") + 1); return Long.parseLong(idStr); } /** * 构建更新对象 * * @param pictureId 图片ID * @param interactions 互动数据 * @return 跟新对象 */ private PictureDO buildPictureUpdate(Long pictureId, Map<String, Object> interactions) { PictureDO update = new PictureDO(); update.setId(pictureId); interactions.forEach((field, value) -> { try { switch (Integer.parseInt(field)) { case 0: update.setLikeQuantity(Integer.parseInt(String.valueOf(ObjectUtil.isEmpty(value) ? '0' : value))); break; case 1: update.setCollectQuantity(Integer.parseInt(String.valueOf(ObjectUtil.isEmpty(value) ? '0' : value))); break; case 2: update.setDownloadQuantity(Integer.parseInt(String.valueOf(ObjectUtil.isEmpty(value) ? '0' : value))); break; case 3: update.setShareQuantity(Integer.parseInt(String.valueOf(ObjectUtil.isEmpty(value) ? '0' : value))); break; case 4: update.setViewQuantity(Integer.parseInt(String.valueOf(ObjectUtil.isEmpty(value) ? '0' : value))); break; case 5: update.setCreateTime(new Date(Long.parseLong(String.valueOf(value)))); break; default: log.warn("未知的互动类型: {}", field); } } catch (NumberFormatException e) { log.error("互动数据格式错误 field={}, value={}", field, value); } }); return update; } // region 下面是计算图片推荐分数 @Value("${recommend.score.view}") private double view; @Value("${recommend.score.like}") private double like; @Value("${recommend.score.collect}") private double collect; @Value("${recommend.score.download}") private double download; @Value("${recommend.score.share}") private double share; @Value("${recommend.score.time:0.1}") private double time; /** * 计算推荐评分 */ public void calculateRecommendScore(List<PictureDO> pictureDOS) { log.info("↓↓↓↓↓↓↓↓↓↓ 开始[计算图片推荐评分] ↓↓↓↓↓↓↓↓↓↓"); if (CollUtil.isEmpty(pictureDOS)) { log.info("↑↑↑↑↑↑↑↑↑↑ 结束[无计算内容] ↑↑↑↑↑↑↑↑↑↑"); return; } pictureDOS.forEach(pic -> { pic.setRecommendScore(BigDecimal.valueOf(calculateScore(pic))); }); log.info("↑↑↑↑↑↑↑↑↑↑ 结束[计算图片推荐评分] ↑↑↑↑↑↑↑↑↑↑"); } /** * 计算推荐评分 * * @param pic 图片对象 * @return 评分 */ private double calculateScore(PictureDO pic) { return calculateTimeScore(pic.getCreateTime()) + Math.log1p(pic.getViewQuantity()) * view + Math.log1p(pic.getLikeQuantity()) * like + Math.log1p(pic.getCollectQuantity()) * collect + Math.log1p(pic.getDownloadQuantity()) * download + Math.log1p(pic.getShareQuantity()) * share; } /** * 计算时间衰减得分(指数衰减) * * @param publishTime 发布时间 * @return 评分 */ private double calculateTimeScore(Date publishTime) { long hours = ChronoUnit.HOURS.between(publishTime.toInstant(), Instant.now()); return Math.exp(-time * hours); } // endregion 下面是计算图片推荐分数 }
到这里就算是正式结束!!!
扩展
还记得 新的思考 章节中的解决办法吗?是的,我还有其他方案:
计算列,减少代码层的计算,充分利用 MySQL 的内置能力;
新增计算列,代码如下:
▼sql复制代码ALTER TABLE picture ADD COLUMN hot_score DECIMAL(10,4) GENERATED ALWAYS AS ( (EXP(-0.3 * EXTRACT(EPOCH FROM (NOW() - create_time)) / 3600) + view_quantity * 0.1 + like_quantity * 0.15 + collect_quantity * 0.5 + download_quantity * 0.15 + share_quantity * 0.15) ) STORED;
很多小伙伴可能不知道这个是什么东西?估计都没有见过,其实就是虚拟列啦~
作用:根据数据库中指定列的变化动态的更新这一列的值。有东西吧 [手动狗头]
这样是不是就不要什么定时任务在代码层面计算热度分数啦~
好处:
- 查询直接
ORDER BY hot_score DESC,数据库自己用存的分排,不用我们算。 - 实时性高,只要底层数据库里的
view_quantity等更新了,hot_score就自动重新算好存着。 - 省了 Redis,没网络开销。
注意:
- 得确保交互数据最终能写入数据库(可能需要另外的异步任务从 Redis 或消息队列同步过去)。
- 数据库得顶得住写入。
- 计算公式别太复杂,不然写入也慢。
结束!
