从王鹤棣直播间 500 万点赞说起:Java 后端如何扛住热点互动洪峰?

摘要: 2026 年 5 月 24 日,王鹤棣雅迪品牌直播间公开报道中出现 10 万+ 在线、500 万+ 点赞的互动数据。本文不讨论娱乐事件本身,而是借这个真实高并发场景,拆解 Java 后端如何设计点赞幂等、Kafka 异步计数、Caffeine + Redis + MySQL 三级缓存、单飞锁和计数重建自愈。

在这里插入图片描述

1. 真实场景:直播间突然从 2 万在线冲到 10 万+

2026 年 5 月 24 日,王鹤棣亮相雅迪品牌直播活动。公开报道里提到,直播间在线人数从约 2 万快速冲到 10 万+,整场点赞量突破 500 万。

这类新闻从业务侧看,是明星热度。

从 Java 后端视角看,就是典型的热点互动洪峰:

同一个直播间
同一段时间
同一个热点对象
大量用户同时点赞、关注、评论、刷新信息流

如果 500 万次点赞分布在 1 小时内,平均每秒就是 1300+ 次互动;如果集中在 10 分钟内,平均每秒会到 8000+ 次。真实平台还会叠加弹幕、关注、榜单、推荐流刷新,压力不是只打一个接口,而是同时打计数、缓存、消息队列和数据库。

所以本文要讲的不是“Redis 很快”,而是:

Java 后端扛高并发互动,要把状态、计数、缓存、重建拆开设计。

2. 先分清:直播点赞和帖子点赞不是一回事

直播间点赞通常是互动事件,用户可以连续点击,用来表达热度。

帖子点赞、收藏、关注则更像状态变化:

场景 本质 是否幂等 后端重点
直播间点赞 互动事件 不一定 高吞吐、削峰、近实时聚合
帖子点赞 用户状态 幂等、计数一致性
收藏 用户状态 状态切换、重复请求兜底
关注主播 用户关系 关系变更、粉丝计数一致

所以,王鹤棣直播间 500 万点赞适合作为热点互动洪峰的真实入口;落到 Java 面试和项目亮点时,要讲清楚状态型点赞怎么做。

在这里插入图片描述

3. 为什么 MySQL 直接 UPDATE 扛不住?

最简单的点赞计数:

UPDATE post
SET like_count = like_count + 1
WHERE id = ?

热点来了会有两个问题。

第一,热点行锁竞争。

同一条帖子被大量点赞,所有事务都在写同一行。InnoDB 更新时要加排他锁,其他事务只能等。最后系统慢,不是因为计算复杂,而是因为大家都卡在同一行上。

第二,幂等没解决。

用户点一次,网络超时重试一次,后端收到两次请求,计数就加两次。前端防抖管不了重试、多端登录、脚本请求和网关重放。

所以给字段加索引也没用。索引只能帮你找到那一行,不能解决并发写同一行。

4. 为什么 Redis INCR 也不够?

很多人会想到:

INCR like_count:post_123

Redis 单条命令原子,性能确实好。

INCR 不知道是谁点的。重复点赞、网络重试、并发请求都会继续 +1。

于是再加集合:

SISMEMBER liked_users:post_123 user_10086
INCR like_count:post_123
SADD liked_users:post_123 user_10086

问题是这三条命令不是原子的。两个请求可能都先看到用户没点过,然后都执行 INCR

解决办法是 Lua,把“读状态、判断、写状态、返回变化量”放到 Redis 内部一次执行。

在这里插入图片描述

5. Bitmap + Lua:让点赞变成状态切换

状态型点赞的关键,不是把数字加 1,而是判断用户状态有没有变化。

用 Bitmap 表示用户是否点过赞:

bitmap:like:post:123:chunk:0
bit[10086] = 0  未点赞
bit[10086] = 1  已点赞

用户 ID 很大时做分片:

chunk = userId / 65536
bit   = userId % 65536

Lua 脚本:

local before = redis.call('GETBIT', KEYS[1], ARGV[1])
local target = tonumber(ARGV[2])

if before == target then
    return 0
end

redis.call('SETBIT', KEYS[1], ARGV[1], target)
return 1

Java 侧只在状态变化时发送计数事件:

long changed = redis.eval(lua, List.of(bitmapKey), List.of(bitOffset, target));

if (changed == 1) {
    kafkaTemplate.send("counter-events", new CounterEvent(postId, field, delta));
}

这样重复点赞不会重复计数,取消点赞也能返回 -1 事件。

6. Kafka 聚合:把高频写折叠成低频写

如果每次点赞都同步更新计数快照,Redis 计数 key 还是会承压。

更合理的链路是:

点赞请求
  -> Redis Lua 切换 Bitmap
  -> 状态变化才发送 Kafka 事件
  -> Consumer 聚合 delta
  -> 每 1 秒 flush 到计数快照

同一个帖子 1 秒内被点 100 次,最终可以折叠成一次 +100 写入,而不是 100 次写入。

Spring Kafka 使用手动提交时,可以处理成功后再 acknowledge()

@KafkaListener(topics = "counter-events", groupId = "counter-agg")
public void onMessage(CounterEvent event, Acknowledgment ack) {
    try {
        aggregate(event);
        ack.acknowledge();
    } catch (Exception ex) {
        throw ex;
    }
}

这里要接受一个事实:Kafka 至少一次投递可能重复。

所以系统需要事实层。Bitmap 是事实层,计数快照是派生层。快照短暂不准,可以从 Bitmap 重新算回来。

7. SDS 快照:读计数不要每次 BITCOUNT

Bitmap 准,但不能每次读都 BITCOUNT

如果一个帖子有 20 个 Bitmap 分片,读一次点赞数就要 20 次 BITCOUNT 再累加。热点帖子高频读时,这会把 Redis 打得很重。

所以需要计数快照:

cnt:post:123 -> binary snapshot

0-3   like_count
4-7   fav_count
8-11  comment_count

这种 SDS 定长快照有几个好处:

  • 批量读可以 pipeline GET。
  • 字段位置固定,Java 按 offset 解析。
  • 返回数据短,没有 Hash field 名开销。
  • 快照异常时,可以从 Bitmap 重建。

在这里插入图片描述

读路径:

读帖子列表
  -> pipeline GET 多个 cnt:post:{id}
  -> 正常:解析快照
  -> 缺失或长度异常:触发重建

8. Caffeine + Redis + MySQL:热点读要做三级缓存

热点直播或热帖出现时,用户不只是点赞,还会不断刷新首页、主播页、活动页。

读路径可以做成三级缓存:

L1 Caffeine 本地缓存
  -> L2 Redis 分页缓存
    -> L3 MySQL

Redis 官方 cache-aside 文档提到,读多写少的实体可以先查 Redis,miss 后回源主库再写回缓存。但同一份文档也提醒,热门 key 在高并发下过期,会让大量进程同时查询数据库,形成 cache stampede。

信息流可以拆成两类 key:

feed:uid:{userId}:p:{page}
  -> 只存帖子 ID 列表

post:detail:{postId}
  -> 存帖子详情

Feed 只存 ID,是为了降低失效成本。帖子被删除或下架时,第二阶段查详情查不到,直接过滤即可,不需要主动删除所有用户 Feed 缓存。

9. 单飞锁:热点详情过期时只让一个线程回源

热点 Key 过期那一刻最危险。

如果 post:detail:{id} 正好过期,100 个请求同时 miss,都去查 MySQL,就是缓存击穿。

单机内可以用 ConcurrentHashMap + CompletableFuture 合并同一个 key 的并发 miss:

private final ConcurrentHashMap<Long, CompletableFuture<PostDetail>> inflight =
        new ConcurrentHashMap<>();

public PostDetail getPostDetail(long postId) {
    PostDetail cached = redisCache.get(postId);
    if (cached != null) {
        return cached;
    }

    CompletableFuture<PostDetail> future = inflight.computeIfAbsent(postId, id ->
            CompletableFuture.supplyAsync(() -> {
                PostDetail detail = postMapper.selectById(id);
                redisCache.set(id, detail);
                return detail;
            }, executor).whenComplete((r, ex) -> inflight.remove(id))
    );

    return future.join();
}

重点:

  • 同一个 postId 共享同一个 Future
  • 第一个请求回源,其他请求等结果。
  • whenComplete 必须清理 inflight key,避免失败后内存泄漏。

它不是分布式锁,但非常划算。100 个请求分散到 10 个实例,没有单飞锁是 100 次 DB 查询,有单飞锁后大约是 10 次。

10. 重建风暴:限流 + 指数退避

计数快照 SDS 丢失时,也会形成风暴。

200 个请求同时读 cnt:post:123
  -> 都发现 SDS 不存在
  -> 都去 BITCOUNT 20 个 Bitmap 分片
  -> 200 * 20 = 4000 次 BITCOUNT

解决思路是控制重建并发:

RRateLimiter limiter = redisson.getRateLimiter("rebuild:post:" + postId);
limiter.trySetRate(RateType.OVERALL, 1, 1, RateIntervalUnit.SECONDS);

if (!limiter.tryAcquire()) {
    return fallbackValue;
}

long trueCount = bitcountAllChunks(postId);
writeSnapshot(postId, trueCount);

Redisson 的 tryAcquire() 有许可就返回 true,没有许可就立即返回 false。它适合重建场景,因为我们不希望线程都阻塞等待。

再加指数退避:

第一次被限流:1 秒后再试
第二次被限流:2 秒后再试
第三次被限流:4 秒后再试
最多退避到 8 秒

这样可以把重建风暴压成“一个请求执行,其他请求分散尝试”。

11. 面试时怎么讲这套方案?

推荐用这条链路:

MySQL UPDATE
  -> 热点行锁竞争

Redis INCR
  -> 性能好,但幂等没解决

Bitmap + Lua
  -> 状态切换原子化

Kafka 聚合
  -> 高频写削峰

SDS 快照
  -> 批量读计数

Caffeine + Redis + MySQL
  -> 热点读分层缓存

单飞锁 + 限流退避
  -> 防缓存击穿和重建风暴

常见追问:

追问 回答重点
为什么不用 MySQL UPDATE? 热点行锁竞争,幂等也没解决
为什么不用 Redis INCR? 性能解决了,用户状态没解决
Kafka 重复消费怎么办? Bitmap 是事实层,快照可以重建
Caffeine 多实例不一致怎么办? L1 只做短 TTL 热点保护
单飞锁为什么不用分布式锁? 本地合并成本低,已能显著降压
SDS 丢了怎么办? 限流后从 Bitmap BITCOUNT 重建
热点 Key 过期怎么办? 动态 TTL、单飞锁、限流、退避一起用

12. 最小落地方案

一个可落地、可面试讲清楚的 Java 版本:

  1. 状态型点赞/收藏:Bitmap 分片 + Redis Lua。
  2. 异步计数:Kafka 发送 delta,Consumer 聚合后写 SDS。
  3. 批量读计数:pipeline GET 多个 SDS。
  4. 信息流读缓存:Caffeine 短 TTL + Redis 分页缓存 + MySQL。
  5. 热点详情保护:ConcurrentHashMap + CompletableFuture 合并 miss。
  6. 重建保护:Redisson 限流 + 指数退避。
  7. 监控指标:缓存命中率、热点 Key、Kafka lag、重建次数、BITCOUNT 耗时、DB 回源量。

在这里插入图片描述

总结

从直播间 500 万点赞看后端,最重要的不是某一个中间件,而是热点互动的分层设计:

状态用 Bitmap 兜住
计数用 Kafka 削峰
快照用 SDS 加速
读取用三级缓存分层
miss 用单飞锁合并
重建用限流和退避保护

热点事件最怕的不是 QPS 高,而是所有请求都集中在同一个对象上。Java 后端真正要做的,是让这个热点对象不会变成数据库热点行、Redis 热点 Key、Kafka 积压点和缓存重建风暴。

参考资料

  • 新浪新闻:王鹤棣雅迪直播的人气数据具体是多少?有具体截图吗? https://k.sina.com.cn/article_7879776328_1d5abd84806801lpeu.html?from=ent&subch=oent
  • 新浪娱乐:王鹤棣直播人气 https://ent.sina.cn/2026-05-25/detail-inhzawmn3615170.d.html
  • Redis 官方文档:Cache-aside https://redis.io/docs/latest/develop/use-cases/cache-aside/
  • Caffeine Wiki:Specification https://github.com/ben-manes/caffeine/wiki/Specification
  • Redisson Javadoc:RRateLimiter https://javadoc.io/static/org.redisson/redisson/3.10.7/org/redisson/api/RRateLimiter.html
  • Spring Kafka 文档:Message Listener Containers / Committing Offsets https://docs.spring.io/spring-kafka/reference/3.3-SNAPSHOT/kafka/receiving-messages/message-listener-container.html
  • Confluent 文档:Kafka Consumer Offset Management https://docs.confluent.io/cloud/current/client-apps/consumer.html

更多推荐