抖音二面:如何设计一个高并发点赞系统?

前言

前几天有个小伙伴找我诉苦,说他在抖音二面被问到了一个场景题------"如何设计一个高并发点赞系统? "

他说他当时脑子一片空白,支支吾吾说了几句"用Redis"、"用消息队列",然后就没然后了。

这个问题其实是个经典的架构设计题。

面试官真正想考察的,不是你会不会用Redis,而是你面对一个高并发场景时,能不能跳出CRUD的思维定式,从整体架构的角度去思考问题

今天这篇文章专门跟大家一起聊聊这个话题,希望看完后,你也能跟面试官聊上半小时。

更多项目实战在Java突击队网:susan.net.cn/project

一、点赞系统的核心挑战是什么?

有些小伙伴可能会说:"点赞不就是update一下数据库的计数吗?有什么难的?"

如果你这么想,那说明你还没被高并发毒打过。

想象一下这个场景:某个明星官宣恋情,瞬间几百万用户冲进来点赞

单条内容的点赞请求可能在几秒内冲到百万级QPS

后台数据库的连接池瞬间被打满,响应时间从毫秒级飙升到十几秒。

每次点赞,你至少需要做三件事:

  1. 查询当前用户是否已点赞(防止重复)
  2. 更新帖子的点赞计数
  3. 记录点赞关系

在千万级并发下,大量的磁盘写入和行锁竞争会让数据库迅速成为瓶颈。

这就是为什么高并发点赞系统不能用"数据库硬扛"的思维来设计。

核心设计目标有四个

目标 具体要求
高并发 热点内容瞬时QPS可达百万级
低延迟 用户点击后必须500ms内完成反馈
高可用 99.99%可用性,不能因为一个功能挂了整站
数据准确 防止重复点赞、数据不丢失

二、整体架构

先上一张全局架构图,让你对整个系统有个整体认知:

点赞系统的核心架构采用"客户端-网关-服务-缓存-数据库"分层设计,各层职责明确。下面我逐一拆解每一层。

三、数据结构选型

点赞系统的核心数据有两类:

第一类:点赞关系 ------ "谁赞了谁"

第二类:点赞计数 ------ "内容有多少赞"

这两类数据的特点完全不同,存储方案也完全不同。

3.1 点赞关系:用Redis Set还是Hash?

点赞关系需要解决的核心问题是:如何快速判断一个用户是否已经点过赞 ,同时要防止重复点赞

在Redis里,主要有两种方案:

方案一:Set集合

bash 复制代码
# 点赞:把用户ID加入集合
SADD like:post:1001 user:123

# 取消点赞:从集合移除
SREM like:post:1001 user:123

# 检查是否已赞:O(1)时间复杂度
SISMEMBER like:post:1001 user:123

# 获取点赞总数
SCARD like:post:1001

# 获取点赞列表(小心数据量!)
SMEMBERS like:post:1001

代码写起来非常清爽。但是,当某个明星发帖,点赞量直奔百万而去时,问题来了------Set在元素超过512个后会用哈希表存储,一个百万级点赞的Set可能占用几十MB内存

方案二:Hash结构

bash 复制代码
# 用Hash存储,field是用户ID,value是时间戳
HSET like:post:1001 user:123 1620000000

# 检查是否已赞
HEXISTS like:post:1001 user:123

# 获取点赞总数
HLEN like:post:1001

Hash方案的内存效率比Set更好,特别是当用户ID是数字时。

我的建议 :对于大多数场景,Hash是更优的选择,因为它的内存效率更高。

但如果你需要获取完整的点赞用户列表,Set会更方便。

3.2 点赞计数:原子操作是关键

点赞计数的核心要求是:高并发下计数不能出错

Redis的String类型提供了原子性的INCR和DECR操作

bash 复制代码
# 点赞:计数+1
INCR like_count:post:1001

# 取消点赞:计数-1
DECR like_count:post:1001

# 获取点赞数
GET like_count:post:1001

就算同时有1000个用户点赞,Redis的原子操作也能保证计数不会算错。

3.3 用户维度查询:ZSet按时间排序

有时候我们需要查询"某个用户点赞了哪些内容",而且需要按时间排序。这时候可以用ZSet(有序集合)

bash 复制代码
# 用户点赞:score存时间戳
ZADD user_like:user:123 1620000000 post:1001

# 获取用户最近点赞的10条内容(按时间倒序)
ZREVRANGE user_like:user:123 0 9

四、高并发写入

数据存哪里确定了,接下来是核心问题:怎么扛住高并发写入

4.1 第一招:Lua脚本保证原子性

点赞和取消点赞涉及多个Redis操作(检查是否已赞 → 更新Set → 更新计数),如果分开执行,在高并发下会出现并发问题。

解决方案:用Lua脚本把所有操作打包成一个原子操作

lua 复制代码
-- 点赞Lua脚本
-- KEYS[1]: like:post:{postId}
-- KEYS[2]: like_count:post:{postId}
-- ARGV[1]: userId
-- ARGV[2]: timestamp

local isLiked = redis.call('HEXISTS', KEYS[1], ARGV[1])
if isLiked == 1 then
    return 0  -- 已点赞,返回0
end

redis.call('HSET', KEYS[1], ARGV[1], ARGV[2])
redis.call('INCR', KEYS[2])
return 1  -- 点赞成功
java 复制代码
// Java中调用Lua脚本
@Component
public class LikeService {
    
    @Autowired
    private StringRedisTemplate redisTemplate;
    
    private static final String LIKE_SCRIPT = 
        "local isLiked = redis.call('HEXISTS', KEYS[1], ARGV[1]) " +
        "if isLiked == 1 then return 0 end " +
        "redis.call('HSET', KEYS[1], ARGV[1], ARGV[2]) " +
        "redis.call('INCR', KEYS[2]) " +
        "return 1";
    
    public boolean like(Long postId, Long userId) {
        String likeKey = "like:post:" + postId;
        String countKey = "like_count:post:" + postId;
        Long result = redisTemplate.execute(
            LIKE_SCRIPT,
            Arrays.asList(likeKey, countKey),
            userId.toString(), 
            String.valueOf(System.currentTimeMillis())
        );
        return result != null && result == 1L;
    }
}

Lua脚本让检查、写入、计数三步操作在Redis服务端一次性完成,彻底避免了并发问题。

4.2 第二招:消息队列异步解耦

点赞数据最终要落到数据库里做持久化,但如果每次点赞都同步写数据库,数据库根本扛不住。

解决方案:点赞操作只写Redis,然后异步发送消息到MQ,消费者批量写入数据库

这样做的好处:

  • 用户毫秒级收到反馈
  • 数据库写入压力从峰值3000+ QPS 降到平均200 QPS
  • 系统吞吐量大幅提升
java 复制代码
@Component
public class LikeConsumer {
    
    @PulsarListener(topics = "like-topic")
    public void consume(List<LikeMessage> messages) {
        // 批量处理,减少数据库连接开销
        List<LikeRecord> records = messages.stream()
            .map(msg -> new LikeRecord(msg.getUserId(), msg.getPostId(), msg.getTime()))
            .collect(Collectors.toList());
        
        // 批量插入,一次提交多条
        likeRecordService.batchInsert(records);
        
        // 批量更新计数
        Map<Long, Long> countMap = messages.stream()
            .collect(Collectors.groupingBy(LikeMessage::getPostId, Collectors.counting()));
        likeRecordService.batchUpdateCount(countMap);
    }
}

4.3 第三招:多级缓存应对热点Key

热点内容(比如明星的爆款内容)的点赞请求会集中在同一个Redis Key 上,这个Key就成了"热点Key"。

解决方案:在Redis前面再加一层本地缓存(如Caffeine)。

本地缓存相对于分布式缓存,数据不需要跨网络传输,性能更好 。某电商系统引入Caffeine后,QPS从1万提升到5万,Redis负载下降了60%

java 复制代码
@Component
public class LikeCountService {
    
    // 本地缓存:热点数据的点赞数
    private final Cache<String, Long> localCache = Caffeine.newBuilder()
        .maximumSize(10000)
        .expireAfterWrite(Duration.ofSeconds(10))
        .build();
    
    public Long getLikeCount(Long postId) {
        String key = "like_count:post:" + postId;
        // 先查本地缓存
        Long count = localCache.getIfPresent(key);
        if (count != null) {
            return count;
        }
        // 本地缓存没有,查Redis
        count = redisTemplate.opsForValue().get(key);
        if (count != null) {
            localCache.put(key, count);
        }
        return count;
    }
}

4.4 第四招:HeavyKeeper识别真正的热点

热点Key不是一成不变的------今天的热点是周杰伦的新歌,明天可能就是某个突发热点事件。

HeavyKeeper算法 可以高效识别出真正的热点数据,让你动态决定哪些数据需要进本地缓存

配合HeavyKeeper热点检测,系统可以自动识别突发热点,动态调整缓存策略。

五、数据一致性

有些小伙伴可能会问:"Redis写成功了,但MQ消费失败了怎么办?数据不就丢了吗?"

这是个好问题。在高并发场景下,我们追求的是最终一致性,而不是强一致性。

5.1 先写Redis,再发MQ

核心流程:

关键设计

  1. Redis是主数据源,所有读写操作都走Redis
  2. MQ异步刷DB,消费者批量写入
  3. 定时对账任务,定期比对Redis和DB的数据,发现不一致就补偿

5.2 数据库分表设计

当数据量达到千亿级别时,单表肯定扛不住。需要做分库分表

sql 复制代码
-- 点赞记录分表(按用户ID取模)
CREATE TABLE like_record_0000 (
    id BIGINT PRIMARY KEY,
    user_id BIGINT NOT NULL,
    biz_type VARCHAR(32) NOT NULL,
    item_id BIGINT NOT NULL,
    status TINYINT DEFAULT 1,
    create_time TIMESTAMP,
    update_time TIMESTAMP,
    UNIQUE KEY uk_user_item (user_id, biz_type, item_id),
    INDEX idx_item (biz_type, item_id, status)
) ENGINE=InnoDB;

-- 共分64张表,按user_id % 64路由

5.3 冷热数据分离

对于千亿级数据,全部放在MySQL里既不现实也不经济:

数据类型 存储介质 时间范围
热数据 Redis集群 最近7天
温数据 TiDB/MySQL 最近90天
冷数据 HBase/对象存储 90天以上

冷数据可以压缩归档到廉价存储,查询时走专门的离线分析系统。

六、防刷与安全

点赞系统还有一个容易被忽视的问题:刷赞

黑灰产会用自动化脚本疯狂点赞,不仅影响业务公平性,还会拖垮你的系统。

6.1 限流策略

在API网关层做限流:

  • 单用户限流:限制单个用户1分钟内的点赞次数
  • 单IP限流:限制单个IP的请求频率
  • 单内容限流:限制单个内容每秒新增点赞数

6.2 防重复提交

基于AOP + Redis实现接口防重复提交:

java 复制代码
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface NoRepeatSubmit {
    long lockTime() default 3000;  // 锁定时间,默认3秒
}

@Aspect
@Component
public class NoRepeatSubmitAspect {
    
    @Around("@annotation(noRepeatSubmit)")
    public Object around(ProceedingJoinPoint point, NoRepeatSubmit noRepeatSubmit) 
            throws Throwable {
        // 用userId + 请求路径 作为Key
        String key = "repeat:" + userId + ":" + requestPath;
        Boolean success = redisTemplate.opsForValue()
            .setIfAbsent(key, "1", Duration.ofMillis(noRepeatSubmit.lockTime()));
        if (!success) {
            throw new BusinessException("操作过于频繁,请稍后再试");
        }
        return point.proceed();
    }
}

这样用户3秒内重复点击点赞按钮,会被直接拦截。

七、优缺点与适用场景

这套方案的优点

1. 超高吞吐量

Redis单机可达数万到十万级QPS,配合多级缓存和MQ削峰,能轻松扛住百万级QPS。

2. 毫秒级响应

所有读写在内存中完成,用户500ms内收到反馈。

3. 数据最终一致

即使MQ或DB短暂故障,Redis中的数据依然可用,事后通过定时对账修复。

4. 弹性可扩展

每一层都可以水平扩展------服务层加实例、缓存层加分片、存储层加节点。

5. 防刷机制完善

限流 + 防重复提交 + 反作弊,三层防护。

这套方案的缺点

1. 系统复杂度高

涉及Redis、MQ、本地缓存、分库分表等多个组件,运维成本高。

2. 最终一致性带来的问题

Redis和DB之间可能存在短暂的不一致。比如用户点赞后立即查询,Redis里有了,但DB还没同步。

3. Redis内存压力

热数据全部放Redis,内存成本不低。尤其是百万级点赞的Set/Hash,可能占用几十MB。

4. MQ故障风险

如果MQ挂了,DB同步就会中断。需要有降级方案。

适用场景

场景 推荐程度 理由
社交平台点赞 ✅✅✅ 强烈推荐 高并发+实时性要求高
短视频/直播点赞 ✅✅✅ 强烈推荐 瞬时流量巨大
电商商品点赞 ✅✅ 推荐 并发量中等,可简化方案
小型社区 ⚠️ 过度设计 直接用数据库即可

更多项目实战在Java突击队网:susan.net.cn/project

八、写在最后

回到最初的问题:如何设计一个高并发点赞系统?

我把完整的答案浓缩成一张脑图:

面试回答的核心逻辑

  1. 先分析痛点 ------ 高并发、低延迟、数据准确、高可用
  2. 再给出方案 ------ Redis扛读写、MQ异步削峰、多级缓存应对热点
  3. 最后兜底 ------ 最终一致性、定时对账、冷热分离

这个思路不仅适用于点赞系统,也适用于任何高并发读写的场景------购物车、收藏、关注、评论计数......套路都是一样的。

如果觉得有帮助,欢迎点赞、在看、转发三连支持一波~