📌 本文速览|建议先收藏再看
主播一句"来波 666",背后是每秒十万级写入、一条弹幕送达百万人的残酷现实。逐条推送?10 万 QPS × 100 万在线 = 100 亿次/秒,物理上不可能 。这篇文章不画空架构图,从真实口径算账开始,把百万并发弹幕系统拆成五层:接入层扛百万长连接,业务层做校验过滤,分发层用"节点级扇出 + 本地房间表 + 时间窗聚合 "把 100 万次网络调用降到约 100 次,存储层冷热分离降成本。核心代码全带:Netty 网关调优、Kafka 独立 consumer group 广播语义、CAS 环形缓冲削峰、脏连接队列批量 flush、推 offset 拉消息体、房间 seq 号段、Redis Stream ACK 可靠通道、四层限流与加权采样。文末还有 14 个难点速查表 + 性能指标,面试和实战都能直接抄作业。
一句话戳破本质:弹幕系统的核心矛盾不是"写不写得进",而是"扇出降不降得下来"。看懂了扇出,你就看懂了高并发的八成。
本文亮点,先睹为快:
- 扇出降 4 个数量级:节点级扇出 + 本地房间表 + 时间窗聚合,100 万次 socket write 变约 100 次节点投递;
- 百万长连接:Netty Epoll、堆外池化、心跳踢死连接,HyperLogLog 用 12KB 统计百万 UV;
- 上行三级削峰:本地环形缓冲背压 → MQ 按 roomId 分区 → 消费端批量聚合,附无锁 CAS 核心代码;
- 下行最硬核:脏连接队列、MPSC 无锁入队、背压丢慢客户端、一次编码百万人共享;
- 推拉结合:小房间纯推,超大房间推 offset 指针 + 客户端拉消息体,O(N) 广播变 O(1);
- 可靠分级:普通弹幕可丢,礼物/系统通知走 Redis Stream ACK,一条不能丢;
- 保命符:四层限流、热度采样、加权采样、L1-L3 降级与故障兜底;
- 冷热分离:Redis 只留最近 5 分钟热弹幕,历史异步落 ClickHouse/HBase,存储成本降 80%。
适合谁读:做系统设计、准备面试、负责直播/IM/实时消息的你。读完能带走一套可落地的架构骨架、核心代码思路和面试表达。
一句话记忆:推拉结合保实时,异步批量抗洪峰,背压隔离护全局。
最后留个互动话题:如果你的直播间突然涌入 100 万在线,你第一步会先扩容哪一层?为什么? 评论区聊聊,我会逐条回复。觉得文章有用,点赞、在看、收藏、转发走一波;下一篇拆秒杀系统,关注不迷路。
百万并发直播弹幕系统设计全解:看懂了扇出,你就看懂了高并发的八成
主播在直播间喊一句"来波 666",弹幕瞬间刷屏------这背后是每秒十万级的写入,和一条弹幕要送达百万人的残酷现实。逐条推送?系统三秒就得趴下。这篇文章从真实场景出发,把弹幕系统的五层架构、推拉结合、批量聚合、分级降级一次讲透,核心代码全部带上。做系统设计、准备面试的朋友,值得收藏细看。
一、先算笔账:我们到底在怕什么
实际开发中,接到"设计一个弹幕系统"这种需求,很多人上来就开始画架构图:Netty + Kafka + Redis,一套组合拳打出去。且慢,先问清楚几个口径,因为量级差一个数量级,方案就完全是两回事:
| 维度 | 需要确认的点 | 典型假设 |
|---|---|---|
| 规模 | 单房间在线人数?弹幕峰值 QPS? | 单房间 100 万在线,全局 10 万条/秒 |
| 延迟 | 端到端可接受延迟? | ≤ 200ms,超过用户就觉得"卡住了" |
| 一致性 | 弹幕能不能丢?要不要落库? | 普通弹幕允许丢;礼物、系统通知不能丢 |
| 合规 | 要不要实时审核? | 必须审核,本地词库同步拦 + 模型异步审 |
| 地域 | 单机房还是多机房? | 多机房,按地域就近接入 |
其中"弹幕能不能丢"是整道题的分水岭------它直接决定了系统走"可靠消息队列"还是"尽力而为的流式广播"。
弹幕不是 IM,是"高频短命的广播流"
很多同学会下意识套用 IM 的思路,这是方向性错误。弹幕和微信消息完全是两个物种:
- 写扩散比极高:万人直播间里,一条弹幕的读扩散比是 1 : 10000,而 IM 是点对点;
- 生命周期极短:一条弹幕的价值窗口通常只有 3~5 秒,过了这个窗口再到达,用户根本感知不到;
- 流量是脉冲式的:平时几千 QPS,进球、明星出场、主播喊口号的瞬间,几秒内暴增几十倍。
所以设计的第一原则不是"把所有消息可靠地存下来",而是:在百毫秒内把当前屏幕该看到的弹幕送到,过期的该丢就丢。
真正的难点:扇出爆炸
来算一笔让人头皮发麻的账:
text
写入:10 万 QPS 弹幕
下发:10 万 QPS × 100 万在线 = 100 亿次/秒 ❌ 物理上不可能
一句话戳破本质:弹幕系统的核心矛盾不是"写不写得进",而是"扇出降不降得下来"。 海量写入、实时分发、低成本存储,三者不可兼得,必须分层取舍。
后面所有的设计决策,都从这句话推导出来。
二、整体架构:五层各司其职
围绕"写入削峰、分发降量"八个字,系统拆成五层,全链路异步化,每层独立水平扩容:

每层的职责边界划得很干净:
- 接入层只管长连接保活,不碰复杂业务;
- 业务层只管校验过滤,无状态随便扩容;
- 分发层只管消息路由推送;
- 存储层冷热分离,热数据扛并发,冷数据省成本。
这样拆的好处是故障隔离性强:业务层挂了连接不断,某个房间炸了不影响全平台,哪层不够就扩哪层。
一个最容易踩的坑:Kafka 消费组
这里埋一个面试和实战中都极容易翻车的点:
如果每个网关节点用同一个 consumer group 消费 Kafka,分区会被负载均衡给不同节点,一条弹幕只会被一个节点消费------其他节点上的用户就收不到弹幕了。
正确姿势是:每个网关节点用独立的 consumer group (或者直接
assign全部分区),实现"广播语义",保证消息到达所有持有该房间连接的节点。
类似的选型问题还有一个高频追问:为什么用 Kafka 而不是 Redis Pub/Sub? 答案很直接------Pub/Sub 无持久化、无削峰能力,断线就丢消息;Kafka 有堆积能力,洪峰时消费端可以"慢慢追",天然削峰填谷。
那 MQ 能不能直接把消息推给客户端?也不行。MQ 是面向存储与可靠的,不擅长百万长连接的扇出和协议处理。Kafka 负责跨节点可靠广播,网关负责最后一公里的聚合投递,各司其职。
架构骨架搭好了,接下来从接入层开始,逐层往里拆。
三、接入层:百万长连接怎么扛
百万用户在线 = 百万条 TCP 连接。Tomcat 这种一连接一线程的模型直接出局,必须上 Netty + WebSocket。
选型:为什么是 Netty,而不是 Spring WebSocket
| 对比项 | Netty | Spring WebSocket |
|---|---|---|
| 单机连接数 | 调优后轻松 10 万+ | 默认配置 1 万都费劲 |
| 协议自定义 | 编解码器完全可控 | 封装太重,定制困难 |
| 内存调优 | 堆外池化内存随便调 | 不方便 |
| 心跳/空闲检测 | 原生 IdleStateHandler |
支持较弱 |
网关启动配置:几个参数调对了才能扛量
java
public class DanmakuGateway {
public void start(int port) throws InterruptedException {
// boss 只负责 accept,worker 负责读写,主从 Reactor 互不阻塞
EventLoopGroup boss = new NioEventLoopGroup(1);
EventLoopGroup worker = new NioEventLoopGroup(
Runtime.getRuntime().availableProcessors() * 2);
new ServerBootstrap()
.group(boss, worker)
.channel(NioServerSocketChannel.class) // Linux 上换 EpollServerSocketChannel
.option(ChannelOption.SO_BACKLOG, 2048)
.childOption(ChannelOption.TCP_NODELAY, true) // 关闭 Nagle,低延迟
.childOption(ChannelOption.SO_KEEPALIVE, true)
.childOption(ChannelOption.SO_RCVBUF, 32 * 1024)
.childOption(ChannelOption.SO_SNDBUF, 32 * 1024)
// 堆外池化内存:减少 GC、转发时避免内核态/用户态拷贝
.childOption(ChannelOption.ALLOCATOR, PooledByteBufAllocator.DEFAULT)
.childHandler(new ChannelInitializer<SocketChannel>() {
@Override
protected void initChannel(SocketChannel ch) {
ChannelPipeline p = ch.pipeline();
p.addLast(new HttpServerCodec());
p.addLast(new HttpObjectAggregator(64 * 1024));
p.addLast(new WebSocketServerProtocolHandler("/danmaku"));
// 读空闲 30s 心跳检测,自动剔除僵尸连接
p.addLast(new IdleStateHandler(30, 60, 0, TimeUnit.SECONDS));
p.addLast(new DanmakuServerHandler());
}
}).bind(port).sync();
}
}
配置之外,单机扛 10 万+ 连接还需要一整套系统级调优,这些都是踩过坑才知道的:
- 文件描述符 :
ulimit -n调到百万级,否则连接数上不去; - 内核参数 :
tcp_mem、wmem调优,不然内存先炸; - Epoll 模式 + ET 边沿触发,比 NIO 性能更好;
- 自定义二进制协议:消息头带房间 ID、消息类型,比 JSON 省解析开销,Protobuf 编码比 JSON 体积压缩 60% 以上;
- GC 三件套 :池化堆外内存减少堆内 GC、弹幕对象走对象池复用、所有耗时业务逻辑丢业务线程池------千万别在 IO 线程做任何重活。
网关本身要做无状态化(连接信息可重建),这样宕机后客户端能自动重连到其他节点。入口还要做连接维度限流和单机连接数熔断,慢连接及时检测断开。
冷知识:在线人数用 HyperLogLog 统计
直播间"10 万+ 人在看"这个数字,千万别用 Set 存------100 万个 userId 要几十 MB 内存。用 Redis 的 HyperLogLog:
java
// 进房
redis.opsForHyperLogLog().add("danmu:online:room:" + roomId, userId);
// 统计:近似值,百万 UV 只占 12KB 内存,误差 < 1%
Long onlineCount = redis.opsForHyperLogLog().size("danmu:online:room:" + roomId);
12KB 统计百万级 UV,误差不到 1%,用户完全感知不到。这种"用精度换内存"的思路,在高并发场景里到处都是。
连接扛住了,弹幕消息进来之后该怎么处理?往下看。
四、上行链路:三级削峰,把洪峰变细流
用户发一条弹幕,网关既不能同步写库,也不能同步广播。正确的链路是:

注意第 ⑤ 步:校验通过写入 MQ 后接口立即返回,不等下发结果。前端配合做本地回显------用户看到自己的弹幕"发出去了",体验无损。哪怕这条弹幕后面被限流丢弃,用户也无感知。这是个很便宜但极其好用的体验优化。
上行核心代码:频控 → 过滤 → 保序投递
java
@Service
public class DanmakuSendService {
@Autowired private RocketMQTemplate rocketMQTemplate;
@Autowired private SensitiveWordFilter sensitiveWordFilter;
@Autowired private RateLimiterService rateLimiterService;
public void sendDanmaku(DanmakuDTO dto) {
// 1. 频控校验:用户 + 房间双维度,防恶意刷屏
if (!rateLimiterService.tryAcquire(dto.getUserId(), dto.getRoomId())) {
throw new BusinessException("发送太频繁,请稍后再试");
}
// 2. DFA 敏感词实时过滤
if (sensitiveWordFilter.containsSensitiveWord(dto.getContent())) {
throw new BusinessException("内容包含违规词汇");
}
// 3. 按房间 ID 哈希分区发送,保证同房间消息顺序;失败重试
rocketMQTemplate.asyncSendOrderly("danmaku_topic", dto, dto.getRoomId(),
new SendCallback() {
@Override public void onSuccess(SendResult r) {}
@Override public void onException(Throwable e) { retrySend(dto); }
});
}
}
(Kafka 还是 RocketMQ 看团队技术栈,思路完全一致:MessageQueueSelector / partition key 按 roomId 哈希。)
三级削峰:光有 MQ 还不够
弹幕峰值每秒几十万条,直接写 MQ 也可能被打懵。工业级的做法是三级削峰:
- 网关本地环形缓冲 :先入本地队列,满了就丢最老的(背压机制,宁可丢消息也不能阻塞 IO 线程);
- MQ 按 roomId 分区:同房间消息进同一分区,既削峰又保序;
- 消费端批量聚合:不是来一条推一条,而是攒够一批再推。
第一级的本地聚合器是整个写入链路的亮点,直接上代码:
java
/**
* 弹幕本地聚合器(Disruptor 思想):
* 把单条写 Kafka 的 10 万 QPS,聚合成 200 批/秒
* 环形数组 + CAS + 批量 flush,全程无锁,近乎零 GC
*/
public class DanmakuAggregator {
private final DanmakuMsg[] ring = new DanmakuMsg[8192]; // 环形缓冲
private final AtomicInteger writeIndex = new AtomicInteger(0);
private final AtomicInteger flushedIndex = new AtomicInteger(0);
private final KafkaTemplate<String, String> kafka;
private static final int BATCH = 512;
private static final long FLUSH_INTERVAL_MS = 10;
public boolean offer(DanmakuMsg msg) {
while (true) {
int idx = writeIndex.get();
int next = (idx + 1) & (ring.length - 1); // 位运算取模
if (next == flushedIndex.get()) { // 满了直接丢,保实时性
metrics.increment("danmaku_dropped");
return false;
}
if (writeIndex.compareAndSet(idx, next)) { // CAS 无锁
ring[idx] = msg;
return true;
}
}
}
/** 定时批量 flush:一次网络 IO 发送 512 条 */
@Scheduled(fixedDelay = FLUSH_INTERVAL_MS)
public void flush() {
int end = writeIndex.get();
int start = flushedIndex.get();
if (end == start) return;
List<ProducerRecord<String, String>> batch = new ArrayList<>(BATCH);
for (int i = start; i != end; i = (i + 1) & (ring.length - 1)) {
DanmakuMsg m = ring[i];
batch.add(new ProducerRecord<>("danmaku_topic", m.getRoomId(), JsonUtil.write(m)));
ring[i] = null; // 及时置 null,避免跨代引用导致 GC 问题
}
kafka.send(batch);
flushedIndex.set(end);
}
}
亮点拆解:位运算取模、CAS 无锁、满了直接丢保实时性、批量 IO、及时置 null 防 GC 泄漏。面试时把这五个点主动说出来,基本就是加分项。
分区与优先级:Kafka 的两个关键设计

- 按
roomId哈希分片,同一直播间消息落同一分区,天然有序,消费端无需额外排序; - 分区数随流量水平扩容,吞吐线性增长。
另外,不是所有弹幕都平等。建议按消息类型拆 Topic 分级:
| Topic | 消息类型 | 消费优先级 |
|---|---|---|
danmaku-normal |
普通弹幕 | 低 |
danmaku-gift |
礼物弹幕 | 高 |
danmaku-system |
系统公告 | 最高 |
高级消息优先消费,确保付费用户体验不受洪峰影响------这是实打实的"成本意识"。
消费端拿到批量消息后,按房间分组喂给聚合器:
java
@KafkaListener(topics = "live-danmu",
containerFactory = "batchKafkaListenerContainerFactory")
public void consume(List<ConsumerRecord<String, Danmu>> records) {
records.stream()
.map(ConsumerRecord::value)
.collect(Collectors.groupingBy(Danmu::getRoomId))
.forEach((roomId, list) -> list.forEach(aggregator::add));
}
幂等:防重复的最后防线
客户端弱网重发、MQ 重复消费都会产生重复消息。服务端用 Redis 存 弹幕ID + 用户ID 的短时效 Key(5 秒过期)做幂等校验;客户端上行时带 clientMsgId,重复消息直接返回成功,不再入队。
消息进了 MQ,洪峰算是稳住了。但真正的硬仗才刚开始------这些弹幕怎么送到百万人手上?
五、内容审核:DFA 同步拦截 + 异步模型复审
弹幕是 UGC 重灾区,审核必须做,但 50 万 QPS 同步调审核模型等于自杀。方案是两层防线:
第一层:DFA 本地过滤,微秒级
为什么用 DFA(确定有限状态自动机)而不是正则?正则每来一条弹幕都要把所有敏感词全匹配一遍,1000 个词就是 1000 次匹配;DFA 把所有词构建成一棵前缀树,一次扫描完成,时间复杂度 O(n),只和文本长度有关,跟敏感词数量无关。实测比正则快 100 倍以上。
java
public class SensitiveWordFilter {
private final TrieNode root = new TrieNode();
/** 构建前缀树:逐字符插入,词尾打标 */
public void addWord(String word) {
TrieNode p = root;
for (char c : word.toCharArray()) {
p = p.next.computeIfAbsent(c, k -> new TrieNode());
}
p.end = true;
}
/** 匹配:一次扫描完成;fail 失败指针处理后缀重叠(AC 自动机) */
public boolean hit(String text) {
TrieNode p = root;
for (int i = 0; i < text.length(); i++) {
char c = text.charAt(i);
while (p != root && p.next.get(c) == null) p = p.fail; // 失败指针跳转
p = p.next.getOrDefault(c, root);
if (p.end) return true;
}
return false;
}
}
工程上还有三个细节:
- 词库全量加载到本地内存,单条匹配 < 1ms,扛住几十万 QPS 同步过滤;
- 后台异步增量更新词库,热更新无需重启服务;
- 流量峰值时启动分级策略:只匹配高危词库,普通词库降级为异步审核。
第二层:先发后审 + 撤回指令
本地 DFA 只能拦词库里的东西,变体字、阴阳怪气它管不了。所以完整链路是:
text
DFA 同步拦截(μs) → 弹幕先下发 → Kafka → 异步模型审核(~200ms) → 违规则下发撤回指令 + 账号惩罚
配合内容指纹缓存(相同内容只审一次),审核成本又能降一大截。这套"先发后审"是用极小的违规曝光窗口,换来了全链路不被审核拖慢,是典型的工程权衡。
六、下行分发:这道题的胜负手
前面说过,扇出才是核心矛盾。这一章是全文最硬核的部分,也是面试和实战中最能拉开差距的地方。
6.1 分层扇出:从 100 万次网络调用到 100 次
先看朴素方案错在哪:

关键思路是三级降压:
- 节点级扇出:消息只投递到"持有该房间观众连接的网关节点"(100 个节点,而不是 100 万个连接);
- 本地房间表 :网关内存维护
roomId → 本地 Channel 集合,精准投递而非全量扫描; - 时间窗聚合:一个窗口内的弹幕合并成一个包发出,系统调用数降低 1~2 个数量级。
网络开销从 100 万次降到 100 次,下降 4 个数量级。
6.2 房间路由:先回答"推给谁"
弹幕是房间内广播,不是全局广播,所以必须有一张路由表。两种模式都常见:
模式 A:网关直接消费。每个网关节点独立消费组订阅 MQ,消息广播到所有节点,节点查本地房间表决定是否下发。链路最短,延迟最低。
模式 B:独立分发服务。分发服务消费 MQ,查 Redis 路由表,把消息推给对应网关。路由表的典型结构:
text
room:1001:gateways = [gateway-1, gateway-2, gateway-7] # 正向:房间在哪些网关
gateway:gateway-1:rooms = [1001, 2003, ...] # 反向:网关持有哪些房间
| 对比项 | 网关直接消费 | 独立分发服务 |
|---|---|---|
| 链路延迟 | 最短 | 多一跳 |
| 路由复杂度 | 无需路由表 | 需维护路由表 + 心跳续期 |
| 灵活性 | 消费逻辑耦合在网关 | 分发策略可独立演进 |
| 适用场景 | 网关数量可控 | 网关极多、需集中调度 |
路由表有几个容易忽略的运维细节:心跳续期 + TTL(防止网关宕机后路由残留)、网关上下线事件同步、反向索引便于节点下线时快速清理。
而网关本地房间表,是精准投递的基石:
java
@Component
public class RoomRegistry {
/** roomId -> 本机该房间的连接集合 */
private final ConcurrentHashMap<Long, Set<Channel>> roomChannels = new ConcurrentHashMap<>();
public void join(long roomId, Channel ch) {
roomChannels.computeIfAbsent(roomId, k -> ConcurrentHashMap.newKeySet()).add(ch);
// 本机首次有人进这个房间 -> 订阅该房间,避免无效扇出
MqSubscriber.subscribeIfAbsent(roomId);
}
public void leave(long roomId, Channel ch) {
Set<Channel> set = roomChannels.get(roomId);
if (set == null) return;
set.remove(ch);
// 空房间必须移除,否则百万房间累积 -> 内存泄漏
// CAS 双参删除,避免并发下误删新加入的连接集合
if (set.isEmpty()) {
roomChannels.remove(roomId, set);
MqSubscriber.unsubscribe(roomId);
}
}
public Set<Channel> localViewers(long roomId) {
return roomChannels.getOrDefault(roomId, Collections.emptySet());
}
}
亮点 :
remove(roomId, set)这个 CAS 双参删除,防的是"判断为空"和"删除"之间恰好有新连接加入导致误删的并发问题;空房间及时移除 + 取消订阅,防内存泄漏和无效扇出。这两个细节,没踩过坑的人写不出来。
连接集合也可以用 Netty 的 DefaultChannelGroup 管理,配合 closeFuture() 监听自动清理断开的连接。但注意:百万连接下更推荐 ConcurrentHashMap.newKeySet() 或分片 Set ,CopyOnWriteArraySet 在写多的场景会有复制开销。
6.3 批量聚合:整个系统最核心的一段代码
很多方案写到 channelGroup.writeAndFlush(msg) 就交卷了,但 DefaultChannelGroup 的 writeAndFlush 是同步遍历所有 channel 逐个写入 ------在线超过 5000、弹幕频率一高,IO 线程直接阻塞,EventLoop 积压到抛 RejectedExecutionException。
正确的聚合下发器长这样:
java
/**
* 弹幕聚合下发:把时间窗内的弹幕合并成 1 次 write
* 三大亮点:
* ① 脏连接队列 ------ 只遍历"有数据"的连接,百万连接下从 O(总连接) 降到 O(活跃数)
* ② MPSC 无锁入队 ------ 业务线程不加锁(jctools 单消费者队列)
* ③ 背压感知 ------ 慢客户端直接丢弃,绝不拖垮服务端
*/
public class DanmuFlusher {
static final AttributeKey<MpscArrayQueue<Danmu>> QUEUE = AttributeKey.valueOf("dq");
static final AttributeKey<AtomicBoolean> DIRTY = AttributeKey.valueOf("dirty");
private static final int MAX_BATCH = 64;
private final ConcurrentLinkedQueue<Channel> dirtyQueue = new ConcurrentLinkedQueue<>();
/** 入队 O(1);队列满则丢------弹幕允许丢,绝不能 OOM */
public boolean offer(Channel ch, Danmu d) {
MpscArrayQueue<Danmu> q = ch.attr(QUEUE).get();
if (!q.offer(d)) return false;
// CAS 保证同一连接在脏队列中只出现一次
if (ch.attr(DIRTY).get().compareAndSet(false, true)) {
dirtyQueue.offer(ch);
}
return true;
}
/** 每 30ms flush 一次 */
@Scheduled(fixedDelay = 30)
public void flush() {
Channel ch;
while ((ch = dirtyQueue.poll()) != null) {
ch.attr(DIRTY).get().set(false);
if (!ch.isActive()) continue;
// 背压:TCP 发送缓冲区积压 = 客户端慢/弱网 -> 清空队列保命
if (!ch.isWritable()) {
ch.attr(QUEUE).get().clear();
continue;
}
List<Danmu> batch = new ArrayList<>(MAX_BATCH);
ch.attr(QUEUE).get().drain(batch::add, MAX_BATCH);
if (!batch.isEmpty()) {
// 1 次 syscall 发 N 条:Protobuf 批量编码
ch.writeAndFlush(new BinaryWebSocketFrame(
Unpooled.wrappedBuffer(Codec.encode(batch))));
}
}
}
}
窗口时间怎么选? 10ms、30ms、100ms 都有人用。人眼对 100ms 以内的延迟基本无感,所以窗口开到 100ms 体验都还能接受;窗口越大,聚合效果越好。一般小房间 10~30ms 保体验,热点房间拉到 100ms 甚至更长保吞吐。也可以用 HashedWheelTimer 做时间轮触发,配合 AtomicBoolean CAS 防止重复调度:
java
private final HashedWheelTimer mergeTimer = new HashedWheelTimer(10, TimeUnit.MILLISECONDS, 512);
private final ConcurrentLinkedQueue<DanmakuMsg> pendingQueue = new ConcurrentLinkedQueue<>();
private final AtomicBoolean flushScheduled = new AtomicBoolean(false);
public void onDanmakuReceived(DanmakuMsg msg) {
pendingQueue.offer(msg);
// 每个窗口只提交一次 flush 任务
if (flushScheduled.compareAndSet(false, true)) {
mergeTimer.newTimeout(t -> flushBatch(), 10, TimeUnit.MILLISECONDS);
}
}
聚合带来的收益是数量级的:
| 指标 | 朴素逐条推 | 聚合下发 |
|---|---|---|
| write 次数/秒 | 千亿级 | ~1000 万 |
| 单条网络开销 | 每条独占 TCP 头 + 帧头 | N 条共用一个包头 |
| 服务端 CPU | sys 态打满 | 下降 70%+ |
6.4 写入保序与字节复用:两个教科书写法
Netty 只保证单个 channel 内 写入有序,多个业务线程并发调 channel.write() 顺序就乱了。解法是强制所有写入走该 channel 所属的 EventLoop,天然串行:
java
public void safeBroadcast(Channel ch, ByteBuf payload) {
ch.eventLoop().submit(() -> {
if (!ch.isActive()) return;
ch.writeAndFlush(new BinaryWebSocketFrame(payload));
});
}
另一个是一次编码、百万人共享:同一批弹幕只序列化一次,所有连接复用同一份字节,用引用计数控制生命周期:
java
public void push(Long roomId, DanmakuMsg msg) {
Set<Channel> channels = roomChannels.get(roomId);
if (channels == null) return;
ByteBuf buf = encode(msg); // 只编码一次,百万人共享同一份字节
for (Channel ch : channels) {
if (ch.isWritable()) {
ch.writeAndFlush(buf.duplicate().retain()); // 引用计数复用
} else {
metrics.increment("slow_client");
if (ch.bytesBeforeUnwritable() == 0) {
ch.close(); // 宁可踢掉一人,不拖垮一个房间
}
}
}
buf.release();
}
写广播时还可以用 voidPromise() 替代默认 promise,省掉 listener 对象分配的开销。这些细节单个看不起眼,叠加起来就是 70% 的 CPU。
6.5 推拉结合:不要无脑对所有房间写扩散
纯推模式在超大房间会遇到物理瓶颈:一条弹幕复制 N 份写出去,CPU 和带宽随在线数线性增长。工业界按房间规模分级处理:
| 房间规模 | 策略 | 理由 |
|---|---|---|
| 小房间(< 5 万在线) | 纯推,WebSocket 直接广播 | 延迟最低,实现简单 |
| 中大房间 | 批量推送 + 客户端定时拉取补偿 | 平衡延迟与扇出成本 |
| 超大房间(10 万+) | 推 offset 指针 + 客户端拉消息体 | O(N) 广播变 O(1) 指针推送 |
超大房间的"推信号、拉消息体"是这样的:
java
// 网关只推 offset 指针,不推全量弹幕
public void pushOffsetSignal(String roomId, long latestOffset) {
OffsetSignal signal = OffsetSignal.newBuilder()
.setRoomId(roomId)
.setOffset(latestOffset)
.build();
roomChannels.get(roomId).writeAndFlush(
new BinaryWebSocketFrame(Unpooled.wrappedBuffer(signal.toByteArray())));
}
// 客户端收到信号后按需拉取:
// GET /danmaku/pull?roomId=1001&sinceSeq=10234&limit=50
即使 50 万人在线,网关每秒也只需推几十个 offset 信号,真正的消息体由客户端自己拉------带宽和 CPU 都从 O(N) 降到 O(1)。
拉取接口直接打在 Redis 上,服务端不维护任何客户端进度,天然无状态、可水平扩展:
java
/**
* 断线补漏 / 增量拉取
* Redis ZSet 按 seq 存最近 5 分钟弹幕,O(logN) 范围查询
*/
@GetMapping("/danmaku/pull")
public List<DanmakuMsg> pull(@RequestParam Long roomId,
@RequestParam Long sinceSeq,
@RequestParam(defaultValue = "200") int limit) {
return redis.opsForZSet()
.rangeByScore("dm:buf:" + roomId, sinceSeq + 1, "+inf", 0, limit)
.stream().map(JsonUtil::read).toList();
}
新进直播间的用户也走这套:直接从 Redis 拉最近的热弹幕,100ms 内完成画面补全,不用查历史库。热弹幕缓存的维护:
java
@Service
public class DanmakuCacheService {
@Resource private StringRedisTemplate redisTemplate;
private static final int MAX_HOT_COUNT = 1000; // 单房间最多 1000 条热弹幕
private static final int MAX_FETCH_COUNT = 50; // 单次最多拉 50 条,控制前端渲染压力
/** 增量拉取:客户端传上次拉到的位置 */
public List<DanmakuVO> fetchIncrementDanmaku(String roomId, long lastIndex) {
String key = "danmaku:hot:" + roomId;
Long totalLen = redisTemplate.opsForList().size(key);
if (totalLen == null || totalLen <= lastIndex) {
return Collections.emptyList();
}
long endIndex = Math.min(lastIndex + MAX_FETCH_COUNT - 1, totalLen - 1);
return redisTemplate.opsForList().range(key, lastIndex, endIndex).stream()
.map(json -> JSON.parseObject(json, DanmakuVO.class))
.collect(Collectors.toList());
}
/** 写入热缓存:rightPush + trim 固定长度,内存占用恒定 */
public void addHotDanmaku(String roomId, DanmakuVO danmaku) {
String key = "danmaku:hot:" + roomId;
redisTemplate.opsForList().rightPush(key, JSON.toJSONString(danmaku));
redisTemplate.opsForList().trim(key, -MAX_HOT_COUNT, -1);
}
}
亮点 :
LPUSH/LTRIM(或rightPush + trim)把 Redis 变成一个定长环形缓冲,热数据内存恒定不膨胀;拉取接口无状态,集群随便加机器。轮询间隔按房间热度调,200ms~2s 都可以。
到这里,"怎么推"讲完了。但还有两个问题悬着:弹幕乱了怎么办?礼物消息丢了怎么办?
七、顺序、去重与分级可靠
7.1 房间维度的单调 seq:一石三鸟
给每条弹幕发一个房间维度单调递增的序列号,能同时解决三件事:客户端按 seq 排序渲染 (不依赖到达顺序,网络轻微乱序无感知)、断线重连按 seq 去重补帧 、推拉结合按 seq 补漏。
发号器推荐号段模式,批量借号,把 Redis 的 QPS 降 1000 倍:
java
/**
* 房间维度递增 seq:批量借号段(1000 个/次),
* 避免每条弹幕都打一次 Redis
*/
public class SegmentSeqGenerator {
private final ConcurrentHashMap<Long, long[]> roomSeg = new ConcurrentHashMap<>();
private final StringRedisTemplate redis;
public long next(long roomId) {
long[] seg = roomSeg.computeIfAbsent(roomId, k -> new long[]{0, 0}); // {cursor, end}
long cur = ++seg[0];
if (cur >= seg[1]) {
synchronized (seg) { // 只锁单个房间,粒度极细
if (cur >= seg[1]) {
long end = redis.opsForValue().increment("seq:" + roomId, 1000);
seg[0] = end - 1000;
seg[1] = end;
}
}
cur = ++seg[0];
}
return cur;
}
}
(也可以用简化版 Snowflake:时间戳 << 22 | 机器位 | 序列,好处是去中心化,坏处是跨机器不严格连续,按场景选。)
顺序性上还有一个容易被忽略的点:多节点并发下发时,消费端可以按 serverTime + seq 做归并(小顶堆),保证合流后依然有序;同一房间统一 seq 体系,Web / App / TV 多端各自按 seq 补拉,天然对齐。
7.2 去重:服务端幂等 + 客户端幂等窗口
重复投递的来源很多:网络重试、ACK 超时重发、MQ 重复消费。对策是双保险------服务端 clientMsgId 幂等(前面讲过),客户端按 msgId / seq 维护一个去重窗口,重复到达的直接丢弃。
7.3 分级可靠:普通弹幕尽力而为,礼物一条不能丢
普通文本弹幕可以"尽力而为",但礼物、系统通知、付费弹幕必须不丢不重。让它们走独立的可靠通道,核心组合是 Redis Stream 的 Consumer Group + ACK + 客户端 seq 去重:
text
Producer → XADD stream:room:1001 * uid 20345 content "666" ts ...
Consumer → XREADGROUP GROUP live CONSUMER gateway-1 COUNT 50 BLOCK 2000
STREAMS stream:room:1001 >
成功 → XACK stream:room:1001 live <msgId>
失败 → XCLAIM 转移 / 延迟重试,pending 消息不丢
Redis Stream 的 ID 本身是"毫秒时间戳 + 序号",单 Stream 内天然递增有序。这套"确认 + 重试 + 幂等"是可靠投递的标准三件套。
消息的优先级分级建议直接固化成表:
| 消息类型 | 优先级 | 处理策略 |
|---|---|---|
| 系统公告 | P0 | 永不丢弃,可靠通道 |
| 礼物 / 上舰 | P0 | 永不丢弃,可靠通道 |
| 房管 / 主播发言 | P0~P1 | 优先保障 |
| 付费 / 等级用户弹幕 | P1 | 高权重保留 |
| 普通用户弹幕 | P2 | 采样下发 |
| 点赞 / 进场动画 / 重复刷屏 | P3 | 带宽紧张时优先丢 |

知道"普通弹幕和礼物走不同的可靠性等级",说明你有分级保障的成本意识------这一点在面试里是明确的加分项。
八、限流与降级:系统的保命符
写这篇的时候我反复想强调一句话:降级预案是架构的一部分,不是出事后的补救。 弹幕系统的稳定性,一半靠正常链路的设计,另一半靠极端场景下"优雅地丢东西"。
8.1 四层限流:从用户到集群
| 限流层级 | 限流维度 | 典型阈值 | 降级策略 |
|---|---|---|---|
| 用户级 | 单用户发送频率 | 5 条/10 秒 | 返回"发送太快了" |
| 房间级 | 单房间总 QPS | 按直播间热度动态调整 | 丢弃低优先级弹幕 |
| 网关级 | 单机总连接数 | 10 万/台 | 新连接排队或返回"系统繁忙" |
| 全局级 | 集群总 QPS | 按压测结果设定 | 非核心接口降级,只保弹幕通路 |
用户级限流推荐 Redis ZSet + Lua 滑动窗口,毫秒级精度,原子执行:
lua
-- KEYS[1]: danmaku:limit:{roomId}:{userId}
-- ARGV: now(毫秒), window(窗口毫秒), max(上限)
local key = KEYS[1]
local now = tonumber(ARGV[1])
local window = tonumber(ARGV[2])
local max = tonumber(ARGV[3])
-- 清理窗口外的过期数据
redis.call('ZREMRANGEBYSCORE', key, 0, now - window)
local count = redis.call('ZCARD', key)
if count < max then
-- 写入当前请求,随机后缀避免同毫秒 member 冲突
redis.call('ZADD', key, now, now .. ':' .. math.random(100000))
redis.call('PEXPIRE', key, window)
return 1 -- 放行
end
return 0 -- 限流
三种常见限流算法的取舍,一张表说清:
| 算法 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| 固定窗口(INCR + EXPIRE) | 实现最简单 | 窗口边界有突刺 | 粗粒度房间级统计 |
| 滑动窗口(ZSet + Lua) | 毫秒级精准,无边界突刺 | 内存略高 | 用户级频控(推荐) |
| 令牌桶 | 允许一定突发流量 | 实现稍复杂 | 网关入口平滑限流 |
令牌桶可以用 Redis Lua 原子实现(Hash 存 tk/ts,按时间补令牌),也可以在网关本地做 JVM 版预限流,把明显超频的请求在入口就丢掉,连 Redis 都不用打:
java
// 网关本地令牌桶:超频弹幕直接丢,用户无感知
public class TokenBucket {
private final int capacity = 5; // 桶容量
private final double rate = 1.0; // 每秒补 1 个令牌
private long tokens = capacity;
private long lastRefill = System.currentTimeMillis();
public synchronized boolean tryAcquire() {
long now = System.currentTimeMillis();
tokens = Math.min(capacity, tokens + (long) ((now - lastRefill) * rate / 1000));
lastRefill = now;
if (tokens > 0) { tokens--; return true; }
return false;
}
}
顺带一个体验细节:被限流的弹幕,服务端丢弃的同时前端保持"已发送"的本地回显------用户爽了,系统也没压力,双赢。
8.2 热度采样:百万房间的救命稻草
热门房间不可能给每个人全量下发,带宽不允许。按房间热度做采样,关键是稳定分桶------同一个用户始终看同一个桶的弹幕,画面不会忽闪忽现:
java
/** 稳定分桶:用户只收到属于自己桶的弹幕 */
public boolean shouldDeliver(Danmu d, long uid, int sampleRate) {
if (d.priority() >= PRIORITY_GIFT) return true; // 礼物/舰长/主播:永不丢弃
if (sampleRate <= 1) return true; // 小房间全量
return (d.seq() % sampleRate) == ((uid & 0x7FFF_FFFF) % sampleRate);
}
/** 房间热度 -> 采样率(房间服务定时计算,推送到各节点本地缓存) */
static int sampleRate(long viewers) {
if (viewers < 1_000) return 1; // 小房间:全量,聊天氛围最重要
if (viewers < 10_000) return 2;
if (viewers < 100_000) return 5;
return 20; // 百万房间:1/20,反正用户也看不过来
}
客户端也可以配合做采样展示(比如每秒最多渲染 20 条),服务端和客户端两头一起削。
8.3 加权采样:既控量,又保付费体验
峰值场景下单房间每秒上万条弹幕,屏幕根本展示不完。服务端按 100ms 窗口做加权随机采样,把下游流量压到原来的 1/10:
java
/**
* 100ms 窗口加权采样:
* 高等级弹幕 100% 保底,普通弹幕按权重抽样,总量控制在 maxCount
*/
public List<Danmaku> weightSampling(List<Danmaku> list, int maxCount) {
if (list.size() <= maxCount) return list;
Map<Integer, Integer> levelWeight = Map.of(
0, 1, // 普通用户
1, 5, // 贵族用户
2, 20, // 礼物弹幕
3, 100 // 系统公告
);
List<Danmaku> result = new ArrayList<>(maxCount);
List<Danmaku> normalPool = new ArrayList<>();
for (Danmaku d : list) {
if (d.getLevel() >= 2) result.add(d); // 高优先级 100% 保留
else normalPool.add(d);
}
int remainSlot = maxCount - result.size();
int totalWeight = normalPool.stream()
.mapToInt(d -> levelWeight.getOrDefault(d.getLevel(), 1)).sum();
Random random = new Random();
for (int i = 0; i < remainSlot && !normalPool.isEmpty(); i++) {
int rand = random.nextInt(totalWeight);
int acc = 0;
Iterator<Danmaku> it = normalPool.iterator();
while (it.hasNext()) {
Danmaku d = it.next();
int w = levelWeight.getOrDefault(d.getLevel(), 1);
acc += w;
if (acc > rand) {
result.add(d);
totalWeight -= w;
it.remove();
break;
}
}
}
return result;
}
亮点:分层保底(礼物、公告 100% 保留,不伤付费体验)+ 加权随机(严格控制输出数量,又保证高等级用户曝光权重)+ 100ms 批量窗口(降低 CPU 计算频次)。
还有个顺手的优化:热点重复弹幕合并。万人刷"666"的时候,与其推一万条,不如合并成"666 x82",前端一次渲染,氛围还在,流量没了。
8.4 多级降级与故障兜底

功能降级按压力分级执行:
- L1:聚合窗口从 30ms 拉到 100ms~3s,牺牲少量延迟换吞吐;
- L2:采样下发,只推 10%~20% 的普通弹幕;
- L3:客户端关闭弹幕通道,只收礼物和系统消息;
- 负载 > 80%:关闭弹幕特效、历史弹幕查询等非核心功能。
故障兜底预案也要提前写好:
| 故障场景 | 兜底方案 |
|---|---|
| Redis 故障 | 网关直接透传弹幕,降级为纯实时分发,暂不缓存、不落库 |
| MQ 积压 | 临时扩容消费组,或直接丢弃低优先级弹幕保核心链路 |
| 单机房故障 | 多机房多活,直播间按地域调度到就近机房,自动切流 |
| 网关宕机 | 网关无状态水平扩展,客户端指数退避重连到其他节点 |
说到重连,还有个必须防的坑:重连风暴 。网关一宕机,百万连接同时重连,能把新集群直接打垮。客户端必须做指数退避 + 随机抖动(jitter);网关下线要走优雅下线流程------先摘流量,等存量连接迁移完再停进程。
写到这里,系统的"攻"和"防"都齐了。最后聊聊数据怎么存。
九、存储层:热数据要快,冷数据要省
弹幕每天几十亿条,千万别直接写 MySQL。冷热分离是唯一出路:

| 数据类型 | 存储选型 | 核心作用 | 设计考量 |
|---|---|---|---|
| 热弹幕(最近 5 分钟) | Redis List / Stream | 进房拉取、增量轮询、断线补齐 | 定长环形缓冲,单房间 200~1000 条 |
| 历史弹幕 | ClickHouse | 回放、数据分析、弹幕报表 | 列存压缩比高,查询毫秒级 |
| 历史弹幕(回放索引) | HBase | 按"房间ID+时间戳"行键 + 二级索引 | 任意时间段回放加载 < 1s |
| 弹幕检索 | Elasticsearch(可配 MongoDB 分片) | 关键词检索、运营查询 | 与主链路异步解耦 |
| 超冷数据 | OSS / S3 | 合规归档、长尾需求 | 成本极低,按需回源 |
| 用户/房间基础数据 | MySQL + 本地缓存 | 基础业务查询 | 缓存降低查询压力 |
核心逻辑一句话:只有最近 5 分钟的热弹幕存 Redis 供实时拉取,历史弹幕全部异步落冷存储,只有回放、审计场景才查冷库------避免热库被历史请求打穿。实测存储成本能降 80%,回溯查询依然是毫秒级。
落库必须异步批量,把万级 QPS 的写库压到百级:
java
@Component
public class DanmakuBatchWriter {
private final BlockingQueue<DanmakuMsg> buffer = new LinkedBlockingQueue<>(10000);
private final ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(1);
@PostConstruct
public void init() {
// 每 200ms 定时刷盘
scheduler.scheduleAtFixedRate(this::flush, 200, 200, TimeUnit.MILLISECONDS);
}
public void add(DanmakuMsg msg) {
buffer.offer(msg);
if (buffer.size() >= 500) flush(); // 满 500 条立即刷盘
}
private void flush() {
List<DanmakuMsg> batch = new ArrayList<>();
buffer.drainTo(batch, 500); // 原子批量取出,无并发问题
if (!batch.isEmpty()) {
danmakuMapper.batchInsert(batch);
}
}
}
内存缓冲 + 定时/定量双触发,DB IO 次数直接降两个数量级。回放场景再按时间分片存储,进度条拖到哪加载到哪。
十、难点速查表与性能指标
把全文的硬仗汇总成一张速查表,建议截图保存:
| # | 技术难点 | 根因 | 解决方案 |
|---|---|---|---|
| 1 | 广播扇出爆炸 | 一条弹幕复制 N 份,CPU/带宽随在线数线性涨 | 节点级扇出 + 本地房间表 + 时间窗聚合 + 推拉结合 + 热度采样 |
| 2 | 百万长连接 | 句柄、内存、GC 三座大山 | Netty Epoll + 堆外池化 + 对象复用 + ZGC;ulimit -n 百万级;tcp_mem 调优;心跳踢死连接 |
| 3 | 写入突发洪峰 | 脉冲式流量瞬间几十倍 | 三级削峰:本地环形缓冲背压 → MQ 分区 → 消费端批量聚合 |
| 4 | Netty 广播阻塞 IO 线程 | ChannelGroup.writeAndFlush 同步遍历 |
时间窗批量合并 + 异步写(voidPromise / ChannelPromise) |
| 5 | 弹幕乱序 | 多线程并发写、跨节点无全局序 | roomId 分区保序 + 同一 EventLoop 串行写 + 单调 seq,客户端按 seq 渲染 |
| 6 | 重复投递 | 网络重试 / ACK 重发 / MQ 重复消费 | clientMsgId 服务端幂等 + 客户端 msgId/seq 去重窗口 |
| 7 | 慢客户端拖垮服务 | 弱网写不动,缓冲区积压 OOM | isWritable 水位背压 → 清空队列 → 连续超时主动断开 |
| 8 | 重连风暴 | 网关宕机,百万连接同时重连 | 客户端指数退避 + 随机抖动;网关优雅下线 |
| 9 | 热点房间单点 | 单房间百万人,单节点/单分区扛不住 | 房间分片 + 独立集群 + 房间内再分片(按用户哈希拆子流)+ 多级分发树 |
| 10 | 实时审核 | 同步模型审核直接雪崩 | 本地 DFA 微秒拦截 → 异步模型审核 → 先发后审 + 撤回指令 |
| 11 | 垃圾刷屏 | 恶意带节奏、机器刷屏 | 四层限流 + DFA/AC 自动机 + 异步 AI 复审,违规拦截率 99.9% |
| 12 | 历史弹幕回放 | 数据量大查询慢 | HBase "房间ID+时间戳" 行键 + 二级索引;ClickHouse 列存,回放加载 < 1s |
| 13 | Redis 大 Key | 房间在线集合巨大,读写阻塞 | 拆成多个小 Set 分段存储 + 网关本地维护子集 |
| 14 | 跨机房延迟 | 跨地域投递慢 | 就近接入 + 同机房优先消费 + 专线 Topic 复制,同机房延迟 < 100ms |
再说性能指标。面试和汇报时,能说出具体数字,可信度直接拉满:
| 指标 | 目标值 | 说明 |
|---|---|---|
| 弹幕端到端延迟 | 平均 < 150ms,99 分位 < 300ms | 从发送到别人看到 |
| 单机长连接数 | 10 万+(4 核 8G),内存 < 800M | Netty + 系统调优 |
| 单机消费推送 QPS | 5 万+ | 批量推送模式 |
| 削峰效果 | 削峰 80%,核心消息零丢失 | MQ + 批量聚合 |
| 消息丢失率 | < 0.1% | 极端峰值下可接受 |
| 下行流量 | 推拉结合降低 90%+ | 对比全量推送 |
| 存储成本 | 冷热分离降低 80% | Redis 只留热数据 |
| 集群扩展性 | 线性扩容 | 加机器就能扛量 |
十一、还能继续深挖的方向
如果你以为到这就结束了,其实还有几个值得深挖的坑,也是面试时体现技术判断力的好素材:
- 边缘节点推送:把接入网关下沉到 CDN 边缘,用户就近接入,中心网关压力骤降;
- QUIC 替代 WebSocket:弱网下比 TCP 表现更好,断线重连更快,适合移动直播场景;
- JDK 21 虚拟线程 :在消费端批量拉取这类场景有收益,但要注意
synchronized的 pinning 问题; - 弹幕智能分级:AI 识别高质量弹幕优先展示,水弹幕自动合并("666"刷屏合并成"×233");
- 回放弹幕对齐:按时间分片存储,回放时按播放进度精准加载;
- 前端轨道分配算法:服务端预分配轨道号,分层渲染避免弹幕重叠,降低前端计算量;
- 实时风控反作弊:内容相似度 + 用户行为特征,识别机器刷屏;
- Flink 实时热度统计:弹幕关键词统计、直播间实时热度值计算;
- 多机房多活:单元化部署 + 跨区域同步,设计"区域弹幕 + 全局精选"双层体验;
- 弹幕撤回、弹幕置顶等进阶玩法,本质都是"消息指令 + 客户端状态机"。
这些方向每一个都能单独写一篇,感兴趣的可以在评论区告诉我,点赞高的优先安排。
写在最后
回头看,弹幕系统的设计哲学其实就一句话:在实时性、可靠性、成本三者之间做 trade-off。
text
实时性
/\
/ \
/ \
完整性 ------ 吞吐量
直播弹幕场景选的是实时性 + 吞吐量,牺牲完整性------所以全链路都围绕"可丢弃"三个字做优化:环形缓冲满了就丢、慢客户端踢掉、极端房间采样。而 seq + 推拉补漏机制保证了"丢得起,也补得回"。
落地建议给三条:
- 先压测再上线,四层限流的阈值全部来自压测数据,拍脑袋的阈值都是定时炸弹;
- 降级预案写进发布检查单,演练过才算数;
- 监控先行:单机连接数、MQ 堆积、端到端延迟、丢弃率,这四个指标必须有大盘和告警。
一句话记忆:推拉结合保实时,异步批量抗洪峰,背压隔离护全局。
如果这篇拆解对你有帮助,点个关注不迷路,公众号后续还会持续拆解更多高并发经典场景(下一篇预告:秒杀系统)。
最后留个互动话题:如果你的直播间突然涌入 100 万在线,你第一步会先扩容哪一层?为什么? 评论区聊聊,我会逐条回复。觉得文章有用,点赞、在看、转发走一波,你的支持是我肝长文的最大动力。