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 核心思路
- 用户点赞请求先写 Redis(毫秒级响应)
- 同时把点赞事件发送到 MQ(如 RocketMQ / Kafka)
- 消费者从 MQ 拉取消息,批量写入 MySQL
- 通过批量插入 + 幂等去重,保证数据最终一致
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_id 或 target_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 如何防止重复点赞?
三层防护:
- 数据库唯一索引 :
(user_id, target_type, target_id)唯一约束,从根源上保证不重复 - Redis Set 原子操作 :
SADD返回 0 表示已存在,直接拒绝 - 业务层幂等:接口设计成幂等,重复请求返回相同结果
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 后端功底。