☰
java知识合集
2026/10/15 21:18:52 网站建设 项目流程

1、为什么要用这两个线程安全变量

@ServerEndpoint每个前端连接都会新建独立实例,static的webSocketSet、onlineCount是所有连接共享。 会有多类线程并发操作:Tomcat 线程(客户端 onOpen/onClose 上下线)、MQ 消费线程(遍历集合推送告警),存在并发安全问题,必须用线程安全类。

2、AtomicInteger(在线连接计数器)

底层 CAS 乐观锁,保证incrementAndGet()/decrementAndGet()是原子操作。 普通 int 的count++分为「读 - 计算 - 写回」三步,多线程并发会丢失计数;CAS 解决这个原子性问题。

3、CopyOnWriteArraySet(存放 WebSocket Session)

底层基于 CopyOnWriteArrayList,核心机制:写时拷贝数组 + ReentrantLock 锁

  1. 写操作(add/remove):要抢锁同一时刻只允许一个写线程执行:拷贝当前数组 → 修改副本 → 替换数组引用;其他写线程阻塞等待。

如果不加锁:多个写线程同时拷贝同一份原始数组,各自修改副本,后替换引用的线程会覆盖前面的修改,造成 Session 丢失、数据错乱。

  1. 读操作(遍历推送消息):不加锁直接拿到当前数组的快照进行遍历。读操作和写操作可以并发执行,互不阻塞。遍历期间集合发生增删,读到的还是旧数组快照,不会抛出 ConcurrentModificationException。

一、并发安全(你的内存连接集合)

  1. 存储所有客户端 Session 用CopyOnWriteArraySet,线程安全。读多写少场景合适;读不加锁,写的时候复制数组 + 加锁。
  2. 在线连接计数器使用AtomicInteger,基于 CAS 保证原子性。
  3. 连接接入逻辑:先 CAS 自增计数,拿到结果再判断是否超过最大连接数。超过就 close 连接、回退计数器;正常则把 session 放入集合。
  4. 断线时在onClose回调中:从集合移除 session、计数器递减,清理资源。
  5. 指数退避重连:客户端断线重试间隔 1s→2s→4s,设置上限,一般加随机 jitter,打散大量客户端同时重连的请求,避免惊群效应,防止网络故障后大量连接瞬间重建压垮服务端;同时设置最大重试次数,防止无限重试。

二、连接超时与僵死连接清理(两套方案,可双层兜底)

方案① 容器原生超时session.setMaxInactiveInterval(秒)

  • TCP 通道完好、长时间无数据,Tomcat 自动关闭 Session,触发 onClose,不需要自己写定时任务。
  • 缺陷:防火墙 / NAT 静默断连场景,没有 TCP FIN 包,操作系统内核 TCP 状态仍是 ESTABLISHED,Tomcat 感知不到,不会触发超时,产生僵死连接,占用内存。

方案② 业务层心跳时间戳 + 定时任务扫描(推荐,抓静默断连)

  • 封装 WS 对象,记录lastHeartbeatTime,客户端发 ping 心跳,服务端收到后刷新这个时间戳。
  • 开启定时任务周期性遍历连接集合,判断超过阈值就主动 close 连接。
  • 优点:不依赖 TCP 底层状态,可以识别静默断连,清理僵死连接。
  • 缺点:定时遍历集合,连接量大时有少量 CPU 开销,清理存在扫描间隔延迟。

✅ 生产最佳实践:两层一起配置。业务定时扫描作为主逻辑(超时更短),setMaxInactiveInterval作为兜底,设置更长超时,防止业务代码 bug 导致连接无法释放。

静默断连:中间防火墙、NAT 长时间无流量直接销毁 TCP 通道,不发送关闭报文。客户端和服务端内核都不知道连接已经断开。只靠容器超时无法发现。

三、集群多节点部署(加分项,承认当前项目是单节点)

集群场景痛点:多实例下,WebSocket Session 保存在各节点内存,客户端只连其中一个节点。消息产生在 A 节点,但用户连接在 B 节点,A 拿不到 B 内存里的 Session,无法直接推送消息。两种方案:

  1. MQ Fanout 广播方案:每个节点创建临时 auto-delete 队列,绑定 fanout 交换机。消息发到交换机,广播给全部节点;每个节点收到消息,判断本机是否持有用户 Session,有则推送,无则丢弃。开发简单;缺点是节点越多无效流量越大。
  2. 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 谁推,没有的忽略。前端不碰 MQ,MQ 只负责服务器间广播。

“直接联表会把所有数据都参与拼接,产生大量中间结果。先子查询,是先筛选出需要的数据,再拿这些小结果集去拼接,避免不必要的数据参与,减少拼接开销。”

一、消息可靠性三层防线

1. 生产者端:确认机制

  • publisher-confirm:确认消息到没到交换机。

    • ack:到了。

    • nack:没到,可以重试。

  • publisher-return:确认消息路由到没到队列。

    • 到了交换机但路由不到队列 →returnCallback退回消息。

    • 重试没用(路由规则没变),要落库 + 定时补发。

2. MQ 端:持久化

  • 队列持久化:durable=true。

  • 消息持久化:deliveryMode=2。

  • 两者必须都开,Broker 宕机消息不丢。

3. 消费者端:手动 ACK + 死信

  • 手动 ACK:acknowledge-mode=manual。

  • 业务成功 →basicAck。

  • 业务失败 →basicNack(requeue=false)。

  • 死信交换机 + 死信队列:接收消费失败的消息。

🎯 二、重试策略

  • confirmCallback的nack:没到交换机,可以直接重试。

  • returnCallback:路由失败,落库 + 定时任务补发。

  • 消费者失败:basicNack(requeue=true)重投,或requeue=false走死信。

  • 重试次数要限制:超过 N 次,告警,人工处理。

🎯 三、幂等(防重复消费)

  • 消息带唯一 ID。

  • 消费前用唯一索引或Redis SETNX判断处理过没。

  • 处理过 → 直接 ACK 忽略。

  • ACK 防不住网络问题导致的重投,幂等兜底。

🎯 四、延迟队列 + 死信(你之前问的)

  • 消息进延迟队列,到期投到业务队列。

  • 消费失败 → 放回延迟队列重试。

  • 重试超过 N 次 →basicNack(requeue=false)→ 进死信队列。

  • retryCount放消息头,每次重试 +1。

🎯 REQUIRED

  • 外层方法有事务,内层方法加入这个事务。

  • 两者共享同一个事务。

  • 外层任何地方失败 →整个事务回滚,内层方法也回滚。

🎯 REQUIRES_NEW

  • 外层方法有事务,内层方法挂起外层,新建一个独立事务。

  • 内层事务先提交,和外层没关系。

  • 外层后续失败 →外层回滚,内层已经提交,不回滚。

🎯 你的例子

text

methodA(大方法,有事务) ├── 其他逻辑 ├── methodB(小方法,REQUIRES_NEW) └── 其他逻辑失败 → 抛异常
  • methodB:独立事务,已经提交,不回滚。

  • methodA 的其他逻辑:回滚。

  • methodA 本身:回滚。

最终:methodB 的数据留下了,methodA 其他逻辑的数据没了。

🎯 注意

  • REQUIRES_NEW要两个数据库连接:外层一个,内层一个。连接池不够会死锁。

  • 自调用失效:同一个类里methodA调methodB,REQUIRES_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 动态代理【面试浓缩总结】

  1. JDK 动态代理
  • 前提:目标类必须实现接口
  • 生成的代理类:实现和目标相同的接口,不是目标类的子类
  • 能拦截:只有接口中定义的方法;实现类独有的方法,代理类不会生成,无法调用、无法增强
  • 注入坑点:拿到的代理对象类型是代理类,不能强转为目标实现类,否则报类型转换异常
  • 内部:代理持有原始目标对象,调用接口方法进入InvocationHandler.invoke做增强
  1. CGLIB 动态代理
  • 前提:目标类不能是 final,要增强的方法也不能是 final
  • 生成的代理类:继承目标类,作为子类
  • 能拦截:目标类所有非 final 方法,不管是不是接口里定义的,都可以增强
  • 注入优势:代理是目标类子类,既能用接口接收,也能用原始实现类接收,不会类型转换异常
  • 内部:子类代理持有原始目标对象,方法调用进入MethodInterceptor.intercept增强
  1. SpringBoot2 + 默认规则proxyTargetClass=true,默认全部使用 CGLIB 代理。 好处:不用关心有没有接口,支持实现类独有方法增强,避免强转类型异常; 只有手动设置spring.aop.proxy-target-class=false,才启用老规则:有接口 JDK,无接口 CGLIB。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询