用 Spring Boot + Redis 实现哔哩哔哩弹幕系统
支持:历史弹幕 + 实时弹幕 + 敏感词过滤 + 限频 + 持久化
🧩 项目功能总览
功能模块 |
技术实现 |
🎞 历史弹幕 |
Redis List 存储,按时间排序展示 |
📡 实时弹幕 |
WebSocket 双向通信 + 广播 |
🚫 敏感词过滤 |
Redis Set 管理敏感词,系统提醒用户 |
🚦 弹幕防刷限频 |
Redis 键限速,每人 2 秒 1 条 |
📦 持久化存储 |
Redis 弹幕每 30 秒批量写入 MySQL |
🧑💼 管理接口 |
敏感词添加/删除/查看 REST 接口 |
🧱 技术栈
层级 |
技术 |
说明 |
后端 |
Spring Boot |
主体开发框架 |
通信 |
WebSocket |
实时弹幕传输 |
缓存 |
Redis |
弹幕缓存、限频控制 |
数据库 |
MySQL |
弹幕历史存储 |
前端 |
HTML + JS |
视频播放 + 弹幕显示 |
🗃️ 弹幕数据模型(MySQL)
sql
复制代码
CREATE TABLE danmu (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
video_id BIGINT NOT NULL,
user_id VARCHAR(50),
text VARCHAR(255),
time_in_video DOUBLE,
send_time DATETIME
);
☁️ Redis 数据结构设计
Key |
类型 |
示例值 |
danmu:video:{videoId} |
List |
弹幕 JSON,按时间顺序 |
filter:words |
Set |
管理敏感词 |
limit:user:{userId} |
String |
限制用户发送频率 |
☁️ Redis 存弹幕(实时 + 历史)
- 弹幕按
timeInVideo
入 Redis List
- 前端加载 Redis 弹幕,根据视频播放进度展示
- 每隔 30 秒自动将 Redis 弹幕落库并清除缓存
🔐 敏感词过滤系统(服务 + 接口)
🔧 Redis Filter Service
java
复制代码
@Service
public class DanmuFilterService {
@Autowired RedisTemplate<String, String> redis;
public boolean containsForbidden(String text) {
Set<String> words = redis.opsForSet().members("filter:words");
return words != null && words.stream().anyMatch(text::contains);
}
}
🔧 管理接口
java
复制代码
@RestController
@RequestMapping("/api/filters")
public class FilterController {
@Autowired RedisTemplate<String, String> redis;
@PostMapping("/add")
public String add(@RequestParam String word) {
redis.opsForSet().add("filter:words", word);
return "添加成功";
}
@PostMapping("/remove")
public String remove(@RequestParam String word) {
redis.opsForSet().remove("filter:words", word);
return "删除成功";
}
@GetMapping("/list")
public Set<String> list() {
return redis.opsForSet().members("filter:words");
}
}
🚦 弹幕限频控制
👮 Redis 限流器
java
复制代码
@Service
public class DanmuRateLimitService {
@Autowired RedisTemplate<String, String> redis;
public boolean isTooFast(String userId) {
String key = "limit:user:" + userId;
if (redis.hasKey(key)) return true;
redis.opsForValue().set(key, "1", Duration.ofSeconds(2));
return false;
}
}
🔄 定时将弹幕持久化到 MySQL
java
复制代码
@Component
public class DanmuBackupTask {
@Autowired RedisTemplate<String, String> redis;
@Autowired DanmuRepository danmuRepo;
Gson gson = new Gson();
@Scheduled(fixedRate = 30000) // 每 30 秒
public void flushToDb() {
Set<String> keys = redis.keys("danmu:video:*");
if (keys == null) return;
for (String key : keys) {
List<String> list = redis.opsForList().range(key, 0, -1);
if (list == null || list.isEmpty()) continue;
List<Danmu> danmus = list.stream().map(j -> gson.fromJson(j, Danmu.class)).toList();
danmuRepo.saveAll(danmus);
redis.delete(key); // 清空 Redis
}
}
}
📡 WebSocket 处理器(敏感词 + 限频 + 广播)
java
复制代码
@ServerEndpoint("/ws/danmu/{videoId}/{userId}")
@Component
public class DanmuWebSocket {
private static final Map<String, Session> sessions = new ConcurrentHashMap<>();
private static DanmuFilterService filterService;
private static DanmuRateLimitService rateLimitService;
private static RedisTemplate<String, String> redis;
@Autowired
public void setDeps(DanmuFilterService f, DanmuRateLimitService r, RedisTemplate<String, String> rt) {
filterService = f;
rateLimitService = r;
redis = rt;
}
@OnOpen
public void onOpen(Session session) {
sessions.put(session.getId(), session);
}
@OnMessage
public void onMessage(String msgJson, Session session,
@PathParam("videoId") String videoId,
@PathParam("userId") String userId) {
Danmu danmu = new Gson().fromJson(msgJson, Danmu.class);
danmu.setUserId(userId);
danmu.setSendTime(LocalDateTime.now());
// 限频
if (rateLimitService.isTooFast(userId)) {
sendTo(session, "[系统通知] 请勿频繁发送弹幕!");
return;
}
// 敏感词
if (filterService.containsForbidden(danmu.getText())) {
sendTo(session, "[系统通知] 弹幕含违禁词,已屏蔽!");
return;
}
// 存 Redis
redis.opsForList().rightPush("danmu:video:" + videoId, new Gson().toJson(danmu));
// 广播
sessions.values().forEach(s -> sendTo(s, new Gson().toJson(danmu)));
}
private void sendTo(Session session, String msg) {
try { session.getBasicRemote().sendText(msg); } catch (Exception e) {}
}
@OnClose
public void onClose(Session session) {
sessions.remove(session.getId());
}
}
💻 前端弹幕逻辑(伪代码)
js
复制代码
// 加载历史弹幕
fetch("/api/danmu/history?videoId=123")
.then(res => res.json())
.then(data => {
danmus = data.sort((a, b) => a.time - b.time);
});
setInterval(() => {
const currentTime = video.currentTime;
while (danmus.length && danmus[0].time <= currentTime) {
showDanmu(danmus.shift().text);
}
}, 200);
// 连接 WebSocket
const ws = new WebSocket("ws://localhost:8080/ws/danmu/123/userA");
ws.onmessage = e => showDanmu(JSON.parse(e.data).text);
// 发送弹幕
function sendDanmu(text) {
ws.send(JSON.stringify({ text, time: video.currentTime }));
}
✅ 最终效果
功能 |
效果 |
实时弹幕 |
多用户同步,实时显示 |
历史弹幕 |
视频播放自动同步 |
敏感词拦截 |
系统通知+拦截广播 |
防刷控制 |
每 2 秒最多 1 条 |
持久化保障 |
弹幕定时入库 |
🧪 当前系统存在的缺点分析
分类 |
问题描述 |
影响 |
改进建议 |
🏗 架构 |
WebSocket 逻辑中 Redis 和 Spring Bean 注入依赖手动静态赋值 |
不规范,难维护,容易出错 |
使用 @Component + @ServerEndpointExporter 或 Spring WebSocket(STOMP)替代 |
💾 数据存储 |
Redis 弹幕写入后一次性 flush 到 MySQL,每次清空缓存 |
如果任务挂掉,数据可能丢失 |
采用 MQ(如 Kafka)异步写库,或采用 AOF 持久化增强安全性 |
🧍♂️ 用户控制 |
弹幕限频基于 Redis 键,粒度较粗(用户级 2 秒) |
不能支持每用户每视频限频、动态限速 |
改为 Lua 脚本实现限流(滑动窗口或令牌桶)更精准 |
🔎 敏感词检测 |
整体为"包含"检测,容易误伤、无法处理变形词 |
用户体验下降 + 容易绕过 |
支持正则、Trie 树、拼音转写等模糊检测方案 |
📋 管理后台 |
敏感词接口无权限保护,任意人可添加/删除 |
高危漏洞 |
使用 Spring Security + 登录鉴权系统 |
📈 弹幕密度 |
当前只支持"每秒多条弹幕"的简单展示方式 |
弹幕重叠、遮挡,影响观看 |
加入轨道(轨迹)管理:每条弹幕分配不重复轨道并添加动画队列 |
📺 前端展示 |
弹幕展示样式较简单,没有封装动画、颜色、字体大小 |
不够炫酷,体验不如 B 站 |
使用 canvas 或独立 JS 弹幕引擎如 danmaku.js |
📶 多节点支持 |
当前广播使用内存 Map 保存所有 Session |
无法扩展多实例部署 |
引入消息中间件(如 Redis Pub/Sub、Kafka)实现弹幕广播中转 |
💬 消息格式 |
弹幕是纯文本,缺乏弹幕类型(滚动/顶端/底端)、颜色等字段 |
无法实现个性化弹幕样式 |
扩展弹幕数据结构支持样式字段:如 { text, type, color, fontSize } |
✅ 总结建议
优化方向 |
推荐技术 |
高可用架构 |
Spring WebSocket + Redis Pub/Sub + Kafka |
数据安全 |
Redis AOF + MQ 异步写库 |
用户限频 |
Redis Lua 限流脚本(滑动窗口算法) |
敏感词检测 |
DFA + 正则匹配 + 后台管理审查 |
前端动画 |
使用弹幕引擎库,如 danmaku.js / canvas 实现 |
安全控制 |
Spring Security + RBAC 管理员角色 |