Java 后端面试题总结:点赞功能怎么实现?

1. 引言

点赞功能是互联网产品中最常见、也最基础的功能之一。无论是微博、抖音、知乎,还是电商平台的商品评价,都离不开点赞。正因为它足够常见,所以也成了 Java 后端面试中高频出现的考题。

面试官问「点赞功能怎么实现」时,往往不是想听你背一个固定的答案,而是想考察你对以下问题的理解深度:

  • 点赞数据如何存储?用 MySQL 还是 Redis?
  • 如何防止重复点赞?如何保证并发下的数据一致性?
  • 点赞数如何统计?如何避免频繁更新数据库?
  • 如何设计点赞列表的查询?
  • 如何做性能优化?

本文从「基础实现 → 高并发优化 → 面试追问」三个层次,系统梳理点赞功能的核心知识点,帮助你从容应对面试。

2. 点赞功能的需求分析

在动手设计之前,先明确点赞功能的核心需求。面试时先讲清楚需求,再讲方案,会显得思路清晰。

2.1 核心功能点

  • 用户对某条内容(文章、评论、视频等)点赞 / 取消点赞
  • 查询某条内容的点赞总数
  • 查询当前用户是否已点赞某条内容
  • 查询某条内容的点赞用户列表(可选)
  • 查询当前用户点赞过的内容列表(可选)

2.2 核心难点

  • 幂等性:同一用户对同一内容重复点赞,只能生效一次
  • 并发安全:多个用户同时点赞,点赞数不能出错
  • 性能:高并发场景下,不能每次都直接操作数据库
  • 一致性:缓存与数据库的数据最终要一致

3. 方案一:基于 MySQL 的基础实现

这是最直观、也最容易被面试者首先想到的方案,适合中小规模业务或作为设计的起点。

3.1 表结构设计

点赞关系表(核心表):

sql 复制代码
CREATE TABLE `like_record` (
  `id` BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键',
  `user_id` BIGINT NOT NULL COMMENT '点赞用户ID',
  `target_type` TINYINT NOT NULL COMMENT '点赞对象类型:1-文章 2-评论 3-视频',
  `target_id` BIGINT NOT NULL COMMENT '点赞对象ID',
  `status` TINYINT NOT NULL DEFAULT 1 COMMENT '状态:1-已点赞 0-已取消',
  `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
  `update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
  UNIQUE KEY `uk_user_target` (`user_id`, `target_type`, `target_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='点赞记录表';

内容点赞数表(冗余计数表):

sql 复制代码
CREATE TABLE `like_count` (
  `id` BIGINT PRIMARY KEY AUTO_INCREMENT,
  `target_type` TINYINT NOT NULL COMMENT '对象类型',
  `target_id` BIGINT NOT NULL COMMENT '对象ID',
  `like_num` INT NOT NULL DEFAULT 0 COMMENT '点赞总数',
  UNIQUE KEY `uk_target` (`target_type`, `target_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='点赞计数表';

面试要点:like_record 表用 (user_id, target_type, target_id) 建唯一索引,从数据库层面保证「同一用户对同一内容只能有一条点赞记录」,这是防重复点赞的第一道保障。

3.2 点赞 / 取消点赞

java 复制代码
@Service
public class LikeService {

    @Transactional
    public void like(Long userId, Integer targetType, Long targetId) {
        // 1. 尝试插入点赞记录,利用唯一索引防重
        int inserted = likeRecordMapper.insertIgnore(userId, targetType, targetId);
        if (inserted > 0) {
            // 2. 插入成功说明是首次点赞,计数 +1
            likeCountMapper.incr(targetType, targetId, 1);
        }
    }

    @Transactional
    public void unlike(Long userId, Integer targetType, Long targetId) {
        // 1. 更新状态为已取消
        int updated = likeRecordMapper.cancel(userId, targetType, targetId);
        if (updated > 0) {
            // 2. 更新成功说明之前是点赞状态,计数 -1
            likeCountMapper.incr(targetType, targetId, -1);
        }
    }
}

3.3 查询点赞数

java 复制代码
public Integer getLikeCount(Integer targetType, Long targetId) {
    return likeCountMapper.selectCount(targetType, targetId);
}

3.4 该方案的优缺点

优点:

  • 实现简单,逻辑清晰,事务保证强一致性
  • 数据可靠,不会丢失

缺点:

  • 每次点赞都写数据库,高并发下数据库压力大
  • 点赞数查询频繁,每次都查库性能差
  • 计数表与记录表需要事务保证一致性,锁竞争明显

面试话术:这个方案适合业务初期或并发量不高的场景。如果面试官追问「高并发怎么办」,就可以引出 Redis 方案。

4. 方案二:基于 Redis 的高并发优化方案

当点赞量达到一定规模(比如秒杀、热点内容),数据库方案扛不住,就需要引入 Redis。这是面试中最重要的方案,务必熟练掌握。

4.1 整体架构

#mermaid-svg-hR2iN4XTCAl8KtzH{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-hR2iN4XTCAl8KtzH .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-hR2iN4XTCAl8KtzH .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-hR2iN4XTCAl8KtzH .error-icon{fill:#552222;}#mermaid-svg-hR2iN4XTCAl8KtzH .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-hR2iN4XTCAl8KtzH .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-hR2iN4XTCAl8KtzH .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-hR2iN4XTCAl8KtzH .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-hR2iN4XTCAl8KtzH .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-hR2iN4XTCAl8KtzH .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-hR2iN4XTCAl8KtzH .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-hR2iN4XTCAl8KtzH .marker{fill:#333333;stroke:#333333;}#mermaid-svg-hR2iN4XTCAl8KtzH .marker.cross{stroke:#333333;}#mermaid-svg-hR2iN4XTCAl8KtzH svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-hR2iN4XTCAl8KtzH p{margin:0;}#mermaid-svg-hR2iN4XTCAl8KtzH .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-hR2iN4XTCAl8KtzH .cluster-label text{fill:#333;}#mermaid-svg-hR2iN4XTCAl8KtzH .cluster-label span{color:#333;}#mermaid-svg-hR2iN4XTCAl8KtzH .cluster-label span p{background-color:transparent;}#mermaid-svg-hR2iN4XTCAl8KtzH .label text,#mermaid-svg-hR2iN4XTCAl8KtzH span{fill:#333;color:#333;}#mermaid-svg-hR2iN4XTCAl8KtzH .node rect,#mermaid-svg-hR2iN4XTCAl8KtzH .node circle,#mermaid-svg-hR2iN4XTCAl8KtzH .node ellipse,#mermaid-svg-hR2iN4XTCAl8KtzH .node polygon,#mermaid-svg-hR2iN4XTCAl8KtzH .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-hR2iN4XTCAl8KtzH .rough-node .label text,#mermaid-svg-hR2iN4XTCAl8KtzH .node .label text,#mermaid-svg-hR2iN4XTCAl8KtzH .image-shape .label,#mermaid-svg-hR2iN4XTCAl8KtzH .icon-shape .label{text-anchor:middle;}#mermaid-svg-hR2iN4XTCAl8KtzH .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-hR2iN4XTCAl8KtzH .rough-node .label,#mermaid-svg-hR2iN4XTCAl8KtzH .node .label,#mermaid-svg-hR2iN4XTCAl8KtzH .image-shape .label,#mermaid-svg-hR2iN4XTCAl8KtzH .icon-shape .label{text-align:center;}#mermaid-svg-hR2iN4XTCAl8KtzH .node.clickable{cursor:pointer;}#mermaid-svg-hR2iN4XTCAl8KtzH .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-hR2iN4XTCAl8KtzH .arrowheadPath{fill:#333333;}#mermaid-svg-hR2iN4XTCAl8KtzH .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-hR2iN4XTCAl8KtzH .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-hR2iN4XTCAl8KtzH .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-hR2iN4XTCAl8KtzH .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-hR2iN4XTCAl8KtzH .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-hR2iN4XTCAl8KtzH .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-hR2iN4XTCAl8KtzH .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-hR2iN4XTCAl8KtzH .cluster text{fill:#333;}#mermaid-svg-hR2iN4XTCAl8KtzH .cluster span{color:#333;}#mermaid-svg-hR2iN4XTCAl8KtzH div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-hR2iN4XTCAl8KtzH .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-hR2iN4XTCAl8KtzH rect.text{fill:none;stroke-width:0;}#mermaid-svg-hR2iN4XTCAl8KtzH .icon-shape,#mermaid-svg-hR2iN4XTCAl8KtzH .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-hR2iN4XTCAl8KtzH .icon-shape p,#mermaid-svg-hR2iN4XTCAl8KtzH .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-hR2iN4XTCAl8KtzH .icon-shape .label rect,#mermaid-svg-hR2iN4XTCAl8KtzH .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-hR2iN4XTCAl8KtzH .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-hR2iN4XTCAl8KtzH .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-hR2iN4XTCAl8KtzH :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 未点赞
已点赞
用户发起点赞请求
Redis 判断是否已点赞
写入 Redis Set 记录
Redis 计数 +1
异步落库 MySQL
取消点赞:Set 移除
Redis 计数 -1
最终一致性

4.2 数据结构设计

用 Redis 的 Set 存储「谁点赞了某条内容」,用 String 或 Hash 存储点赞数。

java 复制代码
// 点赞用户集合:like:set:{targetType}:{targetId}
// 例如 like:set:1:10086  ->  存储点赞了文章 10086 的所有用户ID
String likeSetKey = "like:set:" + targetType + ":" + targetId;

// 点赞计数字符串:like:count:{targetType}:{targetId}
String likeCountKey = "like:count:" + targetType + ":" + targetId;

4.3 点赞 / 取消点赞

java 复制代码
@Service
public class LikeService {

    @Autowired
    private StringRedisTemplate redisTemplate;

    public boolean like(Long userId, Integer targetType, Long targetId) {
        String setKey = "like:set:" + targetType + ":" + targetId;
        String countKey = "like:count:" + targetType + ":" + targetId;

        // SADD 返回 1 表示添加成功(之前未点赞),返回 0 表示已存在(重复点赞)
        Long added = redisTemplate.opsForSet().add(setKey, userId.toString());
        if (added != null && added > 0) {
            // 首次点赞,计数 +1
            redisTemplate.opsForValue().increment(countKey);
            // 异步落库
            asyncSaveLikeRecord(userId, targetType, targetId, true);
            return true;
        }
        return false; // 重复点赞
    }

    public boolean unlike(Long userId, Integer targetType, Long targetId) {
        String setKey = "like:set:" + targetType + ":" + targetId;
        String countKey = "like:count:" + targetType + ":" + targetId;

        // SREM 返回 1 表示移除成功(之前已点赞),返回 0 表示不存在
        Long removed = redisTemplate.opsForSet().remove(setKey, userId.toString());
        if (removed != null && removed > 0) {
            redisTemplate.opsForValue().decrement(countKey);
            asyncSaveLikeRecord(userId, targetType, targetId, false);
            return true;
        }
        return false; // 未点赞却取消
    }
}

4.4 查询点赞数与是否已点赞

java 复制代码
public Long getLikeCount(Integer targetType, Long targetId) {
    String countKey = "like:count:" + targetType + ":" + targetId;
    String count = redisTemplate.opsForValue().get(countKey);
    return count == null ? 0L : Long.parseLong(count);
}

public boolean isLiked(Long userId, Integer targetType, Long targetId) {
    String setKey = "like:set:" + targetType + ":" + targetId;
    return Boolean.TRUE.equals(redisTemplate.opsForSet().isMember(setKey, userId.toString()));
}

4.5 缓存与数据库的一致性

Redis 是缓存,MySQL 是最终数据源。两者之间需要保证最终一致性,常见做法是异步落库

java 复制代码
@Async
public void asyncSaveLikeRecord(Long userId, Integer targetType, Long targetId, boolean liked) {
    // 1. 写 like_record 表(幂等:存在则更新 status,不存在则插入)
    // 2. 更新 like_count 表(用乐观锁或原子更新)
}

面试要点:这里要主动说出「最终一致性」的概念------Redis 保证读性能,MySQL 保证数据可靠,通过异步任务把 Redis 的变更同步到 MySQL,允许短暂不一致,但最终一致。

4.6 缓存穿透与击穿

  • 缓存穿透:查询一个不存在的 targetId,Redis 和 MySQL 都没有。解决:布隆过滤器或缓存空值。
  • 缓存击穿:某个热点内容缓存失效,大量请求同时打到 MySQL。解决:互斥锁(Redis SETNX)或逻辑过期。
java 复制代码
public Long getLikeCountSafe(Integer targetType, Long targetId) {
    String countKey = "like:count:" + targetType + ":" + targetId;
    String count = redisTemplate.opsForValue().get(countKey);
    if (count != null) {
        return Long.parseLong(count);
    }
    // 缓存未命中,加锁查库回填,防止击穿
    String lockKey = "like:lock:" + targetType + ":" + targetId;
    Boolean locked = redisTemplate.opsForValue().setIfAbsent(lockKey, "1", Duration.ofSeconds(5));
    try {
        if (Boolean.TRUE.equals(locked)) {
            // 查 MySQL 回填 Redis
            Integer dbCount = likeCountMapper.selectCount(targetType, targetId);
            redisTemplate.opsForValue().set(countKey, String.valueOf(dbCount), Duration.ofHours(24));
            return dbCount.longValue();
        } else {
            // 拿不到锁,短暂休眠后重试
            Thread.sleep(50);
            return getLikeCountSafe(targetType, targetId);
        }
    } finally {
        redisTemplate.delete(lockKey);
    }
}

5. 方案三:引入消息队列削峰

如果点赞请求量极大(比如热点事件导致瞬间百万级点赞),即使 Redis 也扛不住写压力,可以引入 MQ 做削峰填谷。

5.1 架构演进

#mermaid-svg-ShuG9wIRcfArxQQB{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-ShuG9wIRcfArxQQB .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-ShuG9wIRcfArxQQB .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-ShuG9wIRcfArxQQB .error-icon{fill:#552222;}#mermaid-svg-ShuG9wIRcfArxQQB .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-ShuG9wIRcfArxQQB .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-ShuG9wIRcfArxQQB .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-ShuG9wIRcfArxQQB .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-ShuG9wIRcfArxQQB .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-ShuG9wIRcfArxQQB .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-ShuG9wIRcfArxQQB .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-ShuG9wIRcfArxQQB .marker{fill:#333333;stroke:#333333;}#mermaid-svg-ShuG9wIRcfArxQQB .marker.cross{stroke:#333333;}#mermaid-svg-ShuG9wIRcfArxQQB svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-ShuG9wIRcfArxQQB p{margin:0;}#mermaid-svg-ShuG9wIRcfArxQQB .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-ShuG9wIRcfArxQQB .cluster-label text{fill:#333;}#mermaid-svg-ShuG9wIRcfArxQQB .cluster-label span{color:#333;}#mermaid-svg-ShuG9wIRcfArxQQB .cluster-label span p{background-color:transparent;}#mermaid-svg-ShuG9wIRcfArxQQB .label text,#mermaid-svg-ShuG9wIRcfArxQQB span{fill:#333;color:#333;}#mermaid-svg-ShuG9wIRcfArxQQB .node rect,#mermaid-svg-ShuG9wIRcfArxQQB .node circle,#mermaid-svg-ShuG9wIRcfArxQQB .node ellipse,#mermaid-svg-ShuG9wIRcfArxQQB .node polygon,#mermaid-svg-ShuG9wIRcfArxQQB .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-ShuG9wIRcfArxQQB .rough-node .label text,#mermaid-svg-ShuG9wIRcfArxQQB .node .label text,#mermaid-svg-ShuG9wIRcfArxQQB .image-shape .label,#mermaid-svg-ShuG9wIRcfArxQQB .icon-shape .label{text-anchor:middle;}#mermaid-svg-ShuG9wIRcfArxQQB .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-ShuG9wIRcfArxQQB .rough-node .label,#mermaid-svg-ShuG9wIRcfArxQQB .node .label,#mermaid-svg-ShuG9wIRcfArxQQB .image-shape .label,#mermaid-svg-ShuG9wIRcfArxQQB .icon-shape .label{text-align:center;}#mermaid-svg-ShuG9wIRcfArxQQB .node.clickable{cursor:pointer;}#mermaid-svg-ShuG9wIRcfArxQQB .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-ShuG9wIRcfArxQQB .arrowheadPath{fill:#333333;}#mermaid-svg-ShuG9wIRcfArxQQB .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-ShuG9wIRcfArxQQB .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-ShuG9wIRcfArxQQB .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-ShuG9wIRcfArxQQB .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-ShuG9wIRcfArxQQB .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-ShuG9wIRcfArxQQB .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-ShuG9wIRcfArxQQB .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-ShuG9wIRcfArxQQB .cluster text{fill:#333;}#mermaid-svg-ShuG9wIRcfArxQQB .cluster span{color:#333;}#mermaid-svg-ShuG9wIRcfArxQQB div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-ShuG9wIRcfArxQQB .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-ShuG9wIRcfArxQQB rect.text{fill:none;stroke-width:0;}#mermaid-svg-ShuG9wIRcfArxQQB .icon-shape,#mermaid-svg-ShuG9wIRcfArxQQB .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-ShuG9wIRcfArxQQB .icon-shape p,#mermaid-svg-ShuG9wIRcfArxQQB .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-ShuG9wIRcfArxQQB .icon-shape .label rect,#mermaid-svg-ShuG9wIRcfArxQQB .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-ShuG9wIRcfArxQQB .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-ShuG9wIRcfArxQQB .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-ShuG9wIRcfArxQQB :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 用户请求
Redis 快速写入
MQ 异步削峰
消费者批量落库
MySQL

5.2 核心思路

  1. 用户点赞请求先写 Redis(毫秒级响应)
  2. 同时把点赞事件发送到 MQ(如 RocketMQ / Kafka)
  3. 消费者从 MQ 拉取消息,批量写入 MySQL
  4. 通过批量插入 + 幂等去重,保证数据最终一致
java 复制代码
// 生产者:点赞后发送 MQ 消息
public void likeWithMq(Long userId, Integer targetType, Long targetId) {
    // 1. 写 Redis
    boolean liked = doLikeInRedis(userId, targetType, targetId);
    if (liked) {
        // 2. 发送 MQ 消息
        LikeMessage msg = new LikeMessage(userId, targetType, targetId, true);
        mqTemplate.send("like-topic", msg);
    }
}

// 消费者:批量落库
@RocketMQMessageListener(topic = "like-topic", consumerGroup = "like-consumer")
public class LikeConsumer implements RocketMQListener<LikeMessage> {

    @Override
    public void onMessage(LikeMessage msg) {
        // 幂等写入 like_record,更新 like_count
        likeRecordMapper.insertOrUpdate(msg);
        likeCountMapper.incr(msg.getTargetType(), msg.getTargetId(), msg.isLiked() ? 1 : -1);
    }
}

面试要点:引入 MQ 后要主动说明「削峰填谷」和「异步解耦」两个价值,同时提到「消息幂等消费」------消费者要支持重复消息,不能因为 MQ 重试导致计数错误。

6. 数据库层面的优化

即使有 Redis 和 MQ,MySQL 侧的读写优化依然重要,面试中常被追问。

6.1 读写分离

  • 点赞写操作走主库
  • 点赞数查询走从库
  • 通过主从复制保证数据同步

6.2 分库分表

当点赞记录量达到亿级时,按 user_idtarget_id 进行分表:

sql 复制代码
-- 按 user_id 取模分表:like_record_0 ~ like_record_15
-- 查询「我赞过的内容」时,按 user_id 路由到对应分表

6.3 计数表用原子更新

sql 复制代码
UPDATE like_count SET like_num = like_num + 1 WHERE target_type = ? AND target_id = ?;

用数据库自身的原子操作避免「读-改-写」带来的并发问题,比先查后改更安全。

7. 面试高频追问与回答

这一节整理面试官最常追问的问题,建议重点准备。

7.1 如何防止重复点赞?

三层防护:

  1. 数据库唯一索引(user_id, target_type, target_id) 唯一约束,从根源上保证不重复
  2. Redis Set 原子操作SADD 返回 0 表示已存在,直接拒绝
  3. 业务层幂等:接口设计成幂等,重复请求返回相同结果

7.2 点赞数不一致怎么办?

  • 以 MySQL 为准,Redis 只是缓存
  • 定期对账:定时任务扫描 Redis 与 MySQL 的计数差异并修正
  • 兜底方案:缓存失效后从 MySQL 重新加载

7.3 如何查看某条内容的点赞用户列表?

java 复制代码
// 分页查询 Redis Set 中的用户ID
public List<Long> getLikedUserIds(Integer targetType, Long targetId, int page, int size) {
    String setKey = "like:set:" + targetType + ":" + targetId;
    long start = (long) (page - 1) * size;
    long end = start + size - 1;
    Set<String> members = redisTemplate.opsForSet().range(setKey, start, end);
    return members.stream().map(Long::valueOf).collect(Collectors.toList());
}

7.4 点赞功能如何做压测与性能评估?

  • 用 JMeter 模拟高并发点赞请求
  • 关注指标:QPS、响应时间、Redis 内存占用、MySQL 主从延迟
  • 压测发现瓶颈后针对性优化(加缓存、加 MQ、分库分表)

7.5 如果 Redis 宕机了怎么办?

  • Redis 持久化(RDB + AOF)保证重启后数据可恢复
  • 主从 + 哨兵 / Redis Cluster 保证高可用
  • 降级方案:Redis 不可用时直接读写 MySQL,保证功能可用

8. 总结

点赞功能看似简单,实则涵盖了后端开发的多个核心知识点。面试时建议按照「需求分析 → 基础方案 → 高并发优化 → 一致性保障 → 扩展演进」的脉络来回答,体现系统化思维。

核心要点回顾:

  • 表设计:点赞记录表 + 计数表,唯一索引防重
  • Redis 方案:Set 存关系,String 存计数,异步落库
  • MQ 削峰:高并发下用消息队列异步批量落库
  • 一致性:最终一致性 + 定期对账
  • 扩展性:读写分离、分库分表、缓存穿透/击穿防护

掌握以上内容,你不仅能回答「点赞功能怎么实现」,还能在面试官层层追问时从容应对,展现出扎实的 Java 后端功底。

相关推荐
__zRainy__1 小时前
Node系列 · 数据库:MySQL 安装
数据库·后端·mysql·node.js
Zane19941 小时前
threading、multiprocessing、asyncio 到底怎么选?一张图 + 三组实测数据说清楚
后端·python
CodeStats1 小时前
【Java进程通信】Java进程通信系统完全指南:从ProcessBuilder底层原理到多语言实战
java·前端·python·进程·ai编程·processbuilder
绿智校园1 小时前
物联网平台选型指南:为什么底层协议自研比云端SDK接入更可靠?
java·物联网·struts
T01156181 小时前
艺培场馆课时预约小程序用户端 & 后台联动踩坑上篇(预约、课时、缓存、消息、学员端自身问题)
java·缓存·小程序·bug·全栈项目实战手记·艺培课时系统‘’
wenruozhu1 小时前
【面试题解】 Vue 响应式原理
前端·面试
长谷深风1111 小时前
AI Agent 踩坑:盲目重试会把小故障炸成系统灾难
java·大数据·ai·agent·ai agent·工具调用·agent设计
程序员cxuan1 小时前
DeepSeek 多模态深度实测,确实有东西的
人工智能·后端·程序员
evans在进步2 小时前
Spring Boot 核心机制详解:自动装配、事件监听、异步任务与请求映射
java·spring boot·后端