外观
07 - 点赞子系统
知识图谱
POST /like/behavior
│
▼
LikeBehaviorServiceImpl.like()
│
┌────────────────┼────────────────┐
│ type=1 (点赞) │ │ type=0 (取消赞)
▼ │ ▼
isLike()? │ isLike()?
┌───┴───┐ │ ┌───┴───┐
│已赞 │ │ │未赞 │
│→失败 │ │ │→失败 │
└───────┘ │ └───────┘
│
┌──────────┴──────────┐
│ isLike() 三层判重 │
│ │
│ 1. BloomFilter │ → 不存在 → 必定未赞
│ (快速否定) │
│ ↓ 存在 │
│ 2. Redis ZSet │ → 存在 → 已赞
│ (热数据确认) │
│ ↓ 不存在 │
│ 3. MySQL 查询 │ → 最终判定
│ (兜底) │
└──────────┬──────────┘
│
▼
发送 Kafka 消息
TOPIC_LIKE_BEHAVIOR
│
▼
KafkaLikeConsumer
┌──────────────────┐
│ 1. 保存到 DB │
│ 2. 更新 Redis │
│ - ZSet 记录 │
│ - Count 计数 │
│ 3. 更新本地缓存 │
│ 4. 裁剪 ZSet≤200 │
└──────────────────┘三层判重详解
第一层:布隆过滤器
文件: src/main/java/com/like/utils/BloomFilterService.java
BloomFilter 配置:
名称: like-behavior-bloom-filter
预期元素数: 1,000,000
误判率: 1%
特性:
- 说"不存在" → 100% 不存在 (无假阴性)
- 说"存在" → 99% 存在, 1% 误判 (有假阳性)
Key 构成: bloomFilter.add(articleId + ":" + userId)第二层:Redis ZSet
Key: likeZset:userId:{userId}
likeZset:articleId:{articleId}
Type: Sorted Set
Member: 对方ID (articleId 或 userId)
Score: 时间戳 (Instant.now().getEpochSecond())
上限: 200 条 (超出异步裁剪)第三层:MySQL 兜底
java
LikeBehavior latestLikeBehavior = this.getOne(new LambdaQueryWrapper<LikeBehavior>()
.eq(LikeBehavior::getArticleId, articleId)
.eq(LikeBehavior::getUserId, userId)
.orderByDesc(LikeBehavior::getTime)
.last("LIMIT 1"));
// 取最新一条记录, type=1 表示点赞中
return latestLikeBehavior != null && latestLikeBehavior.getType() == 1;isLike 判重流程
文件: src/main/java/com/like/service/impl/LikeBehaviorServiceImpl.java
java
private boolean isLike(Long articleId, Long userId) {
// 1. 布隆过滤器: 快速否定
if (!bloomFilterService.isExist(LIKE_BEHAVIOR_BLOOM_FILTER,
articleId.toString(), userId.toString())) {
return false; // 一定未赞
}
// 2. Redis ZSet: 热数据确认
if (isZsetExist(USER_LIKE_ZSET_KEY + userId, articleId.toString())
|| isZsetExist(USER_LIKE_ZSET_KEY + articleId, userId.toString())) {
return true; // ZSet 中有 → 已赞
}
// 3. MySQL: 最终兜底
LikeBehavior latestLikeBehavior = this.getOne(...);
return latestLikeBehavior != null && latestLikeBehavior.getType() == 1;
}本地缓存(Caffeine L1)
java
@Resource
private LoadingCache<String, String> cache; // Spring Bean 注入
@PostConstruct
public LoadingCache<String, String> init() {
// ⚠️ BUG: 创建了新实例但没有赋值给 cache 字段
// 返回值被丢弃, cache 字段仍然是 Spring 注入的 Bean
return Caffeine.newBuilder()
.maximumSize(100)
.expireAfterWrite(2, TimeUnit.HOURS)
.build(new CacheLoader() { ... });
}
// 每 2 小时预热热点文章的点赞数
@Scheduled(fixedRate = 2 * 60 * 60 * 1000)
public void pullHotArticleList() {
List<Long> hotArticles = articleService.queryHotArticle();
for (Long articleId : hotArticles) {
String count = stringRedisTemplate.opsForValue()
.get(ARTICLE_LIKE_COUNT + articleId);
if (count != null) {
cache.put(ARTICLE_LIKE_COUNT + articleId, count);
}
}
}多级缓存查询路径
查询文章点赞数:
1. Caffeine L1: cache.get(key) → 命中返回
2. Redis L2: stringRedisTemplate.get(key) → 命中返回
3. MySQL L3: SELECT count FROM like_article_count → 返回并回填Kafka 异步持久化
java
// 生产者: 发送点赞事件
private void sendLikeBehaviorMsg(Long behaviorId, Long articleId,
Long userId, Integer type) {
Map<String, Object> data = new HashMap<>();
data.put("behaviorId", behaviorId);
data.put("articleId", articleId);
data.put("type", type);
Event event = new Event()
.setTopic(TOPIC_LIKE_BEHAVIOR)
.setUserId(userId)
.setData(data);
kafkaLikeProducer.publishLikeEvent(event);
}ZSet 裁剪
java
@Async // 异步执行, 不阻塞主流程
public void trimLikeZsetList(String key) {
Long likeCount = redisTemplate.opsForZSet().zCard(key);
if (likeCount > ZSET_LENGTH_LIMIT) { // ZSET_LENGTH_LIMIT = 200
long removeCount = likeCount - ZSET_LENGTH_LIMIT;
Set<TypedTuple<String>> typedTuples = redisTemplate.opsForZSet()
.reverseRangeByScore(key, 0, removeCount - 1);
for (TypedTuple<String> tuple : typedTuples) {
redisTemplate.opsForZSet().remove(key, tuple.getValue());
}
}
}面试 Q&A
Q1: 为什么要三层判重?一层不够吗?
每层解决不同的问题:
层 作用 优势 局限 BloomFilter 快速否定 O(1),极快 有假阳性(1%),不支持删除 Redis ZSet 热数据确认 精确,支持Top-N 只保留最近200条 MySQL 最终兜底 100%准确 慢,有IO开销 单层方案的问题:
- 只用 BloomFilter:1% 误判,用户取消赞后无法从 BloomFilter 删除
- 只用 Redis ZSet:需要存储所有历史记录,内存爆炸
- 只用 MySQL:每次判断都查 DB,QPS 受限
三层组合:BloomFilter 过滤 ~95% 的"未赞"请求,ZSet 命中 ~4% 的"已赞"热数据,只有 ~1% 的请求需要查 DB。
追问:布隆过滤器的假阳性会有什么影响?
假阳性意味着"用户没赞但布隆过滤器说赞了":
- 点赞场景:用户尝试点赞 → BloomFilter 说已赞 → 查 ZSet 未找到 → 查 DB 未找到 → 判定为未赞 → 正常点赞
- 影响:多了一次 ZSet + DB 查询,但不影响正确性
真正有影响的是取消赞场景:BloomFilter 不支持删除,用户取消赞后 BloomFilter 仍然说"存在"。好在后续层会查到最新状态(
orderByDesc(time) LIMIT 1),所以最终结果是正确的。
Q2: ZSet 为什么限制 200 条?
内存控制。假设每个用户点赞 1 万篇文章,100 万用户就是 100 亿条 ZSet 记录。限制 200 条后:
- 每个用户最多 200 条 × (member + score) ≈ 200 × 20 bytes = 4KB
- 100 万用户 ≈ 4GB(可接受)
超过 200 条的历史点赞数据需要查 DB。这是热数据缓存策略——最近的点赞行为查询频率最高。
追问:裁剪逻辑有什么 Bug?
trimLikeZsetList的实现有问题:java// reverseRangeByScore(key, 0, removeCount - 1) // 这不是"按分数范围"而是"按分数区间 [0, removeCount-1]" // 如果 removeCount=5, 那只返回分数在 [0, 4] 范围内的元素 // 而分数实际是时间戳(如 1710000000), 所以这个查询永远返回空!正确的做法应该是用
range(key, 0, removeCount - 1)按下标取最旧的 N 条,然后批量删除。
Q3: @PostConstruct 的 init() 方法有什么问题?
init()方法创建了一个新的LoadingCache实例并返回,但没有赋值给cache字段。@PostConstruct方法的返回值被 Spring 忽略。结果:
cache字段仍然是 Spring 通过@Resource注入的CaffeineCache配置类创建的 Bean。修复方式:
java@PostConstruct public void init() { this.cache = Caffeine.newBuilder() .maximumSize(100) .expireAfterWrite(2, TimeUnit.HOURS) .build(key -> null); }
Q4: updateRedisLikeCount 为什么不用 Lua 脚本?
看代码:
updateRedisLikeCount方法先 GET 再判断再 SET,不是原子操作:javaString count = stringRedisTemplate.opsForValue().get(key); // T1 读到 count=5, T2 也读到 count=5 redisTemplate.opsForValue().set(key, Integer.parseInt(count) + diff); // T1 写入6, T2 也写入6 → 应该是7! 丢失了一次计数项目中有现成的
updateLikeCount.lua做原子计数,但这个方法没有用它。
updateLikeCount.lua的逻辑:luaif redis.call('exists', key) == 1 then newCount = tonumber(redis.call('get', key)) + diff end redis.call('set', key, newCount) return newCount应该用这个 Lua 脚本替换 Java 层的 GET + SET。
踩坑点
| 踩坑点 | 说明 | 面试官考察意图 |
|---|---|---|
| @PostConstruct 返回值被忽略 | init() 创建的 cache 没有赋值 | Spring 生命周期理解 |
| ZSet 裁剪逻辑 Bug | reverseRangeByScore 用分数范围而非下标 | Redis API 精确理解 |
| 计数器非原子 | GET + SET 有并发丢失风险 | 原子性意识 |
| BloomFilter 不支持删除 | 取消赞后 BloomFilter 状态不变 | 数据结构特性 |
| @Async 与事务 | updateRedis 标记 @Async,在新线程执行 | 事务传播与线程模型 |
| articleLikeZsetKey 存错了 | 第 173 行:add(articleLikeZsetKey, articleId, ...) 应该是存 userId | 逻辑错误 |
加分回答
- 分析布隆过滤器的空间效率:1M 元素 × 1% 误判率 ≈ 1.2MB(比 1M 个 Set 成员省千倍)
- 提到 Counting Bloom Filter 支持删除(每个位变成计数器),但空间翻 4-8 倍
- 讨论 Kafka 批量持久化的好处:减少 DB 写入频率,提高吞吐量
- 分析 @Async 的线程池:默认使用
SimpleAsyncTaskExecutor(每次创建新线程),应该配置自定义线程池 - 提到可以用 Redis HyperLogLog 做近似计数(误差 ~0.81%),替代精确的 ZSet 计数
关联文档
- 01-Redis缓存设计 — 多级缓存架构
- 10-Kafka异步架构 — 点赞事件的 Kafka 处理
- 11-已知问题与优化方向 — @PostConstruct Bug、计数器非原子