基于Netty构建WebSocket服务并实现项目群组聊天和实时消息通知推送

文章目录

  • 前言
    • 需求分析
    • 技术预研
      • Web端方案
      • 服务端技术
  • 技术方案
    • 设计思路
    • 功能实现
      • 添加依赖
      • 自定义NettyServer
      • 自定义webSocketHandler
      • 使用NettyServer向在线用户发送消息
  • 需要完善的地方

前言

我们的项目有个基于项目的在线文档编制模块,可以邀请多人项目组成员在线协同编制项目文档,现在的需求是要实现项目组成员在线实时协作沟通交流功能以及消息实时推送功能。

需求分析

根据需求分析,首先我们要基于项目组成员构建在线聊天群组并支持在线聊天,同时成员在线时支持实时推送消息。
在这里插入图片描述

技术预研

Web端方案

实现Web消息实时推送的方案比较多,包括轮询、长轮询、SSE、AJAX、WebSocket等。根据对比我们最终选择使用WebSocket来实现Web消息实时推送。

  • WebSocket: WebSocket是一种在单个TCP连接上进行全双工通信的协议。WebSocket通信协议于2011年被IETF定为标准RFC 6455,并由RFC7936补充规范。WebSocket API也被W3C定为标准。
    WebSocket使得客户端和服务器之间的数据交换变得更加简单,允许服务端主动向客户端推送数据。在WebSocket API中,浏览器和服务器只需要完成一次握手,两者之间就直接可以创建持久性的连接,并进行双向数据传输。

服务端技术

  • spring-boot-starter-websocket
    SpringBoot框架提供了WebSockets自动配置,通过spring-boot-starter-websocket模块轻松访问。
 <!-- 引入 WebSocket 模块依赖 --><dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-websocket</artifactId></dependency>// 创建一个配置类来配置 WebSocket 服务器
@Configuration
@EnableWebSocket
public class WebSocketConfig implements WebSocketConfigurer {@Overridepublic void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {registry.addHandler(new MyWebSocketHandler(), "/ws").setAllowedOrigins("*");}
}
// 自定义消息处理
public class MyWebSocketHandler extends TextWebSocketHandler {@Overridepublic void afterConnectionEstablished(WebSocketSession session) throws Exception {log.debug("Connection established: " + session.getId());}@Overrideprotected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception {String payload = message.getPayload();log.debug("Received message: " + payload);// 回复消息session.sendMessage(new TextMessage("Echo: " + payload));}@Overridepublic void afterConnectionClosed(WebSocketSession session, CloseStatus status) throws Exception {log.debug("Connection closed: " + session.getId());}
}

如果不考虑吞吐和并发,spring-boot-starter-websocket非常适合构建WebSocket Server端。

  • Netty
    Netty 是一个基于NIO的客户、服务器端的编程框架,使用Netty 可以确保你快速和简单的开发出一个网络应用,例如实现了某种协议的客户、服务端应用。Netty相当于简化和流线化了网络应用的编程开发过程,例如:基于TCP和UDP的socket服务开发。

技术方案

设计思路

在这里插入图片描述

  1. 自定义NettyServer 基于项目构建用户群组
  2. 用户在指定群组发送消息,NettyServer向群组所有用户推送消息
  3. 业务系统向指定用户发送通知消息到kafka
  4. 消费者消费消息通过暴露出的NettyServerHanlder向所有在线用户实时推送消息

功能实现

添加依赖

<dependency><groupId>io.netty</groupId><artifactId>netty-all</artifactId><version>4.1.112.Final</version></dependency><dependency><groupId>org.springframework.kafka</groupId><artifactId>spring-kafka</artifactId></dependency>

自定义NettyServer

因为我们需要暴露NettyServer 的webSocketHandler,所以将NettyServer实例交由Spring管理,并暴露广播消息和系统消息接口

@Slf4j
@Component
public class CusNettyServer implements InitializingBean, DisposableBean {@Value("${netty.port:9000}")Integer nettyPort;EventLoopGroup bossGroup = new NioEventLoopGroup(1); // 通常只需要一个线程即可EventLoopGroup workerGroup = new NioEventLoopGroup(); // 根据实际情况调整线程数 默认创建与 CPU 核心数相等的线程数private ChannelFuture channelFuture;private NettyWebSocketHandler webSocketHandler;@Overridepublic void destroy() throws Exception {if (channelFuture != null && channelFuture.channel().isOpen()) {channelFuture.channel().closeFuture().sync();}bossGroup.shutdownGracefully();workerGroup.shutdownGracefully();}@Overridepublic void afterPropertiesSet() throws Exception {ServerBootstrap sb = new ServerBootstrap();sb.option(ChannelOption.SO_BACKLOG, 128); // 考虑调整这个值sb.option(ChannelOption.SO_REUSEADDR, true); // 避免地址重用问题sb.childOption(ChannelOption.TCP_NODELAY, true); // 减少延迟sb.childOption(ChannelOption.SO_KEEPALIVE, true); // 保持连接webSocketHandler = new NettyWebSocketHandler();// 绑定线程池sb.group(bossGroup, workerGroup)// 指定使用的channel.channel(NioServerSocketChannel.class)// 绑定监听端口.localAddress(this.nettyPort)// 绑定客户端连接时候触发操作.childHandler(new ChannelInitializer<SocketChannel>() {@Overrideprotected void initChannel(SocketChannel ch) throws Exception {log.debug("收到新连接: {}", ch.remoteAddress());//websocket协议本身是基于http协议的,所以这边也要使用http解编码器ch.pipeline().addLast(new HttpServerCodec());//以块的方式来写的处理器ch.pipeline().addLast(new ChunkedWriteHandler());ch.pipeline().addLast(new HttpObjectAggregator(8192));ch.pipeline().addLast(webSocketHandler);//添加聊天消息处理类ch.pipeline().addLast(new WebSocketServerProtocolHandler("/ws", null, true, 65536 * 10));}});// 服务器异步创建绑定channelFuture = sb.bind().sync();log.debug("{} 启动正在监听: {}", NettyServer.class, channelFuture.channel().localAddress());}// 广播消息接口public void broadcastMessage(SocketMessage socketMessage) {webSocketHandler.broadcastMessage(socketMessage);}// 系统通知接口public void sendSystemMessage(SocketMessage socketMessage, String toUserId) {webSocketHandler.sendSystemMessage(socketMessage, toUserId);}
}

自定义webSocketHandler

前面自定义CusNettyServer 过程我们是将NettyWebSocketHandler 放在外层初始化的,为了避免一个Handler被多个channel传递抛io.netty.channel.ChannelPipelineException异常,我们需要将NettyWebSocketHandler 标记为 @ChannelHandler.Sharable

@ChannelHandler.Sharable
@Slf4j
public class NettyWebSocketHandler extends SimpleChannelInboundHandler<TextWebSocketFrame> {/*** 存储已经登录用户的channel对象*/public static ChannelGroup channelGroup = new DefaultChannelGroup(GlobalEventExecutor.INSTANCE);/*** 存储用户id和用户的channelId绑定*/public static ConcurrentHashMap<String, ChannelId> userMap = new ConcurrentHashMap<>();/*** 存储广播消息的channel对象*/private static final ConcurrentHashMap<String, Channel> broadcastClients = new ConcurrentHashMap<>();/*** 用于存储群聊房间号和群聊成员的channel信息*/public static ConcurrentHashMap<String, ChannelGroup> groupMap = new ConcurrentHashMap<>();private static final ExecutorService executor = Executors.newFixedThreadPool(10); // 创建线程池private final TokenStore tokenStore = SpringUtil.getBean(TokenStore.class);private final StringRedisTemplate redisTemplate = SpringUtil.getBean(StringRedisTemplate.class);/*** 获取用户拥有的群聊id号*/private final UserGroupRepository userGroupRepository = SpringUtil.getBean(UserGroupRepository.class);private final MessageDataAssembler messageDataAssembler = SpringUtil.getBean(MessageDataAssembler.class);private final MessageManagerService messageService = SpringUtil.getBean(MessageManagerService.class);@Overridepublic void channelActive(ChannelHandlerContext ctx) throws Exception {log.info("与客户端建立连接,通道开启!");//添加到channelGroup通道组channelGroup.add(ctx.channel());ctx.channel().id();}@Overridepublic void channelInactive(ChannelHandlerContext ctx) throws Exception {log.info("与客户端断开连接,通道关闭!");//添加到channelGroup 通道组channelGroup.remove(ctx.channel());}@Overridepublic void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception {//首次连接是FullHttpRequest,把用户id和对应的channel对象存储起来if (msg instanceof FullHttpRequest) {FullHttpRequest request = (FullHttpRequest) msg;// 首次握手进行登录验证String uri = request.uri();String token = getUrlParams(uri);String userId = chkLogin(token);userMap.put(userId, ctx.channel().id());broadcastClients.put(userId, ctx.channel());log.info("登录的用户id是:{}", userId);//第1次登录,需要查询下当前用户是否加入项目组,没有拒绝连接,有将群聊管理对象放入groupMap中List<UserGroup> groups = userGroupRepository.findGroupIdByUserId(userId);if (CollUtil.isNotEmpty(groups)) {groups.stream().map(UserGroup::getProjectId).forEach(groupId -> {ChannelGroup cGroup = Optional.ofNullable(groupMap.get(groupId)).orElseGet(() -> {ChannelGroup newGroup = new DefaultChannelGroup(GlobalEventExecutor.INSTANCE);groupMap.put(groupId, newGroup);return newGroup;});//把用户放到群聊管理对象里去cGroup.add(ctx.channel());});}//如果url包含参数,需要处理if (uri.contains("?")) {String newUri = uri.substring(0, uri.indexOf("?"));request.setUri(newUri);}} else if (msg instanceof TextWebSocketFrame) {//正常的TEXT消息类型TextWebSocketFrame frame = (TextWebSocketFrame) msg;log.info("客户端收到服务器数据:{}", frame.text());SocketMessage socketMessage = JSON.parseObject(frame.text(), SocketMessage.class);socketMessage.setSendTime(new Date());socketMessage.setId(IdUtil.getSnowflakeNextIdStr());// 如果群聊不存在,则不处理消息if (!groupMap.containsKey(socketMessage.getProjectId())) {log.info("无效消息,对应群聊不存在 {}", socketMessage.getProjectId());return;}// 将消息存储到 RedisString projectId = socketMessage.getProjectId();String messageKey = String.join(":", "message", projectId, socketMessage.getId());String messageJson = JSON.toJSONString(socketMessage);redisTemplate.opsForValue().set(messageKey, messageJson, 10, TimeUnit.MINUTES);// 异步处理消息executor.submit(() -> {// 从 Redis 中获取消息String storedMessageJson = redisTemplate.opsForValue().get(messageKey);if (storedMessageJson != null) {SocketMessage storedMessage = JSON.parseObject(storedMessageJson, SocketMessage.class);// 持久化消息Message message = messageDataAssembler.toEntity(storedMessage);message.setBizType(MsgBizType.PROJECT.getCode());Message saved = messageService.saveMessage(message);storedMessage.setId(saved.getId());// 推送群聊信息// 这里假设 groupMap 已经定义并且是线程安全的ChannelGroup group = groupMap.get(projectId);if (group != null) {group.writeAndFlush(new TextWebSocketFrame(JSON.toJSONString(storedMessage)));}// 处理完成移除RedisredisTemplate.delete(messageKey);}});}super.channelRead(ctx, msg);}@Overrideprotected void channelRead0(ChannelHandlerContext channelHandlerContext, TextWebSocketFrame textWebSocketFrame) throws Exception {}public void broadcastMessage(SocketMessage socketMessage) {// 异步处理消息executor.submit(() -> {// 持久化消息Message message = messageDataAssembler.toEntity(socketMessage);message.setSendTime(new Date());message.setBizType(MsgBizType.BROADCAST.getCode());message.setMessageType(MessageType.TEXT.getCode());messageService.broadcastMessage(message);channelGroup.writeAndFlush(new TextWebSocketFrame(JSON.toJSONString(socketMessage)));});}public void sendSystemMessage(SocketMessage socketMessage, String toUserId) {// 持久化消息Message message = messageDataAssembler.toEntity(socketMessage);messageService.sendUserMessage(message, toUserId);// 如何用户在线则推送websocket消息Optional.ofNullable(userMap.get(toUserId)).map(channelId -> channelGroup.find(channelId)).ifPresent(channel -> channel.writeAndFlush(new TextWebSocketFrame(JSON.toJSONString(socketMessage))));}private static String getUrlParams(String url) {if (!url.contains("=")) {throw new BusinessException(CusBusinessExceptionEnum.BUSINESS_ERROR_NETTY_SERVER_PATH_MUST_HAS_USER_ID_ERROR);}return url.substring(url.indexOf("=") + 1);}private String chkLogin(String token) {OAuth2AccessToken accessToken =  tokenStore.readAccessToken(token);if (accessToken == null) {throw new BusinessException(401, "Invalid access token:" + token);}if (accessToken.isExpired()) {throw new BusinessException(401, "Expired access token:" + token);}OAuth2Authentication oauth2Authentication = tokenStore.readAuthentication(accessToken);if (tokenStore.readAuthentication(accessToken) == null) {throw new BusinessException(401, "access token Authentication error:" + token);}LoginAppUser loginAppUser = (LoginAppUser) oauth2Authentication.getPrincipal();return loginAppUser.getUserId();}
}

这里有个问题是我们始终未解决的,那就是首次握手token传递的问题,最开始后端是FullHttpRequest request中获取token,且通过apifox也验证通过,但是前端在实现过程始终无法传递token(这块有大佬实现可以在评论下留言指点下)
这是我最开始的实现

 FullHttpRequest request = (FullHttpRequest) msg;// 首次握手进行登录验证// String userId = chkLogin(request);private String chkLogin(FullHttpRequest request) {String token = Optional.ofNullable(request.headers()).map(headers -> headers.get(HttpHeaderNames.AUTHORIZATION)).map(authHeader -> authHeader.replace("Bearer ", "")).orElseThrow(() -> new BusinessException(CusBusinessExceptionEnum.BUSINESS_ERROR_NETTY_SERVER_NOT_LOGIN_ERROR));OAuth2AccessToken accessToken =  tokenStore.readAccessToken(token);if (accessToken == null) {throw new BusinessException(401, "Invalid access token:" + token);}if (accessToken.isExpired()) {throw new BusinessException(401, "Expired access token:" + token);}OAuth2Authentication oauth2Authentication = tokenStore.readAuthentication(accessToken);if (tokenStore.readAuthentication(accessToken) == null) {throw new BusinessException(401, "access token Authentication error:" + token);}LoginAppUser loginAppUser = (LoginAppUser) oauth2Authentication.getPrincipal();return loginAppUser.getUserId();}

下图是我通过apifox使用header传递token验证成功
在这里插入图片描述

使用NettyServer向在线用户发送消息

消费kafka消息并使用NettyServer向在线用户发送消息

@Component
public class NotifyMsgConsumer {private final MessageManagerApplication messageManagerApplication;@KafkaListener(topics = "system_message_notify")public void processMessage(ConsumerRecord<Long, String> record, Acknowledgment acknowledgment) {log.info("system_message_notify 通知: {} {} ", record.key(), record.value());if (StringUtils.isEmpty(record.value())) {log.debug("system_message_notify 消息为空 {} 消息直接丢弃", record.key());acknowledgment.acknowledge();return;}NotifyMsg notifyMsg = null;try {notifyMsg = JSONObject.parseObject(record.value(), NotifyMsg.class);} catch (Exception e) {log.debug("system_message_notify 消息格式异常 {} {} 消息直接丢弃", record.key(), record.value());acknowledgment.acknowledge();return;}messageManagerApplication.sendNotify(notifyMsg);acknowledgment.acknowledge();}}@Override
public void sendNotify(NotifyMsg notifyMsg) {SocketMessage socketMessage = SocketMessage.buildNotifyMessage(messageDataAssembler, notifyMsg);cusNettyServer.sendSystemMessage(socketMessage, notifyMsg.getTo());}

下图是我们实现的一个前端效果图:
在这里插入图片描述
在这里插入图片描述

在这里插入图片描述

需要完善的地方

该方案目前是我们单机部署的方案,集群下还需要扩展,包括:

  1. 在线用户同步的问题:群组新消息处理如何实时同步到所有NettyServer节点连接下的客户端
  2. 通知消息处理问题:也是实时同步的问题,要考虑到所有NettyServer节点连接下的客户端

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.mzph.cn/pingmian/57868.shtml

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈email:809451989@qq.com,一经查实,立即删除!

相关文章

python爬虫-爬取蛋白晶体和分子结构

文章目录 前言一、环境准备二、爬取PDB蛋白结构1.下载指定数量的随机PDB2.下载指定靶标的PDB二、从ZINC爬取小分子mol2结构1.下载指定数量的随机分子2.下载指定分子三、从ChEMBL爬取小分子信息1.下载指定ID的SMILES(测试不成功,网站变成readonly了)四、总结爬虫1.查看对应的…

AMD锐龙8845HS+780M核显 虚拟机安装macOS 15 Sequoia 15.0.1 (2024.10)

最近买了机械革命无界14X&#xff0c;CPU是8845HS&#xff0c;核显是780M&#xff0c;正好macOS 15也出了正式版&#xff0c;试试兼容性&#xff0c;安装过程和之前差不多&#xff0c;这次我从外网获得了8核和16核openCore&#xff0c;分享一下。 提前发一下ISO镜像地址和open…

JavaScript完整笔记

JS引入 JavaScript 程序不能独立运行&#xff0c;它需要被嵌入 HTML 中&#xff0c;然后浏览器才能执行 JavaScript 代码。 通过 script 标签将 JavaScript 代码引入到 HTML 中&#xff0c;有两种方式&#xff1a; 内部方式 通过 script 标签包裹 JavaScript 代码 我们将 &…

安装Maven配置以及构建Maven项目(2023idea)

一、下载Maven绿色软件 地址&#xff1a;http://maven.apache.org/download.cgi 尽量不要选择最高版本的安装&#xff0c;高版本意味着高风险的不兼容问题&#xff0c;选择低版本后续问题就少。你也可以选择尝试。 压缩后&#xff1a; 打开后&#xff1a; 在该目录下新建mvn-…

SQLite 3.47.0 发布,大量新功能来袭

SQLite 开发团队于 2024 年 10 月 21 日发布了 SQLite 3.47.0 版本&#xff0c;我们来了解一下新版本的改进功能。 触发器增强 SQLite 3.47.0 版本开始&#xff0c;触发器函数 RAISE() 的 error-message 参数可以支持任意 SQL 表达式。在此之前&#xff0c;该参数只能是字符串…

SQL注入之sqlilabs靶场21-30题

重点插入&#xff1a;html表 第二十一题 分析过程&#xff1a;&#xff08;没有正确的账号密码是否能拿到Cookie&#xff1f;最后注释好像只能使用#&#xff0c;--好像无法注释&#xff09; 查看源码 这里输入账号密码处被过滤了 但Cookie被base64编码了 可以从Cookie入手 …

vue2结合echarts实现数据排名列表——前端柱状进度条排行榜

写在前面&#xff0c;博主是个在北京打拼的码农&#xff0c;工作多年做过各类项目&#xff0c;最近心血来潮在这儿写点东西&#xff0c;欢迎大家多多指教。 数据排名列表——图表开发&#xff0c;动态柱状图表&#xff0c;排名图 UI 直接搜到类似在线代码&#xff08;数据列表…

多线程——线程池

目录 前言 一、什么是线程池 1.引入线程池的原因 2.线程池的介绍 二、标准库中的线程池 1.构造方法 2.方法参数 &#xff08;1&#xff09;corePoolSize 与 maximumPoolSize &#xff08;2&#xff09;keepAliveTime 与 unit &#xff08;3&#xff09;workQueue&am…

Redis集群分片存储最佳实践,手把手搭建Redis集群

目录 一、Redis集群介绍1、设计目标2、为什么需要分片存储 二、官网集群方案三、搭建集群1、准备6个独立的Redis服务2、通过redis-cli工具创建集群3、检验集群4、集群重新分片5、故障转移测试6、集群扩容7、集群节点删除 四、集群关心的问题五、集群功能限制 一、Redis集群介绍…

蓝桥杯题目理解

1. 一维差分 1.1. 小蓝的操作 1.1.1. 题目解析&#xff1a; 这道题提到了对于“区间”进行操作&#xff0c;而差分数列就是对于区间进行操作的好方法。 观察差分数列&#xff1a; 给定数列&#xff1a;1 3 5 2 7 1 差分数列&#xff1a;1 2 2 -3 5 6 题目要求把原数组全部…

基于SpringBoot的高校体测管理系统设计与实现(源码+定制+开发)高校体测记录系统设计、高校体测信息管理平台、智能体测管理系统开发、高校体测记录系统设计

博主介绍&#xff1a; ✌我是阿龙&#xff0c;一名专注于Java技术领域的程序员&#xff0c;全网拥有10W粉丝。作为CSDN特邀作者、博客专家、新星计划导师&#xff0c;我在计算机毕业设计开发方面积累了丰富的经验。同时&#xff0c;我也是掘金、华为云、阿里云、InfoQ等平台…

25届电信保研经验贴(自动化所)

个人背景 学校&#xff1a;中九 专业&#xff1a;电子信息工程 加权&#xff1a;92.89 绩点&#xff1a;3.91/4.0 rank&#xff1a;前五学期rank2/95&#xff0c;综合排名rank1&#xff08;前六学期和综合排名出的晚&#xff0c;实际上只用到了前五学期&#xff09; 科研…

海外云手机实现高效的海外社交媒体营销

随着全球化的深入发展&#xff0c;越来越多的中国企业走向国际市场&#xff0c;尤其是B2B外贸企业&#xff0c;海外社交媒体营销已成为其扩大市场的重要手段。在复杂多变的海外市场环境中&#xff0c;如何有效提高营销效率并降低运营风险&#xff0c;成为了众多企业的首要任务。…

路由器 相关知识

一、路由器是什么 参考&#xff1a;图解系列--路由器和它庞大的功能_路由功能-CSDN博客 路由器是指&#xff1a;主要负责 OSI参考模型中网络层的处理工作&#xff0c;并根据路由表信息在不同的网络 之间转发IP 分组的网络硬件(图3-1)。这里的网络一般是指IP 子网&#xff0c;…

Java基础(7)图书管理系统

目录 1.前言 2.正文 2.1思路 2.2Book包 2.3people包 2.4operation包 2.5主函数 3.小结 1.前言 哈喽大家好吖&#xff0c;今天来给前面Java基础的学习来一个基础的实战&#xff0c;做一个简单的图书管理系统&#xff0c;这里边综合利用了我们之前学习到的类和对象&…

爬虫ip技术未来发展趋势

各位朋友&#xff0c;大家好&#xff01;有伙伴问爬虫技术未来会有更好的发展么&#xff0c;那今天小蝌蚪来跟大家聊聊爬虫技术未来的发展趋势分享一下行业咨询。 大家在日常工作和生活中&#xff0c;都希望事情能更省心、高效吧&#xff1f;未来的爬虫技术就朝着这个方向发展…

sheng的学习笔记-AI基础-正确率/召回率/F1指标/ROC曲线

AI目录&#xff1a;sheng的学习笔记-AI目录-CSDN博客 分类准确度问题 假设有一个癌症预测系统&#xff0c;输入体检信息&#xff0c;可以判断是否有癌症。如果癌症产生的概率只有0.1%&#xff0c;那么系统预测所有人都是健康&#xff0c;即可达到99.9%的准确率。 但显然这样的…

在Keil调试内存中的程序

在Keil调试内存中的程序 目录 在Keil调试内存中的程序1. 问题引出2. 测试工程3. 工程和Keil配置 实验环境&#xff1a; MCU&#xff1a;STM32F103C8T6 (Flash 64K RAM 20K)Keil&#xff1a;uVision V5.27.0.0仿真器&#xff1a;ST-Link 参考源码&#xff1a;https://download.c…

Redis 集群 总结

前言 相关系列 《Redis & 目录》&#xff08;持续更新&#xff09;《Redis & 集群 & 源码》&#xff08;学习过程/多有漏误/仅作参考/不再更新&#xff09;《Redis & 集群 & 总结》&#xff08;学习总结/最新最准/持续更新&#xff09;《Redis & 集群…

导出问题处理

问题描述 测试出来一个问题&#xff0c;使用地市的角色&#xff0c;导出数据然后超过了20w的数据&#xff0c;提示报错&#xff0c;我还以为是偶然的问题&#xff0c;然后是发现是普遍的问题&#xff0c;本地环境复现了&#xff0c;然后是&#xff0c;这个功能是三套角色&…