目录
[WebSocket 处理器](#WebSocket 处理器)
[WebSocket 配置](#WebSocket 配置)
[扩展知识 - Disruptor 优化](#扩展知识 - Disruptor 优化)
[Disruptor 介绍](#Disruptor 介绍)
[Disruptor 实战](#Disruptor 实战)
本部分内容主要来源于鱼皮智能协图云图库部分,并在笔者个人项目学习的基础上进行扩展衍生。由于项目开发文档已经足够详细,因此这里只记录要点。
方案设计
协同编辑的核心是要求实时与防止协作冲突。
实时性:
选取websocket实现。
websocket的特点:全双工,持续连接,基于http之上。
实现流程


websocket的实现方式
| 实现方式 | 特点 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|---|
| 原生 WebSocket | 低层 API,手动管理连接与消息 | 轻量、灵活、适用于简单点对点通信 | 需要手动管理会话和分发,不支持 STOMP | 简单的实时推送,低并发场景 |
| WebSocket + STOMP + SockJS | 基于 STOMP,支持发布 / 订阅模式 | 支持 STOMP、消息代理、适配 SockJS | 依赖外部代理,配置较复杂 | 聊天室、多人协作,高级实时应用 |
| WebFlux + Reactive WebSocket | 基于 WebFlux 的响应式实现 | 高并发、非阻塞、适用于大流量场景 | 学习曲线高,不支持 STOMP | 高并发场景、大数据流推送 |
这里使用第一种,并且由于有Spring WebSocket框架的封装,因此没有直接endpoint类,与这里的笔记不同。
避免协作冲突
最直接的方法是上锁,只允许一名用户编辑图片。
扩展思路:实时协同 OT 算法
通过对用户操作上下文的转化确保因果一致性。
实时协同 OT 算法(Operational Transformation)是一种支持分布式系统中多个用户实时协作编辑的核心算法,广泛应用于在线文档协作等场景。OT 算法的主要功能是解决并发编辑冲突,确保编辑结果在所有用户终端一致。
OT 算法其实很好理解,先看下 3 个核心概念:
-
操作 (Operation):表示用户对协作内容的修改,比如插入字符、删除字符等。
-
转化 (Transformation):当多个用户同时编辑内容时,OT 会根据操作的上下文将它们转化,使得这些操作可以按照不同的顺序应用而结果保持一致。
-
因果一致性:OT 算法确保操作按照用户看到的顺序被正确执行,即每个用户的操作基于最新的内容状态。
用户状态设置
| 事件触发者(用户 A 的动作) | 事件类型(发送消息) | 事件消费者(其他用户的处理) |
|---|---|---|
| 用户 A 建立连接,加入编辑 | INFO | 显示 "用户 A 加入编辑" 的通知 |
| 用户 A 进入编辑状态 | ENTER_EDIT | 其他用户界面显示 "用户 A 开始编辑图片",锁定编辑状态 |
| 用户 A 执行编辑操作 | EDIT_ACTION | 放大 / 缩小 / 左旋 / 右旋当前图片 |
| 用户 A 退出编辑状态 | EXIT_EDIT | 解锁编辑状态,提示其他用户可以进入编辑状态 |
| 用户 A 断开连接,离开编辑 | INFO | 显示 "用户 A 离开编辑" 的通知,并释放编辑状态 |
| 用户 A 发送了错误的消息 | ERROR | 显示错误消息的通知 |
后端开发
开发流程:引入依赖-定义数据模型-websocket拦截器-websocket处理器-websocket配置
1.websocket拦截器
java
此外,由于 HTTP 和 WebSocket 的区别,我们不能在后续收到前端消息时直接从 request 对象中获取到登录用户信息,因此也需要通过 WebSocket 拦截器,为即将建立连接的 WebSocket 会话指定一些属性,比如登录用户信息、编辑的图片 id 等。
编写拦截器的代码,需要实现 HandshakeInterceptor 接口:
@Component
@Slf4j
public class WsHandshakeInterceptor implements HandshakeInterceptor {
@Resource
private UserService userService;
@Resource
private PictureService pictureService;
@Resource
private SpaceService spaceService;
@Resource
private SpaceUserAuthManager spaceUserAuthManager;
@Override
public boolean beforeHandshake(@NotNull ServerHttpRequest request, @NotNull ServerHttpResponse response, @NotNull WebSocketHandler wsHandler, @NotNull Map<String, Object> attributes) {
if (request instanceof ServletServerHttpRequest) {
HttpServletRequest servletRequest = ((ServletServerHttpRequest) request).getServletRequest();
String pictureId = servletRequest.getParameter("pictureId");
if (StrUtil.isBlank(pictureId)) {
log.error("缺少图片参数,拒绝握手");
return false;
}
User loginUser = userService.getLoginUser(servletRequest);
if (ObjUtil.isEmpty(loginUser)) {
log.error("用户未登录,拒绝握手");
return false;
}
Picture picture = pictureService.getById(pictureId);
if (picture == null) {
log.error("图片不存在,拒绝握手");
return false;
}
Long spaceId = picture.getSpaceId();
Space space = null;
if (spaceId != null) {
space = spaceService.getById(spaceId);
if (space == null) {
log.error("空间不存在,拒绝握手");
return false;
}
if (space.getSpaceType() != SpaceTypeEnum.TEAM.getValue()) {
log.info("不是团队空间,拒绝握手");
return false;
}
}
List<String> permissionList = spaceUserAuthManager.getPermissionList(space, loginUser);
if (!permissionList.contains(SpaceUserPermissionConstant.PICTURE_EDIT)) {
log.error("没有图片编辑权限,拒绝握手");
return false;
}
attributes.put("user", loginUser);
attributes.put("userId", loginUser.getId());
attributes.put("pictureId", Long.valueOf(pictureId));
}
return true;
}
@Override
public void afterHandshake(@NotNull ServerHttpRequest request, @NotNull ServerHttpResponse response, @NotNull WebSocketHandler wsHandler, Exception exception) {
}
}
要点:
1.websocket是长连接,握手后没有httpservletrequest对象,因此需要把关键信息存入会话中。
attributes.put()
2.通过解析原生的HttpServletRequest进行合法性校验
WebSocket 处理器
java
@Component //文本消息专用处理器
public class PictureEditHandler extends TextWebSocketHandler {
@Resource
private UserService userService;
@Resource
private PictureEditEventProducer pictureEditEventProducer;
private final Map<Long, Long> pictureEditingUsers = new ConcurrentHashMap<>();
private final Map<Long, Set<WebSocketSession>> pictureSessions = new ConcurrentHashMap<>();
@Override//当前会话关闭
public void afterConnectionClosed(WebSocketSession session, @NotNull CloseStatus status) throws Exception {
Map<String, Object> attributes = session.getAttributes();
Long pictureId = (Long) attributes.get("pictureId");
User user = (User) attributes.get("user");
handleExitEditMessage(null, session, user, pictureId);
//去除已关闭的会话
Set<WebSocketSession> sessionSet = pictureSessions.get(pictureId);
if (sessionSet != null) {
sessionSet.remove(session);
if (sessionSet.isEmpty()) {
pictureSessions.remove(pictureId);
}
}
//广播
PictureEditResponseMessage pictureEditResponseMessage = new PictureEditResponseMessage();
pictureEditResponseMessage.setType(PictureEditMessageTypeEnum.INFO.getValue());
String message = String.format("%s离开编辑", user.getUserName());
pictureEditResponseMessage.setMessage(message);
pictureEditResponseMessage.setUser(userService.getUserVO(user));
broadcastToPicture(pictureId, pictureEditResponseMessage);
}
@Override
protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception {
PictureEditRequestMessage pictureEditRequestMessage = JSONUtil.toBean(message.getPayload(), PictureEditRequestMessage.class);
Map<String, Object> attributes = session.getAttributes();
User user = (User) attributes.get("user");
Long pictureId = (Long) attributes.get("pictureId");
pictureEditEventProducer.publishEvent(pictureEditRequestMessage, session, user, pictureId);
}
@Override//当前会话建立连接
public void afterConnectionEstablished(WebSocketSession session) throws Exception {
User user = (User) session.getAttributes().get("user");
Long pictureId = (Long) session.getAttributes().get("pictureId");
pictureSessions.putIfAbsent(pictureId, ConcurrentHashMap.newKeySet());
pictureSessions.get(pictureId).add(session);
PictureEditResponseMessage pictureEditResponseMessage = new PictureEditResponseMessage();
pictureEditResponseMessage.setType(PictureEditMessageTypeEnum.INFO.getValue());
String message = String.format("%s加入编辑", user.getUserName());
pictureEditResponseMessage.setMessage(message);
pictureEditResponseMessage.setUser(userService.getUserVO(user));
broadcastToPicture(pictureId, pictureEditResponseMessage);
}
private void broadcastToPicture(Long pictureId, PictureEditResponseMessage pictureEditResponseMessage, WebSocketSession excludeSession) throws Exception {
Set<WebSocketSession> sessionSet = pictureSessions.get(pictureId);
if (CollUtil.isNotEmpty(sessionSet)) {
//Jackson 库的核心类,用于将 Java 对象和 JSON 字符串之间相互转换(序列化 / 反序列化)
ObjectMapper objectMapper = new ObjectMapper();
//Jackson 的自定义扩展模块,用于注册自定义的序列化器、反序列化器,修改 Jackson 的默认序列化行为。
SimpleModule module = new SimpleModule();
//为 ** 包装类型Long** 注册序列化器,序列化时将Long对象转换为字符串类型。
module.addSerializer(Long.class, ToStringSerializer.instance);
//基本数据类型long(Long.TYPE等价于long)注册序列化器,
module.addSerializer(Long.TYPE, ToStringSerializer.instance);
//将自定义模块注册到ObjectMapper中,使上述 Long 类型序列化配置生效。
objectMapper.registerModule(module);
//将Java对象pictureEditResponseMessage转换为JSON字符串
String message = objectMapper.writeValueAsString(pictureEditResponseMessage);
TextMessage textMessage = new TextMessage(message);
for (WebSocketSession session : sessionSet) {
if (excludeSession != null && excludeSession.equals(session)) {
continue;
}
//判断session是否打开
if (session.isOpen()) {
//传播消息
session.sendMessage(textMessage);
}
}
}
}
private void broadcastToPicture(Long pictureId, PictureEditResponseMessage pictureEditResponseMessage) throws Exception {
broadcastToPicture(pictureId, pictureEditResponseMessage, null);
}
//进入编辑状态
public void handleEnterEditMessage( PictureEditRequestMessage pictureEditRequestMessage, WebSocketSession session,User user, Long pictureId) throws Exception {
//排他性校验
if (!pictureEditingUsers.containsKey(pictureId)) {
//进入编辑状态
pictureEditingUsers.put(pictureId, user.getId());
//广播消息
PictureEditResponseMessage pictureEditResponseMessage = new PictureEditResponseMessage();
pictureEditResponseMessage.setType(PictureEditMessageTypeEnum.ENTER_EDIT.getValue());
String message = String.format("%s开始编辑图片", user.getUserName());
pictureEditResponseMessage.setMessage(message);
pictureEditResponseMessage.setUser(userService.getUserVO(user));
broadcastToPicture(pictureId, pictureEditResponseMessage);
}
}
//执行编辑操作
public void handleEditActionMessage(PictureEditRequestMessage pictureEditRequestMessage, WebSocketSession session, User user, Long pictureId) throws Exception {
//校验当前用户是否是该图片的编辑用户
Long editingUserId = pictureEditingUsers.get(pictureId);
String editAction = pictureEditRequestMessage.getEditAction();
PictureEditActionEnum actionEnum = PictureEditActionEnum.getEnumByValue(editAction);
if (actionEnum == null) {
return;
}
if (editingUserId != null && editingUserId.equals(user.getId())) {
//广播消息
PictureEditResponseMessage pictureEditResponseMessage = new PictureEditResponseMessage();
pictureEditResponseMessage.setType(PictureEditMessageTypeEnum.EDIT_ACTION.getValue());
String message = String.format("%s执行%s", user.getUserName(), actionEnum.getText());
pictureEditResponseMessage.setMessage(message);
pictureEditResponseMessage.setEditAction(editAction);
pictureEditResponseMessage.setUser(userService.getUserVO(user));
broadcastToPicture(pictureId, pictureEditResponseMessage, session);
}
}
public void handleExitEditMessage(PictureEditRequestMessage pictureEditRequestMessage, WebSocketSession session, User user, Long pictureId) throws Exception {
//校验
Long editingUserId = pictureEditingUsers.get(pictureId);
if (editingUserId != null && editingUserId.equals(user.getId())) {
//移除
pictureEditingUsers.remove(pictureId);
//广播消息
PictureEditResponseMessage pictureEditResponseMessage = new PictureEditResponseMessage();
pictureEditResponseMessage.setType(PictureEditMessageTypeEnum.EXIT_EDIT.getValue());
String message = String.format("%s退出编辑图片", user.getUserName());
pictureEditResponseMessage.setMessage(message);
pictureEditResponseMessage.setUser(userService.getUserVO(user));
broadcastToPicture(pictureId, pictureEditResponseMessage);
}
}
要点:
1.广播消息到其它用户session.sendMessage()。
2.通过jackson的核心类ObjectMapper解决长整型返回前端精度丢失问题。本质是自定义序列化规则把java对象转换为json字符串来规避精度丢失
3.校验合法性-编写广播信息,调用广播方法
4.采用了事件驱动设计思路
WebSocket 配置
类似于controller层,为指定的路径配置处理器和拦截器
java
@Configuration
@EnableWebSocket
public class WebSocketConfig implements WebSocketConfigurer {
@Resource
private PictureEditHandler pictureEditHandler;
@Resource
private WsHandshakeInterceptor wsHandshakeInterceptor;
@Override
public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
registry.addHandler(pictureEditHandler, "/ws/picture/edit")
.addInterceptors(wsHandshakeInterceptor)
.setAllowedOrigins("*");
}
}
要点:
1开启 Spring WebSocket 功能,启用 WebSocket 相关的自动配置和组件扫描。@EnableWebSocket
WebSocket 握手拦截器(HandshakeInterceptor):仅拦截「WebSocket 连接建立前的握手请求」(ws:// 协议),生效路径是 WebSocket 端点(如/ws/picture/edit),职责是校验 WebSocket 连接的合法性、存储会话属性。
MVC 请求拦截器(HandlerInterceptor):仅拦截「HTTP/HTTPS 请求」(http:///https:// 协议),生效路径是 MVC 接口(如/api/picture/list),职责是拦截 HTTP 请求、统一处理登录校验、日志记录、参数预处理等。
扩展知识 - Disruptor 优化

为避免处理消息过程耗时太长,并发过高时导致线程耗光,采用开一个线程专门来异步处理消息,并使用队列保证消息顺序执行。消息的处理是在该 WebSocketSession 所属的线程中执行。
Disruptor 介绍
Disruptor 是一种高性能的并发框架,由 LMAX(一个金融交易系统公司)开发,它是一种 无锁的环形队列 数据结构,用于解决高吞吐量和低延迟场景中的并发问题。支持生产者 - 消费者模式,可作为消息队列使用,适用于金融交易、实时数据处理、游戏事件等对并发和实时性要求较高的场景。
Disruptor 的核心思想是基于固定大小的 环形缓冲区(Ring Buffer),并通过序列化控制访问,以避免传统队列中常见的锁竞争问题。
它主要通过以下几点实现高性能的消息传递机制:
-
环形缓冲区:使用固定大小的数组,可以复用内存,避免了频繁的内存分配和垃圾回收。
-
无锁设计:依赖 CAS(Compare-And-Swap)和内存屏障,而不是传统的锁,降低了线程切换的开销。
-
缓存友好:最大化利用 CPU 的缓存局部性,提高访问速度。
-
序列号机制:通过序列号管理生产者和消费者的访问,保证数据一致性。
-
多消费者模式:支持多消费者共享同一环形缓冲区,并能配置不同的消费策略(如依赖关系、并行消费等)。
| 特性 | Disruptor | BlockingQueue |
|---|---|---|
| 并发控制 | 无锁(CAS + 内存屏障) | 基于锁(ReentrantLock) |
| 内存管理 | 固定长度的环形数组 | 动态数组或链表 |
| 性能 | 极高(百万级别消息 / 秒) | 较低(数万消息 / 秒) |
| 延迟 | 纳秒级别 | 毫秒级别 |
| GC 压力 | 极低(数据复用) | 较高(频繁创建新对象) |
| 适用场景 | 高频实时消息处理、金融系统 | 一般生产者消费者模型 |
Disruptor 的核心概念:
-
RingBuffer(环形缓冲区):固定大小的循环数组,用于存储数据项,生产者和消费者共享该数据结构。
-
Event(事件):存储在
RingBuffer中的数据对象,用于表示要传递的消息或数据。 -
Producer(生产者):负责向
RingBuffer写入数据的角色。 -
Consumer(消费者):从
RingBuffer中读取并处理数据的角色。 -
Sequencer(序列器):管理生产者与消费者的索引,确保并发安全的序列管理。
-
SequenceBarrier(序列屏障):控制消费者等待数据可用的机制,确保数据完整性。
-
WaitStrategy(等待策略):定义消费者如何等待新的数据(如自旋、自适应等待等)。
-
EventProcessor(事件处理器):集成了
Consumer和SequenceBarrier,用于更高级的消费控制。
Disruptor 的工作流程:
-
环形队列初始化:创建一个固定大小为 8 的 RingBuffer(索引范围 0-7),每个格子存储一个可复用的事件对象,序号初始为 0。
-
生产者写入数据:生产者申请索引 0(序号 0),将数据 "A" 写入事件对象,提交后序号递增为 1,下一个写入索引变为 1。
-
消费者读取数据:消费者检查索引 0(序号 0),读取数据 "A",处理后提交,序号递增为 1,下一个读取索引变为 1。
-
环形队列循环使用:当生产者写入到索引 7(序号 7)后,索引回到 0(序号 8),形成循环存储,但序号会持续自增以区分数据的先后顺序。
-
防止数据覆盖:如果生产者追上消费者,消费者尚未处理完数据,生产者会等待,确保数据不被覆盖。

这里和redis的同步机制有点类似。
Disruptor 实战
引入依赖-Disruptor 配置类-定义事件-消费者-生产者
这里在Disruptor配置中设置线程池。
解决同步处理耗尽线程的关键:
1.增加线程数量
2.专门化线程分工,一个线程专门处理核心业务,一个线程用于接收请求
