讲一下项目中是如何实现websocket协同编辑功能的?
WebSocket协同编辑系统
├── 配置层 (WebSocketConfig)
├── 拦截器层 (WsHandshakeInterceptor)
├── 处理器层 (PictureEditHandler)
├── 消息模型层 (model包)
├── 高性能处理层 (disruptor包)
└── 业务逻辑层 (服务层集成)
消息处理流程:
前端发送消息 → PictureEditHandler.handleTextMessage
→ PictureEditEventProducer.publishEvent
→ Disruptor环形队列
→ PictureEditEventWorkHandler.onEvent
→ 分发到具体的处理方法
为什么选Disruptor而不是BlockingQueue?
1. 吞吐量差异
Disruptor的优势:
无锁设计,通过CAS操作避免线程竞争
环形缓冲区预分配内存,减少GC压力
CPU缓存友好,数据局部性更好
BlockingQueue的局限:
基于锁机制,高并发下线程阻塞严重
频繁的对象创建和销毁增加GC负担
内存分配不够连续,缓存命中率低
2. 延迟表现
让我们看实际的性能数据(基于LMAX的测试结果):
并发场景下的平均延迟对比:
--------------------------------------------------
并发数 | BlockingQueue | Disruptor | 性能提升
------|---------------|-----------|----------
1K | 1.2ms | 0.1ms | 12倍
10K | 15ms | 0.3ms | 50倍
100K | 150ms+ | 2ms | 75倍+
技术实现层面的优势
1. 内存预分配
java
// Disruptor预先分配固定大小的环形缓冲区
int bufferSize = 1024 * 256; // 256KB
Disruptor<PictureEditEvent> disruptor = new Disruptor<>(
PictureEditEvent::new, // 预先实例化事件对象
bufferSize,
threadFactory
);
vs BlockingQueue需要动态创建对象:
java
// 每个消息都需要new对象,增加GC压力
queue.offer(new PictureEditEvent(...));
2. 无锁并发处理
java
// Disruptor使用CAS操作,避免锁竞争
long next = ringBuffer.next(); // 无锁获取位置
ringBuffer.publish(next); // 无锁发布事件
// BlockingQueue需要加锁
queue.put(item); // 阻塞式put,涉及锁竞争
3. 批量处理能力
java
// Disruptor支持批量消费
disruptor.handleEventsWithWorkerPool(workHandler1, workHandler2, workHandler3);
// 可以并行处理多个事件,充分利用多核CPU
实际业务场景验证:
WebSocket消息处理的特点:
高并发:多个用户同时编辑同一图片
低延迟:编辑操作需要实时同步
顺序性:某些操作需要保持执行顺序
可靠性:不能丢失任何编辑事件
具体性能收益
Disruptor带来的实际收益:
内存效率提升
对象复用 :预创建256K个事件对象,避免频繁GC
内存连续 :环形缓冲区内存布局连续,提高缓存命中率
减少分配:无需为每个消息动态分配内存
CPU利用率优化
减少上下文切换 :无锁设计减少线程阻塞
更好的缓存局部性:数据在CPU缓存中停留更久
SIMD友好:连续内存布局利于现代CPU优化
延迟降低
java
// 典型的延迟对比(微秒级别)
操作类型 | BlockingQueue | Disruptor | 改善幅度
---------|---------------|-----------|--------
消息入队 | 800ns | 50ns | 16倍
消息出队 | 1200ns | 80ns | 15倍
端到端延迟 | 2500ns | 200ns | 12.5倍
对象复用怎么实现的?
Disruptor对象复用的核心原理
1. 预分配事件对象池
java
// 在PictureEditEventDisruptorConfig.java中的配置
@Bean("pictureEditEventDisruptor")
public Disruptor<PictureEditEvent> messageModelRingBuffer() {
int bufferSize = 1024 * 256; // 256KB缓冲区大小
Disruptor<PictureEditEvent> disruptor = new Disruptor<>(
PictureEditEvent::new, // 关键:预创建事件工厂
bufferSize,
ThreadFactoryBuilder.create().setNamePrefix("pictureEditEventDisruptor").build()
);
disruptor.handleEventsWithWorkerPool(pictureEditEventWorkHandler);
disruptor.start();
return disruptor;
}
2. 环形缓冲区的工作机制
让我画个图来说明:
java
环形缓冲区对象复用示意图:
[Event1][Event2][Event3][Event4][Event5]...[EventN] ← 固定大小数组
↓ ↓ ↓ ↓ ↓ ↓
预创建的Event对象,内存地址固定不变
生产者使用流程:
1. 获取可用位置:ringBuffer.next() → 位置3
2. 获取对应对象:ringBuffer.get(3) → Event3对象
3. 填充数据到Event3
4. 发布事件:ringBuffer.publish(3)
消费者处理流程:
1. 等待可消费事件
2. 获取Event3对象
3. 处理完后,Event3回到可用状态
4. 下次生产者可以重新使用Event3
GC压力对比
内存分配和回收情况:
java
传统BlockingQueue方式:
每秒处理10000个消息
→ 每秒创建10000个Event对象
→ 每秒触发多次Minor GC
→ 频繁的内存分配和回收
Disruptor方式:
总共预分配256K个Event对象
→ 运行期间零额外内存分配
→ 几乎无GC压力
→ 内存使用稳定
协同编辑如何解决操作冲突?(OT/CRDT)
在项目中的实现是使用 concurrenthashmap<pictureId,userId> , 以此实现了一种伪协同编辑(实际在同一时间片内只能有一个用户进行操作),这里进行一下扩展:
实时协同 OT 算法(Operational Transformation) 是一种支持分布式系统中多个用户实时协作编辑的核心算法,广泛应用于在线文档协作等场景。OT 算法的主要功能是解决并发编辑冲突,确保编辑结果在所有用户终端一致。
OT 算法其实很好理解,先看下 3 个核心概念:
- 操作 (Operation):表示用户对协作内容的修改,比如插入字符、删除字符等。
- 转化 (Transformation):当多个用户同时编辑内容时,OT 会根据操作的上下文将它们转化,使得这些操作可以按照不同的顺序应用而结果保持一致。
- 因果一致性:OT 算法确保操作按照用户看到的顺序被正确执行,即每个用户的操作基于最新的内容状态。
其中,最重要的就是 转化 步骤了,相当于有一个负责人统一收集大家的操作,然后按照设定的规则和信息进行排序与合并,最终给大家一个统一的结果。
举一个简单的例子,假设初始内容是 "abc",用户 A 和 B 同时进行编辑:
- 用户 A 在位置
1插入"x" - 用户 B 在位置
2删除"b"
如果不使用 OT 算法,结果是:
- 用户 A 操作后,内容变为
"axbc" - 用户 B 操作后,内容变为
"ac"
如果直接应用 B 的操作到 A 的结果,得到的是 "ac",对于 A 来说,相当于删除了 "b",A 会感到一脸懵逼。
如果使用 OT 算法,结果是:
- 用户 A 的操作,应用后内容为
"axbc" - 用户 B 的操作经过 OT 转化为删除
"b"在"axbc"中的新位置
最终用户 A 和 B 的内容都一致为 "axc",符合预期。OT 算法确保无论用户编辑的顺序如何,最终内容是一致的。
当然,具体的 OT 算法还是要根据需求来设计了,协作密度越高,算法设计难度越大。
此外,还有一种与 OT 类似的协同算法 CRDT(Conflict-free Replicated Data Type),其通过数学模型实现无需中心化转化的冲突解决,在离线协作场景中更具优势,感兴趣的同学可以自行了解。