Skip to content

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() &#123;
    this.cache = Caffeine.newBuilder()
            .maximumSize(100)
            .expireAfterWrite(2, TimeUnit.HOURS)
            .build(key -> null);
&#125;

Q4: updateRedisLikeCount 为什么不用 Lua 脚本?

看代码:updateRedisLikeCount 方法先 GET 再判断再 SET,不是原子操作:

java
String 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 的逻辑:

lua
if 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 裁剪逻辑 BugreverseRangeByScore 用分数范围而非下标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 计数

关联文档