社区系统点赞模块设计

发布时间:2026/6/25 14:12:04

社区系统点赞模块设计 目录简介架构设计缓存设计1、redis缓存2、大key问题3、热点key问题高并发场景引对策略设计读300k 写15k数据库设计简介高并发和大数据量下需要实现一下几个业务场景1、内容的点赞总数2、内容是否被当前用户点赞列表展示1、内容的点赞用户列表2、用户的内容点赞列表架构设计1、流量路由层决定流量应该去往哪个机房2、业务网关层统一鉴权、反黑灰产等统一流量筛选3、点赞服务thumbup-service,提供统一的RPC接口4、点赞异步任务thumbup-job5、数据层db、kv、redis判断是否点赞缓存设计缓存更新在缓存维护上每次有新增点赞时主动向zset集合中添加用户ID并更新缓存过期时间。每次查询时也同样会查询缓存的剩余过期时间如果低于三分之一就会重新更新过期时间这样避免了热门动态有大量新增点赞动作时出现缓存击穿的情况1、redis缓存缓存结构设计内容keyzset:存储内容的点赞用户按user_id进行切片处理用户keyhash用来判断内容该用户是否点赞。redis缓存key举例说明1、点赞关系存储 //记录内容被哪些用户点赞了内容点赞历史 String likeKey like:content: contentId; // ZSetuserId,timestamp //用户点赞了哪些内容用户点赞历史、是否点赞某些内容 String userLikeKey1 user:like: userId; // ZsetcontentId, timestamp 2、点赞数设计 String countKey count:content: contentId; // String 计数 String hotKey hot:content; // ZSet热门排行 3、考虑到内容点赞记录都要存储到redis zset且feed流批量查询会增加并发量可以考虑使用redis Map结构进行数据存储 判断是否点赞某些内容 { userId:{ ttl:1653532653, //缓存新建或更新时时间戳 cid1:1, //用户近一段时间点赞过的动态id cid2:1, //用户近一段时间点赞过的动态id cidn:1, //用户近一段时间点赞过的动态id minCid:3540575, //缓存中最小的动态id用以区分冷热数据 } } 4、key格式参考 # 基础格式无哈希分片 zset:like:vid:{视频ID}:ts:{时间窗口} # 叠加哈希分片爆火视频 zset:like:vid:{视频ID}:ts:{时间窗口}:shard:{分片ID}2、大key问题解析内容redisKey可能被很多用户点赞存在大key问题解决方案对大Key按user_id进行打散处理所有视频固定100个分片便于架构统计管理。打散分片再维护到缓存每次操作缓存时先通过缓存key配置地址拿到分片key这样每个分片都具有更小的体积和更快的维护和响应速度。contentid1_slice1 [uid1,uid11,uid111...]contentid1_slice2 [uid2,uid22,uid222...]contentid1_slice3 [uid3,uid33,uid333...]注意所有视频无论冷热ZSet 全部统一分片没有例外。分片不是为了解决 “量大”而是为了 “架构安全 冷转热无迁移”为了避免数据量过大需要在每一次加入新的点赞记录的时候按照固定长度裁剪用户的点赞记录缓存3、热点key问题多级缓存进行处理4、举例查询视频点赞用户第二页数据// 1、批量查询所有分片的候选数据Pipeline减少网络IO ListSetZSetOperations.TypedTupleString shardResults redisTemplate.executePipelined((RedisCallbackObject) connection - { for (int shard 0; shard 100; shard) { String zsetKey String.format(video:%s:like:zset:%d, videoId, shard); // ZREVRANGEBYSCORE key max min LIMIT offset count // maxlastCursor上一页最后一条的scoremin0offset0count20 connection.zRevRangeByScoreWithScores( zsetKey.getBytes(), String.valueOf(lastCursor).getBytes(), 0.getBytes(), 0, 20 ); } return null; }); //2、多路归并排序简化版生产用优先队列PriorityQueue优化 private ListZSetOperations.TypedTupleString mergeShardResults(ListSetZSetOperations.TypedTupleString shardResults) { ListZSetOperations.TypedTupleString globalList new ArrayList(); for (SetZSetOperations.TypedTupleString shardData : shardResults) { if (shardData ! null !shardData.isEmpty()) { globalList.addAll(shardData); } } // 按score降序排序即点赞时间从新到旧 globalList.sort((a, b) - Double.compare(b.getScore(), a.getScore())); return globalList; } //3、截取第二页数据第一页0~19第二页20~39 int secondPageStart pageSize; int secondPageEnd secondPageStart pageSize - 1; ListLong secondPageUserIds new ArrayList(); Long nextCursor null; for (int i secondPageStart; i secondPageEnd i globalSortedList.size(); i) { ZSetOperations.TypedTupleString tuple globalSortedList.get(i); secondPageUserIds.add(Long.parseLong(tuple.getValue())); if (i Math.min(secondPageEnd, globalSortedList.size() - 1)) { nextCursor tuple.getScore().longValue(); } } //4、Redis数据不足兜底查MySQL if (secondPageUserIds.size() pageSize) { // 转换cursor为时间戳timestamp Long.MAX_VALUE - lastCursor long maxCreateTime Long.MAX_VALUE - lastCursor; int needCount pageSize - secondPageUserIds.size(); // 从MySQL分页查询按点赞时间倒序取小于maxCreateTime的前needCount条 ListLong dbUserIds likeMapper.selectLikeUsersByVideoIdAndTime( videoId, maxCreateTime, needCount ); secondPageUserIds.addAll(dbUserIds); // 补缓存临时缓存1小时避免重复查库 cacheUserIdsToRedis(videoId, dbUserIds); // 更新下一页cursor若有数据库数据 if (!dbUserIds.isEmpty()) { // 取数据库最后一条的时间戳转换为score long lastDbTimestamp likeMapper.getLastCreateTimeByUserIds(videoId, dbUserIds); nextCursor Long.MAX_VALUE - lastDbTimestamp; } } return new PageResult(secondPageUserIds, nextCursor);5、过期和归档策略举例100万点赞高并发场景1、写入原子截断原子化控制单分片≤1000 条点赞单分片上限 1000 条用户翻页需求少核心看 “是否点赞”评论单分片上限 2000 条用户翻评论列表更深留存略多。-- KEYS[1] 分片ZSet KeyARGV[1] userIdARGV[2] 倒序scoreARGV[3] 单分片上限1000ARGV[4] TTL按热度定 -- 1. 原子写入点赞数据 redis.call(ZADD, KEYS[1], ARGV[2], ARGV[1]) -- 2. 原子检查并截断超过1000条则删除旧数据只留最新1000条 local card redis.call(ZCARD, KEYS[1]) if card tonumber(ARGV[3]) then redis.call(ZREMRANGEBYRANK, KEYS[1], tonumber(ARGV[3]), -1) -- 删除rank≥1000的元素旧数据 end -- 3. 原子设置TTL分层过期 redis.call(EXPIRE, KEYS[1], ARGV[4]) return 12、分层 TTL 过期自动淘汰冷数据给不同热度的数据设置差异化过期时间让 Redis 自动淘汰低价值数据无需人工干预3、低峰定时冷数据清理低峰期兜底每天凌晨2点扫描的热门视频把7天前的点赞数据删除把长时间没访问的分片key部分数据删除。// 1. 筛选需清理的热门视频100万点赞 ListLong hotVideoIds videoService.listHotVideo(100_0000); for (Long videoId : hotVideoIds) { for (int shard 0; shard 100; shard) { // 遍历100个分片 String zsetKey String.format(video:%s:like:zset:%d, videoId, shard); // 清理逻辑1删除7天前的冷数据score阈值 Long.MAX_VALUE - 7天前时间戳 long sevenDaysAgo System.currentTimeMillis() - 7L*24*3600*1000; double scoreThreshold Long.MAX_VALUE - sevenDaysAgo; redisTemplate.opsForZSet().removeRangeByScore(zsetKey, 0, scoreThreshold); // 清理逻辑2冷分片收缩7天无访问的分片 Long idleTime (Long) redisTemplate.execute((RedisCallbackLong) conn - conn.objectIdleTime(zsetKey.getBytes()) // 获取分片空闲时间秒 ); if (idleTime ! null idleTime 604800) { // 7天无访问 redisTemplate.opsForZSet().removeRange(zsetKey, 500, -1); // 收缩到500条/分片 redisTemplate.expire(zsetKey, 12, TimeUnit.HOURS); // 缩短TTL加速淘汰 } } }4、redis统一进行lru内存淘汰策略未命中缓存时4、当用户翻到缓存外的分页如第 50 页后直接从 ClickHouse抖音存储全量点赞数据的 OLAP 库查询且查询结果临时缓存 1 小时避免重复查库缓存满后自动触发淘汰策略。“冷热数据” 分层处理冷数据超过 2 小时的切片 ZSet异步归档到 ClickHouse归档后删除 Redis 中的对应 Key极冷数据发布超过 30 天的视频Redis 中仅保留 “点赞数计数器”点赞列表直接从 ClickHouse 查询。高并发场景引对策略设计读300k 写15k1、写入【点赞数】数据的时在内存中做部分聚合写入比如聚合10s内的点赞数一次性写入2、点赞操作采用异步处理写缓存发消息异步持久化写缓存先更新Redis中的用户点赞关系Hash添加元素、点赞计数String自增、用户点赞列表ZSet添加元素发消息发送点赞事件包含用户ID、实体类型、实体ID、时间戳实现业务解耦点赞服务无需等待数据库持久化完成写数据库消费消息队列中的点赞事件将数据写入分布式数据库如TiDB失败重试3、对强一致性场景如电商订单的点赞奖励采用分布式事务框架如Seata实现Redis与数据库的原子操作如点赞成功后同时更新Redis计数与数据库奖励积分。数据库设计Redis 作为缓存数据库层面Mysql存储数据、TIDB考虑大数据量下可以替代MySQL进行使用参考点赞设计得物https://juejin.cn/post/7124511400948400142点赞设计B站https://www.bilibili.com/read/cv21576373/?opus_fallback1https://blog.csdn.net/zhaozhiqiang1981/article/details/141072196b

相关新闻