社区系统点赞模块设计

目录

简介

架构设计

缓存设计

1、redis缓存

2、大key问题

3、热点key问题

4、举例查询视频点赞用户(第二页数据)

5、过期和归档策略(举例:100万点赞,高并发场景)

高并发场景引对策略设计(读300k 写15k)

数据库设计


简介

高并发和大数据量下需要实现一下几个业务场景

1、内容的点赞总数

2、内容是否被当前用户点赞

列表展示:

1、内容的点赞用户列表

2、用户的内容点赞列表

架构设计

1、流量路由层(决定流量应该去往哪个机房)

2、业务网关层(统一鉴权、反黑灰产等统一流量筛选)

3、点赞服务(thumbup-service),提供统一的RPC接口

4、点赞异步任务(thumbup-job):

点赞数据写入、刷新缓存、为下游其他服务发送点赞、点赞数消息等功能

5、数据层(db、kv、redis)

判断是否点赞:

缓存设计

缓存更新:在缓存维护上,每次有新增点赞时,主动向zset集合中添加用户ID,并更新缓存过期时间。每次查询时,也同样会查询缓存的剩余过期时间,如果低于三分之一,就会重新更新过期时间,这样避免了热门动态有大量新增点赞动作时,出现缓存击穿的情况

1、redis缓存

缓存结构设计:

  • 内容key(zset):存储内容的点赞用户,按user_id进行切片处理
  • 用户key(hash):用来判断内容该用户是否点赞。
  • redis缓存key举例说明:
1、点赞关系存储
//记录内容被哪些用户点赞了(内容点赞历史)
String likeKey = "like:content:" + contentId; // ZSet<userId,timestamp>

//用户点赞了哪些内容(用户点赞历史、是否点赞某些内容)
String userLikeKey1 = "user:like:" + userId;   // Zset<contentId, 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个分片,便于架构统计管理。
  • 按照固定长度裁剪用户的点赞记录缓存,超过该长度的数据请求需要回源DB查询。
  • 打散分片再维护到缓存,每次操作缓存时先通过缓存key配置地址拿到分片key,这样每个分片都具有更小的体积和更快的维护和响应速度。

contentid1_slice1 => [uid1,uid11,uid111...]

contentid1_slice2 => [uid2,uid22,uid222...]

contentid1_slice3 => [uid3,uid33,uid333...]

注意:

  • 所有视频,无论冷热,ZSet 全部统一分片,没有例外。分片不是为了解决 “量大”,而是为了 “架构安全 + 冷转热无迁移”
  • 为了避免数据量过大,需要在每一次加入新的点赞记录的时候,按照固定长度裁剪用户的点赞记录缓存 

3、热点key问题

多级缓存进行处理,利用最小堆算法,在可配置的时间窗口范围内,统计出访问最频繁的缓存Key,并将热Key(Value)按照业务可接受的TTL存储在本地内存中。

4、举例查询视频点赞用户(第二页数据)

// 1、批量查询所有分片的候选数据(Pipeline减少网络IO)
List<Set<ZSetOperations.TypedTuple<String>>> shardResults = redisTemplate.executePipelined((RedisCallback<Object>) 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
        // max=lastCursor(上一页最后一条的score),min=0,offset=0,count=20
        connection.zRevRangeByScoreWithScores(
            zsetKey.getBytes(),
            String.valueOf(lastCursor).getBytes(),
            "0".getBytes(),
            0, 20
        );
    }
    return null;
});

//2、多路归并排序(简化版,生产用优先队列PriorityQueue优化)
private List<ZSetOperations.TypedTuple<String>> mergeShardResults(List<Set<ZSetOperations.TypedTuple<String>>> shardResults) {
    List<ZSetOperations.TypedTuple<String>> globalList = new ArrayList<>();
    for (Set<ZSetOperations.TypedTuple<String>> 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;
    List<Long> secondPageUserIds = new ArrayList<>();
    Long nextCursor = null;
    
    for (int i = secondPageStart; i <= secondPageEnd && i < globalSortedList.size(); i++) {
        ZSetOperations.TypedTuple<String> 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条
        List<Long> 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 Key;ARGV[1] = userId;ARGV[2] = 倒序score;ARGV[3] = 单分片上限(1000);ARGV[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 1

2、分层 TTL 过期(自动淘汰冷数据)

给不同热度的数据设置差异化过期时间,让 Redis 自动淘汰低价值数据,无需人工干预

  • 写入时:根据数据的 “创建时间 / 访问频次” 计算 TTL,传入 Lua 脚本;
  • 巡检修正:每小时轻量扫描分片,对 TTL 不合理的(如核心热数据 TTL<7 天)自动修正,避免误淘汰。

3、低峰定时冷数据清理(低峰期兜底)

  • 每天凌晨2点,扫描的热门视频,把7天前的点赞数据删除
  • 前两层可覆盖 99% 的淘汰场景,再通过凌晨低峰期(2:00-4:00)的定时任务做兜底,确保分片绝对合规,且不影响白天高并发。
  • 违代码:
// 1. 筛选需清理的热门视频(100万+点赞)
List<Long> 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((RedisCallback<Long>) 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内存淘汰策略

5、冷数据分层处理

  • 当用户翻到缓存外的分页(如第 50 页后),直接从 ClickHouse查询。
  • 发布超过 30 天的视频,Redis 中仅保留 “点赞数计数器”,点赞列表直接从 ClickHouse 查询。

高并发场景引对策略设计(读300k 写15k)

1、写入【点赞数】数据的时,在内存中做部分聚合写入,比如聚合10s内的点赞数,再一次性写入 

2、点赞操作采用异步处理(写缓存+发消息+异步持久化)

  • 写缓存:先更新Redis中的用户点赞关系(Hash添加元素)、点赞计数(String自增)、用户点赞列表(ZSet添加元素)
  • 发消息:发送点赞事件(包含用户ID、实体类型、实体ID、时间戳),实现业务解耦(点赞服务无需等待数据库持久化完成)
  • 写数据库:消费消息队列中的点赞事件,将数据写入分布式数据库(如TiDB),失败重试

3、对强一致性场景(如电商订单的点赞奖励),采用分布式事务框架(如Seata),实现Redis与数据库的原子操作(如点赞成功后,同时更新Redis计数与数据库奖励积分)。

数据库设计

Redis 作为缓存

数据库层面:Mysql存储近一年数据,clickhouse存储点赞归档数据,TIDB考虑大数据量下可以替代MySQL进行使用。

稳定性设计

  • redis-->KV数据库-->TIDB
  • 服务部署异地多活,数据库分别负责:读写(上海机房)、读流量(杭州机房)。

参考:

点赞设计(得物)

https://juejin.cn/post/7124511400948400142

点赞设计(B站)

https://www.bilibili.com/read/cv21576373/?opus_fallback=1

 https://blog.csdn.net/zhaozhiqiang1981/article/details/141072196b

内容概要:本文围绕可变桨叶四旋翼无人机的规范控制与运动模拟展开,重研究优化推力分配策略在翻转动作中的应用与性能比较。通过Matlab代码实现,构建了四旋翼动力学模型,并设计了多种控制算法以实现精确的姿态调整与轨迹跟踪。研究对比了不同推力分配方案在执行高机动性翻转动作时的稳定性、能耗效率与响应速度,旨在提升无人机在复杂飞行任务中的动态性能与控制精度。该仿真研究为无人机飞控系统设计与优化提供了理论依据和技术支持。; 适合人群:具备一定自动控制理论基础和Matlab编程能力,从事无人机控制、飞行器动力学或机器人系统研究的科研人员及研究生。; 使用场景及目标:① 实现四旋翼无人机在三维空间中的精确运动控制;② 对比分析不同推力分配策略在执行翻转等高难度动作时的控制效果与能耗表现,优化飞行性能;③ 为无人机自主飞行、特技飞行及复杂环境下的机动控制提供算法验证平台。; 阅读建议:此资源以Matlab仿真为核心,建议读者结合相关控制理论知识,深入理解代码实现细节,重关注动力学建模、控制律设计与推力分配模块。在学习过程中,应动手调试参,复现文中翻转动作的仿真结果,并尝试拓展至其他复杂飞行任务,以加深对无人机控制机理的理解。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值