【协同编辑|第十二天】通过WebSocket,Disruptor 无锁队列实现协同编辑

目录

方案设计

实时性:

避免协作冲突

用户状态设置

后端开发

1.websocket拦截器

[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),并通过序列化控制访问,以避免传统队列中常见的锁竞争问题。

它主要通过以下几点实现高性能的消息传递机制:

  1. 环形缓冲区:使用固定大小的数组,可以复用内存,避免了频繁的内存分配和垃圾回收。

  2. 无锁设计:依赖 CAS(Compare-And-Swap)和内存屏障,而不是传统的锁,降低了线程切换的开销。

  3. 缓存友好:最大化利用 CPU 的缓存局部性,提高访问速度。

  4. 序列号机制:通过序列号管理生产者和消费者的访问,保证数据一致性。

  5. 多消费者模式:支持多消费者共享同一环形缓冲区,并能配置不同的消费策略(如依赖关系、并行消费等)。

特性 Disruptor BlockingQueue
并发控制 无锁(CAS + 内存屏障) 基于锁(ReentrantLock)
内存管理 固定长度的环形数组 动态数组或链表
性能 极高(百万级别消息 / 秒) 较低(数万消息 / 秒)
延迟 纳秒级别 毫秒级别
GC 压力 极低(数据复用) 较高(频繁创建新对象)
适用场景 高频实时消息处理、金融系统 一般生产者消费者模型

Disruptor 的核心概念:

  • RingBuffer(环形缓冲区):固定大小的循环数组,用于存储数据项,生产者和消费者共享该数据结构。

  • Event(事件):存储在 RingBuffer 中的数据对象,用于表示要传递的消息或数据。

  • Producer(生产者):负责向 RingBuffer 写入数据的角色。

  • Consumer(消费者):从 RingBuffer 中读取并处理数据的角色。

  • Sequencer(序列器):管理生产者与消费者的索引,确保并发安全的序列管理。

  • SequenceBarrier(序列屏障):控制消费者等待数据可用的机制,确保数据完整性。

  • WaitStrategy(等待策略):定义消费者如何等待新的数据(如自旋、自适应等待等)。

  • EventProcessor(事件处理器):集成了 ConsumerSequenceBarrier,用于更高级的消费控制。

Disruptor 的工作流程:

  1. 环形队列初始化:创建一个固定大小为 8 的 RingBuffer(索引范围 0-7),每个格子存储一个可复用的事件对象,序号初始为 0。

  2. 生产者写入数据:生产者申请索引 0(序号 0),将数据 "A" 写入事件对象,提交后序号递增为 1,下一个写入索引变为 1。

  3. 消费者读取数据:消费者检查索引 0(序号 0),读取数据 "A",处理后提交,序号递增为 1,下一个读取索引变为 1。

  4. 环形队列循环使用:当生产者写入到索引 7(序号 7)后,索引回到 0(序号 8),形成循环存储,但序号会持续自增以区分数据的先后顺序。

  5. 防止数据覆盖:如果生产者追上消费者,消费者尚未处理完数据,生产者会等待,确保数据不被覆盖。

这里和redis的同步机制有点类似。

Disruptor 实战

引入依赖-Disruptor 配置类-定义事件-消费者-生产者

这里在Disruptor配置中设置线程池。

解决同步处理耗尽线程的关键:

1.增加线程数量

2.专门化线程分工,一个线程专门处理核心业务,一个线程用于接收请求

相关推荐
有味道的男人2 小时前
接入MIC(中国制造)接口的帮助
网络·数据库·制造
2501_941652772 小时前
高速公路车辆检测与识别——基于YOLOv8与RFPN网络的智能监控系统_3
网络·yolo
智算菩萨2 小时前
【网络工程师入门】网络技术全解析:从家庭组网到DNS域名系统的实践指南
网络·系统架构
新时代牛马2 小时前
CANopenNode 接口及 CANopenLinux 完整实现
网络·学习
云小逸2 小时前
【Nmap 设备类型识别技术】从nmap_main函数穿透核心执行链路
网络协议·安全·web安全
一起养小猫2 小时前
Flutter for OpenHarmony 进阶:Socket通信与网络编程深度解析
网络·flutter·harmonyos
Code小翊2 小时前
re标准库模块一天学完
运维·服务器·网络
2601_949146532 小时前
HTTPS语音通知接口安全对接指南:基于HTTPS协议的语音API调用与加密传输规范
网络协议·安全·https
Psycho_MrZhang2 小时前
Claude高质量产出
java·服务器·网络