1、为什么要用这两个线程安全变量ServerEndpoint每个前端连接都会新建独立实例static的webSocketSet、onlineCount是所有连接共享。 会有多类线程并发操作Tomcat 线程客户端 onOpen/onClose 上下线、MQ 消费线程遍历集合推送告警存在并发安全问题必须用线程安全类。2、AtomicInteger在线连接计数器底层 CAS 乐观锁保证incrementAndGet()/decrementAndGet()是原子操作。 普通 int 的count分为「读 - 计算 - 写回」三步多线程并发会丢失计数CAS 解决这个原子性问题。3、CopyOnWriteArraySet存放 WebSocket Session底层基于 CopyOnWriteArrayList核心机制写时拷贝数组 ReentrantLock 锁写操作add/remove要抢锁同一时刻只允许一个写线程执行拷贝当前数组 → 修改副本 → 替换数组引用其他写线程阻塞等待。如果不加锁多个写线程同时拷贝同一份原始数组各自修改副本后替换引用的线程会覆盖前面的修改造成 Session 丢失、数据错乱。读操作遍历推送消息不加锁直接拿到当前数组的快照进行遍历。读操作和写操作可以并发执行互不阻塞。遍历期间集合发生增删读到的还是旧数组快照不会抛出 ConcurrentModificationException。一、并发安全你的内存连接集合存储所有客户端 Session 用CopyOnWriteArraySet线程安全。读多写少场景合适读不加锁写的时候复制数组 加锁。在线连接计数器使用AtomicInteger基于 CAS 保证原子性。连接接入逻辑先 CAS 自增计数拿到结果再判断是否超过最大连接数。超过就 close 连接、回退计数器正常则把 session 放入集合。断线时在onClose回调中从集合移除 session、计数器递减清理资源。指数退避重连客户端断线重试间隔 1s→2s→4s设置上限一般加随机 jitter打散大量客户端同时重连的请求避免惊群效应防止网络故障后大量连接瞬间重建压垮服务端同时设置最大重试次数防止无限重试。二、连接超时与僵死连接清理两套方案可双层兜底方案① 容器原生超时session.setMaxInactiveInterval(秒)TCP 通道完好、长时间无数据Tomcat 自动关闭 Session触发 onClose不需要自己写定时任务。缺陷防火墙 / NAT 静默断连场景没有 TCP FIN 包操作系统内核 TCP 状态仍是 ESTABLISHEDTomcat 感知不到不会触发超时产生僵死连接占用内存。方案② 业务层心跳时间戳 定时任务扫描推荐抓静默断连封装 WS 对象记录lastHeartbeatTime客户端发 ping 心跳服务端收到后刷新这个时间戳。开启定时任务周期性遍历连接集合判断超过阈值就主动 close 连接。优点不依赖 TCP 底层状态可以识别静默断连清理僵死连接。缺点定时遍历集合连接量大时有少量 CPU 开销清理存在扫描间隔延迟。✅ 生产最佳实践两层一起配置。业务定时扫描作为主逻辑超时更短setMaxInactiveInterval作为兜底设置更长超时防止业务代码 bug 导致连接无法释放。静默断连中间防火墙、NAT 长时间无流量直接销毁 TCP 通道不发送关闭报文。客户端和服务端内核都不知道连接已经断开。只靠容器超时无法发现。三、集群多节点部署加分项承认当前项目是单节点集群场景痛点多实例下WebSocket Session 保存在各节点内存客户端只连其中一个节点。消息产生在 A 节点但用户连接在 B 节点A 拿不到 B 内存里的 Session无法直接推送消息。两种方案MQ Fanout 广播方案每个节点创建临时 auto-delete 队列绑定 fanout 交换机。消息发到交换机广播给全部节点每个节点收到消息判断本机是否持有用户 Session有则推送无则丢弃。开发简单缺点是节点越多无效流量越大。Redis 精准投递方案Redis 存储用户 / 会话 → 节点地址映射。推送消息先查 Redis找到用户所在节点通过 RPC/HTTP 调用目标节点完成推送。流量精准节省缺点复杂需要维护节点映射处理断线重连、节点宕机清理缓存。现阶段项目单节点直接内存存 Session不需要处理跨节点推送如果后续扩容可根据节点规模选择上面两种方案。onOpen 里面先判断是否超限只有没超限才把 session add 进集合、计数器 1超限直接 close不进集合、不增加计数器。 在OnClose回调执行boolean removed webSocketSet.remove(session);如果removed true说明这个 session 曾经成功加入集合计数器 1 过 → 执行onlineCount.decrementAndGet()如果removed false代表这个连接本来就没放进集合超限拒绝的连接不用减计数器B 发消息 → 服务器 2 → 查本地没有就发交换机 → 广播到所有队列 → 各服务器从自己队列拿 → 谁有 A 谁推没有的忽略。前端不碰 MQMQ 只负责服务器间广播。“直接联表会把所有数据都参与拼接产生大量中间结果。先子查询是先筛选出需要的数据再拿这些小结果集去拼接避免不必要的数据参与减少拼接开销。”一、消息可靠性三层防线1. 生产者端确认机制publisher-confirm确认消息到没到交换机。ack到了。nack没到可以重试。publisher-return确认消息路由到没到队列。到了交换机但路由不到队列 →returnCallback退回消息。重试没用路由规则没变要落库 定时补发。2. MQ 端持久化队列持久化durabletrue。消息持久化deliveryMode2。两者必须都开Broker 宕机消息不丢。3. 消费者端手动 ACK 死信手动 ACKacknowledge-modemanual。业务成功 →basicAck。业务失败 →basicNack(requeuefalse)。死信交换机 死信队列接收消费失败的消息。 二、重试策略confirmCallback的nack没到交换机可以直接重试。returnCallback路由失败落库 定时任务补发。消费者失败basicNack(requeuetrue)重投或requeuefalse走死信。重试次数要限制超过 N 次告警人工处理。 三、幂等防重复消费消息带唯一 ID。消费前用唯一索引或Redis SETNX判断处理过没。处理过 → 直接 ACK 忽略。ACK 防不住网络问题导致的重投幂等兜底。 四、延迟队列 死信你之前问的消息进延迟队列到期投到业务队列。消费失败 → 放回延迟队列重试。重试超过 N 次 →basicNack(requeuefalse)→ 进死信队列。retryCount放消息头每次重试 1。 REQUIRED外层方法有事务内层方法加入这个事务。两者共享同一个事务。外层任何地方失败 →整个事务回滚内层方法也回滚。 REQUIRES_NEW外层方法有事务内层方法挂起外层新建一个独立事务。内层事务先提交和外层没关系。外层后续失败 →外层回滚内层已经提交不回滚。 你的例子textmethodA大方法有事务 ├── 其他逻辑 ├── methodB小方法REQUIRES_NEW └── 其他逻辑失败 → 抛异常methodB独立事务已经提交不回滚。methodA 的其他逻辑回滚。methodA 本身回滚。最终methodB 的数据留下了methodA 其他逻辑的数据没了。 注意REQUIRES_NEW要两个数据库连接外层一个内层一个。连接池不够会死锁。自调用失效同一个类里methodA调methodBREQUIRES_NEW不生效必须跨 Bean 调用。 一句话总结REQUIRED内层加入外层一起回滚。REQUIRES_NEW内层独立外层回滚不影响内层。你说的完全对。四种事务隔离级别分别是读未提交、读已提交、可重复读、串行化。 读未提交可以读到其他事务未提交的数据如果读到的数据最后回滚了就产生脏读。读已提交只能读取已经提交的数据解决了脏读问题但还会出现不可重复读。 不可重复读就是同一个事务内两次读同一行中间别的事务修改提交前后读到的数据不一致。举例子事务里查询订单金额、打印订单标签中间如果别的线程修改了订单数据两次读取结果不一样就会出错。可重复读保证事务内多次快照读读取同一个数据快照避免这个问题。但可重复读分快照读和当前读。当前读不走快照会加锁。范围当前读会加临键锁能阻止加锁之后区间内新增数据。 但是如果先是快照读不加锁后面才执行当前读第一次读到第二次读的这段时间窗口里别的事务可以插入新数据就会产生幻读。 想要解决这个问题就要在读的时候就加上锁只要查询就上锁堵住这个时间窗口彻底消除幻读这个就是串行化。我会开启 publisher confirm 和 publisher return 机制实现回调重写 confirm 和 returnedMessage 方法。 在 confirm 方法中通过入参 ack 判断结果ack 为 true 代表 Broker 成功收到消息nack 代表投递 Broker 失败。 如果消息成功到达交换机但是路由匹配不到队列就会触发 returnedMessage 回调。 这两个回调里面我不会直接重试发送消息而是将失败消息入库记录消息内容、失败原因。然后单独开启定时任务扫描这张失败消息表重新投递消息到 MQ 队列处理。当前项目并发量不算高只有登录用户才会发起这类查询所以暂时没有落地这些方案但我清楚对应的解决手段 缓存雪崩给 Redis 的 key 设置 TTL基础过期时间在 30~60 分钟之间随机生成再额外增加 60 秒以内的随机偏移打散 key 的过期时间避免大量缓存同时失效。 缓存击穿采用 Redisson 分布式锁以业务缓存 key 作为锁的名称。缓存失效时抢到锁的请求去查询数据库并更新缓存完成后释放锁避免大量请求同时访问数据库查询同一条数据。 缓存穿透低并发场景下如果数据库查询不到数据就在 Redis 存入空值防止重复查询 DB如果并发量很高可以搭配 Redisson 分布式锁或者直接使用布隆过滤器。把所有有效数据的 key 存入布隆过滤器请求先经过布隆过滤器校验查询不到的话直接返回不再访问 Redis 和数据库。.这个查询是查询水表历史数据索引把设备编码放在最前面是因为设备编码区分度更大。 举个例子总共有 100 条数据水表分为 3 个区域区域一共 5 种。 索引顺序【设备编码区域】第一次通过设备编码筛选出 15 条再在这 15 条里过滤区域最后得到 3 条。 如果调换顺序索引变成【区域设备编码】第一步按区域筛选直接查出 20 条再在 20 条里面过滤设备编码得到 3 条。 从 15 条里面筛选 3 条比从 20 条里面筛选 3 条的数据量更小所以前者查询速度更快。JDK 动态代理 vs CGLIB 动态代理【面试浓缩总结】JDK 动态代理前提目标类必须实现接口生成的代理类实现和目标相同的接口不是目标类的子类能拦截只有接口中定义的方法实现类独有的方法代理类不会生成无法调用、无法增强注入坑点拿到的代理对象类型是代理类不能强转为目标实现类否则报类型转换异常内部代理持有原始目标对象调用接口方法进入InvocationHandler.invoke做增强CGLIB 动态代理前提目标类不能是 final要增强的方法也不能是 final生成的代理类继承目标类作为子类能拦截目标类所有非 final 方法不管是不是接口里定义的都可以增强注入优势代理是目标类子类既能用接口接收也能用原始实现类接收不会类型转换异常内部子类代理持有原始目标对象方法调用进入MethodInterceptor.intercept增强SpringBoot2 默认规则proxyTargetClasstrue默认全部使用 CGLIB 代理。 好处不用关心有没有接口支持实现类独有方法增强避免强转类型异常 只有手动设置spring.aop.proxy-target-classfalse才启用老规则有接口 JDK无接口 CGLIB。