Java WebSocket高并发低延迟交易系统设计与实践
我有必要先把这次要聊的东西讲清楚这不是一篇泛泛的“WebSocket入门教程”而是一份面向金融交易场景的 Java 技术方案复盘。核心关键词是Java、WebSocket、鉴权、高并发、低延迟。方案围绕的是“客户端通过 WebSocket 长连接完成身份认证并直接发起下单请求服务端在高并发压力下保证低延迟不回退”这一整条链路。适合谁来读如果你正在设计交易客户端、行情推送网关或者在券商、支付、加密资产类项目里做实时交互后端这篇文章可以直接给你一套可落地的架构参考。如果你还在纠结“WebSocket 鉴权到底该怎么做”“下单这种强事务操作能不能走 WebSocket”“连接量上来之后为什么延迟会抖动”那这篇文章应该能让你少踩几个坑。1. 为什么金融下单场景必须用 WebSocketHTTP 轮询方案的根本瓶颈很多团队在做交易类客户端时第一版都是“HTTP 接口轮询前端定时刷新”。用户点一下“买入”前端发一个 POST 请求隔几百毫秒再查一次订单状态。这种方案在散户量级、单机几千连接时没大问题但一旦进入实时性要求更高的场景问题就会集中爆发。1.1 轮询方案的延迟天花板HTTP 轮询的延迟由“轮询间隔-服务端处理时间”共同决定。假设前端每 2 秒拉一次订单状态用户看到的成交回执最迟可能在第 2 秒边界才刷新出来。这个延迟对于行情展示也许可以忍但对“撤单”“改价”“市价抢单”这类操作就是致命的——你撤单请求发出去结果对方在通道里多等了 1.8 秒才被服务端处理可能已经成交了。延迟拆开看主要由三部分组成前端定时器粒度、网络往返时间RTT、服务端查询耗时。HTTP 短连接每一次请求还要经历 TCP 四次挥手和 TLS 握手这些开销在低频场景无所谓但在高频操作下会被放大。实测过一个项目TLS 1.3 握手大概消耗 1 到 2 个 RTT加上 HTTP 报文头部单次请求的固定开销经常超过 10 毫秒。高频场景下这个数字就非常可观了。1.2 WebSocket 解决的是“服务端主动”和“连接复用”两个问题WebSocket 本质上是在 TCP 之上建立一条全双工长连接连接建立后服务端可以随时主动向客户端推送数据不需要客户端先发起请求。这个“服务端主动”能力对交易系统极其重要订单状态变化、成交回报、撤单确认、风控拦截通知这些消息必须从服务端即时推到客户端而不是等着客户端来问。连接复用同样关键。一次 WebSocket 握手建立连接后后续所有消息都在同一条 TCP 连接上传输省掉了反复建连的开销。对于需要同时订阅行情、接收回报、发送指令的客户端WebSocket 可以只用一条连接承载多条消息通道通过消息类型字段区分连接成本大幅下降。1.3 对比数据同一套下单接口在两种协议下的差异我拿一个简单的“下单-回报”流程做过对比测试模拟 1000 个并发客户端每个客户端发送 10 笔订单统计从客户端发出指令到收到第一笔成交回报的端到端延迟指标HTTP 轮询2 秒间隔WebSocket 长连接平均端到端延迟约 1100ms约 18msP99 延迟约 2100ms约 45ms服务端每秒请求数600含大量空轮询220只有真实指令和推送单连接占用资源每次请求新建连接一条连接复用这个结果并不意外。轮询的大量请求是在“查空转”服务端资源严重浪费在无意义的 HTTP 请求上而 WebSocket 只在真实事件发生时传输数据资源利用率高得多。对于金融交易这种强实时场景WebSocket 基本是唯一合理的选择。2. 鉴权设计从 HTTP Token 到 WebSocket 长连接的“两次握手”WebSocket 连接建立之后是长连接如果鉴权只发生在建连瞬间后续所有消息都依赖这条连接的可信度。这里有个关键问题WebSocket 连接建立后如何在服务端把“连接”和“用户身份”安全地绑定起来很多人直接在前端代码里把 token 放到 URL 参数上比如ws://api.example.com/ws?tokenxxx这种做法在金融场景里不合适——URL 参数会出现在网关日志、代理日志、浏览器历史记录中token 等于白给了。2.1 第一步HTTP 预握手换取一次性连接票据我推荐的做法是把鉴权拆成两次握手。第一次是传统的 HTTP 登录接口客户端提交账号密码或 API Key服务端校验通过后返回两个东西短期 token比如有效期 15 分钟用于 HTTP 接口鉴权和一个一次性连接票据connection ticket有效期极短例如 60 秒只能使用一次。public class AuthController { PostMapping(/login) public LoginResponse login(RequestBody LoginRequest request) { // 校验账号密码 UserAccount account accountService.authenticate(request.getUsername(), request.getPassword()); // 生成短期 token用于后续 HTTP 调用 String accessToken jwtService.generateToken(account.getId(), 15 * 60 * 1000L); // 生成一次性连接票据只能用于 WebSocket 握手 String connectionTicket ticketService.generateOneTimeTicket(account.getId(), 60 * 1000L); return new LoginResponse(accessToken, connectionTicket); } }这个连接票据可以存储在 Redis 里key 为ticket:{uuid}value 为 userId同时设置 60 秒过期。WebSocket 握手时客户端带着这个票据来连接服务端校验票据有效且未被使用后立刻删除 Redis 中的 key保证一次性然后才真正建立连接。2.2 第二步WebSocket 握手阶段的 Header 鉴权很多 WebSocket 客户端库不支持自定义 Header比如浏览器端的原生 WebSocket API 只能拼 URL。这时候怎么办可以退而求其次把连接票据放在 URL 的 query 参数上但有一个前置条件该票据是一次性的、有效期极短、只能用于建立连接。这样即使被日志记录下来攻击者拿到后也没时间利用更不可能用于第二次连接。服务端在握手拦截器里读取票据校验通过后把 userId 放入 WebSocketSession 的 attributes 中后续所有消息处理逻辑都从 session 里拿用户身份不再信任客户端传来的任何用户标识字段。public class WebSocketAuthInterceptor implements HandshakeInterceptor { Override public boolean beforeHandshake(ServerHttpRequest request, ServerHttpResponse response, WebSocketHandler wsHandler, MapString, Object attributes) { String ticket extractTicket(request); Long userId ticketService.consumeAndGetUserId(ticket); if (userId null) { response.setStatusCode(HttpStatus.UNAUTHORIZED); return false; } attributes.put(userId, userId); return true; } }2.3 连接生命周期内的持续鉴权与权限收敛握手中的鉴权只是“入门检查”连接建立后还有一个关键设计原则——最小权限原则。一个交易账户可能有多种权限普通用户只能下单和撤单管理员可以查看风控配置只读用户只能看行情。连接建立后服务端应该在消息处理链路中做细粒度鉴权不能因为连接已经建立就默认为所有操作合法。建议在消息处理前、处理中、处理后的三个阶段都合理插入权限校验消息到达时校验该用户是否有权限执行该类操作执行过程中校验交易标的、数量、金额是否在限额内执行完成后的推送环节校验接收人是否有权限看到这笔订单详情防止同账户多设备权限不同引发的信息泄露。另外针对长期不活跃的长连接建议设计 token 自动续期机制。我常用的做法是服务端每 5 分钟检查一次 session 关联的 token 是否即将过期如果即将过期则通过 WebSocket 主动向客户端发送“请续期”指令客户端拿到新 token 后通过内部消息通道上报。这个机制有效避免了用户长期挂机后 token 过期导致连接被突然断开的问题。3. 下单链路设计客户端消息到订单落库全流程拆解金融场景对下单链路的要求不只是“快”更重要的是“可靠”和“可追溯”。WebSocket 是全双工通道客户端发消息和服务端推消息互不影响但下单是一个强事务操作不能因为通道是异步的就把业务也做成完全异步。下面是我实践下来比较稳妥的一套流程。3.1 客户端消息协议设计WebSocket 消息不能直接传二进制对象或 Java 序列化对象必须有一个统一的文本协议。我常用的是 JSON 格式外层包一个消息类型和消息 ID{ msgType: ORDER_CREATE, msgId: 6f2c0e2a-8b9e-4b17-aa2a-6f2d4f0b7d1e, timestamp: 1737028800123, data: { symbol: BTCUSDT, side: BUY, orderType: LIMIT, price: 43250.5, quantity: 0.25 } }msgId 是客户端生成的全局限一标识服务端用它做幂等处理。为什么必须做幂等因为 WebSocket 底层是 TCP极端情况下客户端发送消息后连接闪断客户端重连后重新发送同一笔下单指令如果服务端不识别重复消息就会造成重复下单。这个坑在金融场景中非常危险。3.2 服务端消息接收与业务线程池隔离WebSocket 消息到达服务端后不能直接在 Netty 的 IO 线程里处理业务逻辑。IO 线程的职责是快速解码、路由、响应任何耗时的数据库操作、外部接口调用、复杂计算都应该放到独立的业务线程池中执行。Configuration public class WebSocketExecutorConfig { Bean(orderExecutor) public ThreadPoolTaskExecutor orderExecutor() { ThreadPoolTaskExecutor executor new ThreadPoolTaskExecutor(); executor.setCorePoolSize(8); executor.setMaxPoolSize(32); executor.setQueueCapacity(10000); executor.setThreadNamePrefix(order-biz-); executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy()); return executor; } }核心线程数怎么定常规经验是CPU核心数 * 2 2但下单链路中如果有数据库 IO 或外部接口调用这个数值要适当放大。压测环境建议从 8 开始逐步上调同时观察线程池队列积压情况和 P99 延迟。如果队列积压持续上涨说明线程池太小如果 CPU 使用率已经接近 90%说明线程数太多线程切换开销已经抵消了并发收益。3.3 单用户消息的顺序性保证下单、撤单、改价这些操作之间有严格的时序依赖——先撤单后改价和先改价后撤单结果是完全不同的。WebSocket 虽然底层是 TCP能保证单连接上的消息按序到达但到了业务线程池之后如果多线程并发处理同一个用户的多条消息顺序就乱了。我采用的方案是“用户级有序队列”为每个 userId 分配一个内存队列同一用户的所有指令消息进入同一队列由单线程顺序消费。这样既能保证单用户消息顺序又能保证不同用户之间的处理互相独立、天然并行。这个方案的关键在于队列的选择。ConcurrentLinkedQueue是非阻塞队列不会因为队列满而阻塞 IO 线程但可能带来无界增长的风险。为了控制内存占用我在队列入口做了一个阈值检查当某个用户的积压消息超过 1000 条时直接拒绝新消息并断开连接防止有人恶意刷消息导致内存泄漏。3.4 订单结果的异步回推与失败兜底订单提交成功后真正进入撮合引擎、返回成交回报的过程可能是异步的。服务端需要把处理结果通过 WebSocket 推送给客户端。推送时机有讲究不能等订单完全落库了再推也不能订单刚进内存就推。我的经验是订单状态机的每个关键节点都要触发推送——已接收、校验通过、已报出、部分成交、完全成交、失败拒绝。每个状态推送都带上状态变更时间和错误码方便客户端精确处理。如果推送失败怎么办这里要有一个“消息可靠推送”的兜底机制。我通常会给每个 WebSocket 连接维护一个待确认消息缓存推送出去后等待客户端 ACK消息类型为 ORDER_ACK。如果客户端 10 秒内没有返回 ACK服务端会重推一次重试 3 次仍然失败说明客户端大概率已经掉线此时把未确认消息持久化到数据库等客户端重连后自动补推。有一种常见的错误做法是“只推送不确认”。在演示项目或低并发场景可能没问题但在真实交易系统中漏掉一条成交回报意味着用户看到自己的订单还是“已提交”状态可能引发纠纷。所以下单链路中的 ACK 机制是必须的不能省。4. 高并发下的连接与消息管理会话表、心跳保活与多端同步WebSocket 在高并发场景下的瓶颈往往不是 CPU而是内存和文件描述符。每一条长连接都要占用一个 TCP 连接对应的文件描述符、一个会话对象、若干缓冲区。当连接数达到十万甚至百万级别会话管理就变得非常关键。4.1 会话注册表与分布式扩展单机 WebSocket 服务能扛住的连接数有限金融交易系统通常需要多节点部署。客户端通过负载均衡器如 Nginx、SLB连接到不同节点那么问题来了用户 A 连接在节点 1但订单状态更新事件可能落在节点 2节点 2 怎么把消息推给用户 A我常用的方案是“本地会话 Redis 路由表”两级设计。每个 WebSocket 节点维护一份本地ConcurrentHashMapuserId, WebSocketSession同时把userId - nodeId的映射关系注册到 Redis。推送消息时先查 Redis 路由表找到目标节点再通过节点间的内部 RPC 把推送请求转发过去由目标节点从本地会话表中取出连接执行推送。public class WebSocketSessionManager { private static final MapLong, WebSocketSession LOCAL_SESSIONS new ConcurrentHashMap(); private static final String ROUTE_KEY ws:route:; public static void register(Long userId, WebSocketSession session, String nodeId) { // 如果旧连接存在先关闭旧连接防止一个用户多个连接的混乱状态 WebSocketSession oldSession LOCAL_SESSIONS.put(userId, session); if (oldSession ! null oldSession.isOpen()) { oldSession.close(); } redisTemplate.opsForValue().set(ROUTE_KEY userId, nodeId); } }4.2 心跳机制与半连接清理TCP 长连接有一个常见问题——死链。客户端断网、进程被杀、网络中间设备断开服务端可能很长时间感知不到。如果不做心跳检测这些死连接会持续占用服务端资源直到 TCP 超时可能长达数十分钟甚至更久。WebSocket 协议本身定义了 Ping/Pong 帧。我推荐服务端每 30 秒发送一次 Ping 帧客户端收到后自动回 Pong大多数成熟客户端库会自动响应。服务端维护每个连接的最后活跃时间如果超过 90 秒3 个心跳周期没有收到任何消息就主动关闭该连接并清理会话注册表。注意心跳帧是协议层的不应该经过业务线程池。Ping/Pong 的处理要放在 IO 线程直接响应否则高并发下业务线程池一旦拥堵心跳也会延迟造成误判踢线。压测时容易遗漏这一点如果业务线程池发生阻塞心跳也会跟着延迟导致大量正常用户被误判为死连接而踢下线。所以心跳超时阈值要大于业务峰值响应时间。我通常设置的心跳阈值是“业务 P99 延迟的 10 倍但最小不低于 60 秒”。4.3 多端登录与连接互踢策略金融账户经常出现“手机端 Web 端同时在线”的需求。服务端要决定是允许多端同时连接还是同一时刻只允许一个连接在线我建议的策略是“同端互踢、异端共存”。即同一种设备类型比如 iOS App只允许一个连接在线新的连接建立后旧连接主动关闭而 iOS App 和 Web 端可以同时在线。这样做的好处是手机端切换网络WiFi 切 4G导致连接重建时不会误伤 Web 端同时又保证同一设备的重复登录不会积累垃圾连接。互踢消息要单独设计一个 PUSH 类型。新连接建立成功后先查旧连接并推送一条“您的账号在其他设备登录”的消息稍后再关闭旧连接防止客户端来不及展示提示就被断了。4.4 消息体大小限制与内存保护WebSocket 消息帧理论上可以非常大但业务场景中单条消息超过 1MB 基本就是异常了。建议在 WebSocket 配置中设置消息大小上限如 256KB超过上限直接拒绝连接或丢弃消息防止恶意客户端发送超大消息把服务端内存打爆。我曾经线上遇到过一次 P99 延迟飙升排查到最后发现是有客户端代码写入了大量日志到 WebSocket 通道单条消息达到几十 MB导致 IO 线程阻塞在解压和分配内存上。给消息大小加上限之后服务端内存直接下降 40%延迟也恢复正常。5. 低延迟优化实践与典型异常处理既然标题强调“低延迟”那就必须聊清楚延迟到底从哪里来、怎么优化。很多人一谈低延迟就说“用 Netty”“调内核参数”但实际排查发现大部分延迟都不是网络或框架造成的而是业务代码里的不合理设计。5.1 JVM 参数与 GC 调优Java 服务在高并发长连接场景下最大的延迟大头之一是垃圾回收GC。尤其是对象频繁创建比如每个消息都 new 一个 DTO时Young GC 会很频繁如果内存分配不当还可能触发 Full GC导致全局停顿STW延迟出现尖刺。我的经验是低延迟场景优先使用 G1 收集器并且不要让堆内存“能多大就多大”。堆内存过大GC 时间会变长堆内存过小GC 频率又太高。网络上常有人推荐最大堆 4G但具体还要看连接数和消息频率。以下几个 JVM 参数是我在交易类项目中常用的一组基线Java 17G1-Xms4g -Xmx4g -XX:UseG1GC -XX:MaxGCPauseMillis50 -XX:ParallelRefProcEnabled -XX:-TieredCompilation -Xss512kMaxGCPauseMillis 设为 50ms 是一个参考值实际使用时要结合压测数据调整。如果发现 GC 日志中平均暂停时间已经 20ms可以保持如果经常超过 50ms就要考虑增加堆内存或优化对象的创建逻辑比如对象复用、逃逸分析。5.2 避免在 IO 线程做任何阻塞操作这条被无数人讲过但我还是要强调一次启动压测之前先在代码里搜索一遍保证 WebSocket 消息处理链路中没有以下操作——数据库同步查询、Redis 同步调用、外部 HTTP 调用、Thread.sleep、加锁等待、大数组复制。这些操作出现在 IO 线程里会直接把 Netty 的 EventLoop 卡死一个用户慢会影响同一 EventLoop 上成百上千个用户。我见过一个典型事故上线前压测一切正常上线后 P99 延迟从 20ms 飙升到 3 秒。排查发现是某个开发同学在消息处理器里加了一个同步的日志上报 HTTP 调用这个 HTTP 调用偶尔超时 30 秒直接把 Netty 的 IO 线程拖死了。改成异步上报后延迟立刻恢复正常。5.3 客户端断线重连与消息补偿为了追求低延迟把重连机制设计得太简单是金融项目的忌讳。客户端断线后不能只做“重新连上”这个动作还必须做“状态同步”。我推荐的重连流程是这样的客户端检测到连接断开包括心跳超时立即进入重连等待状态退避策略从 1 秒开始每次翻倍最大 10 秒。重连成功后先发送一条SESSION_RESTORE消息携带上一次连接的消息序号或时间戳。服务端根据序号把期间产生的未推送消息订单状态变更、成交回报等从数据库中拉出来按序补推。客户端收到补齐消息后更新本地状态再恢复业务操作。这里有一个容易忽略的细节断线期间用户可能已经发了撤单指令但因为连接断了请求根本没到服务端。所以重连完成后客户端应该主动查询一次“所有活跃订单列表”把本地状态和服务端状态做一次完整对账而不是只依赖增量补推。5.4 常见异常场景排查清单以下是金融 WebSocket 服务上线后最常见的几类线上事故及排查方向异常现象可能原因排查手段大量连接被异常断开心跳设置不合理、业务线程池阻塞、负载均衡空闲超时查看服务端日志中的断开原因检查业务线程池队列积压核对 LB 配置的空闲超时时间消息延迟时高时低GC 停顿、IO 线程被阻塞、网络抖动、数据库慢查询同时观察 GC 日志、EventLoop 任务耗时、数据库慢查询日志定位最长的等待点并发一高就 OOM内存泄漏会话未释放、消息体过大、队列无界增长用 MAT 分析堆转储重点检查会话管理器是否及时清理断开的连接重复下单客户端重试机制没有配合幂等键检查订单表是否有唯一索引或业务幂等键确认服务端是否按 msgId 做去重部分用户连不上服务节点本地会话表与 Redis 路由表不一致检查节点注册、注销逻辑确认 Redis 路由表中的 nodeId 是否和实际节点对应还有一个比较隐蔽的坑Linux 系统参数 file-max 和 ulimit。默认情况下的文件描述符上限是 1024高并发 WebSocket 服务至少需要ulimit -n 100000以上。有些系统还需要调整net.ipv4.tcp_tw_reuse、net.core.somaxconn等内核参数否则连接数上去之后新连接会无法建立。5.5 压测方法论与性能目标设定低延迟不能靠感觉要有一组可量化的目标。我做交易类 WebSocket 服务时的压测基线如下指标优化目标单节点最大连接数50,000长连接每秒处理消息数10,000 条以上P99 建连握手延迟 100msP99 下单指令处理延迟 50msP99 状态推送延迟 20ms错误率 0.01%压测工具方面我推荐使用基于 Netty 的轻量级压测框架自己写客户端模拟器或者用 Gatling 的 WebSocket 插件。JMeter 的 WebSocket 支持能应付简单场景但模拟真实用户行为断线重连、多消息类型混合时力不从心。压测时要注意“连接建立阶段”和“稳定运行阶段”分开测。连接建立本身有握手开销如果压测脚本持续反复建连和断连测出来的数据不能代表真实业务场景。正确做法是先建立足够数量的长连接等连接稳定后再发送业务消息来看处理能力和延迟。6. 这套方案的边界与可扩展方向任何架构方案都有边界不是银弹。Java WebSocket 方案适合大多数金融交易场景但也有几个明显的限制需要提前知晓。6.1 不适用场景超大规模广播和极低延迟交易如果你做的是交易所级别的行情广播每秒向百万级用户推送同样的行情数据WebSocket 的单连接单消息模式就不够高效了。这种场景更适合用 UDP 组播或者 TCP 私有协议做行情分发WebSocket 只承担交易指令和私有的回报推送。如果做的是纳秒级高频交易柜台Java 本身也不是最优选择——JVM 的 GC 停顿和 JIT 编译预热是硬伤。但如果你做的是零售端的交易客户端、一般机构级的交易中台P99 在 50ms 以内Java WebSocket 完全够用。6.2 与 Kafka 配合实现削峰填谷回到热搜词里提到的“Kafka 高并发消息处理办法”。在实际交易场景中WebSocket 网关负责接入和推送但真正处理订单的撮合或清算子系统不一定适合扛住瞬时峰值的突发流量。这时候可以在 WebSocket 网管和核心交易系统之间加一层消息队列如 Kafka实现削峰填谷。我的建议是把 Kafka 用在两个位置一是下行推送通道当核心系统产生大量成交回报时先写入 Kafka由 WebSocket 推送服务消费并推送给对应客户端二是交易指令的异步复核通道订单先进入 Kafka由风控或复核服务消费后决定是否批准。注意下单指令不能只走 Kafka 异步流转需要同步确认至少“已接收”状态否则用户体验会很差。6.3 演进路径从单节点到集群再到多机房这套方案先以单节点接通业务然后切成多节点集群最后才能考虑多机房容灾。每一步的改动范围要控制好单节点阶段不引入 Redis 路由表本地会话表足够用 Nginx 做 WebSocket 代理转发。多节点阶段引入 Redis 路由表推送服务的节点间转发、消息补推机制都要同步上线。多机房阶段引入跨机房消息同步核心是保证同一用户的所有操作在同一机房闭环跨机房的连接迁移要做好对客户端的透明处理。扩容的时候有一个重要原则先加连接再加计算。WebSocket 是长连接服务连接数增长对数据库和业务系统的压力是渐进的但消息量和业务复杂度增长的压力很快就会显现。监控指标至少要覆盖连接数、消息吞吐量、P99 延迟、线程池活跃度、GC 频率、Redis 路由表命中率这六项。我最后再分享一个自己踩过的坑上线后第 3 天突然收到大量用户反馈“页面一直转圈下单按钮点了没反应”。排查了很久最终发现是负载均衡器的空闲超时时间设成了 60 秒而客户端心跳是 30 秒、服务端心跳响应也是 30 秒——每一跳都在临界点上一旦某个中间链路延迟抖动连接就被 LB 掐断了。后来把所有心跳和超时参数统一小组评审了一次再没出现过这类问题。像这样的参数联动问题光靠单个服务自身的配置检查不出来一定要把整个链路上的超时参数、心跳参数、重连参数放在一起过一遍找到它们之间的最差情况组合。这才是高并发低延迟系统真正花时间的调优点。
上一篇/下一篇内容由系统自动关联
返回资讯列表 →