Loading [MathJax]/jax/output/CommonHTML/config.js
首页
学习
活动
专区
圈层
工具
发布
首页
学习
活动
专区
圈层
工具
MCP广场
社区首页 >专栏 >Web端实时通信技术SSE在携程机票业务中的实践应用

Web端实时通信技术SSE在携程机票业务中的实践应用

作者头像
JackJiang
发布于 2025-05-31 08:42:53
发布于 2025-05-31 08:42:53
2280
举报
文章被收录于专栏:即时通讯技术即时通讯技术

本文由携程前端开发专家Chris Xia分享,关注新技术革新和研发效率提升。

1、引言

本文介绍了携程机票前端基于Server-Sent Events(SSE)实现服务端推送的企业级全链路通用技术解决方案。文章深入探讨了 SSE 技术在应用过程中包括方案对比、技术选型、链路层优化以及实际效果等多维度的技术细节,为类似使用场景提供普适性参考和借鉴。该方案设计目标是实现通用性,适用于各种网络架构和业务场景。

2、技术背景

在如今互联网应用中,实时数据推送已成为很多业务场景的关键技术解决方案。携程机票业务作为在线旅游行业的核心场景,面临着航班数据实时性要求高、信息维度复杂等挑战。

Server-Sent Events(SSE)技术作为一种基于 HTTP 长连接的服务器推送方案,非常适用于机票业务"服务端主动推送、客户端实时展示"的需求特点。

相较于 WebSocket 等双向通信协议,SSE 在实现简单性、协议轻量级和浏览器兼容性等方面具有显著优势,适合机票列表页这类以服务端数据为主导的业务场景。

3、认识SSE

3.1 SSE 是什么?

Server-Sent Events(SSE)服务器发送事件,是一种基于 HTTP 长连接,允许服务器单向实时推送数据到客户端的技术。

SSE 的工作原理非常简单直观。客户端通过与服务器建立一条持久化的 HTTP 连接,然后服务器使用该连接将数据以事件流(event stream)的形式发送给客户端。这些事件流由多个事件(event)组成,每个事件包含一个标识符、类型和数据字段。客户端通过监听事件流来获取最新的数据,并在接收到事件后进行处理。

关于SSE技术的详细介绍可以阅读《SSE技术详解:一种全新的HTML5服务器推送事件技术》。

3.2 SSE 的使用场景

SSE 使用场景非常广泛,大家熟知的 Chatgpt 对话的交互形式使用的就是 SSE 技术。

SSE 在服务器单向实时推送数据的场景非常适用:

  • 1)实时数据流:如股票市场更新、新闻推送、体育比分更新等;
  • 2)实时通知:如社交媒体消息提醒、新订单通知等;
  • 3)仪表盘更新:如系统监控、实时数据统计等。

关于SSE在如今热门的AI大模型技术的中的应用可以阅读:《全民AI时代,大模型客户端和服务端的实时通信到底用什么协议?》、《大模型时代多模型AI网关的架构设计与实现》。

4、先说效果

机票前端首次在核心业务中(机票航班列表)使用 SSE 技术,机票列表页由原先客户端串行请求获取多批次航班数据变为一次请求由服务持续推送数据给客户端。

在调研了公司内外各种实现方案,最终联合携程框架、SRE、机票前后端团队共同实现了全公司通用的SSE技术解决方案(详情见下文中的全链路支持部分)。

1)使用 SSE 前(如下图):

  • 1)客户端需要发起两次请求获取完整航班数据;
  • 2)服务端采用预取优化:在响应第一次请求时,提前获取第二批数据并缓存至 Redis(降低客户端第二次请求响应的耗时);
  • 3)客户端发起第二次请求时,可直接获取缓存数据。

这样的流程和技术方案无疑会提升前后端的代码复杂度,服务端需要额外增加一层缓存来提升响应时间,客户端无法感知服务到底有多少批次数据,需要不断问询。

2)使用 SSE 后(如下图):

客户端发送一次 SSE 请求,服务端实时推送数据到客户端,服务间上下游同样采用流式传输,实现客户端到服务端全链路流式通信。

3)SSE 为前后端带来的价值:

  • 1)减少请求传输耗时:无需请求多次,减少了多次请求的传输耗时;
  • 2)前后端代码结构优化:代码更简洁且易于理解,减少串行请求的回调监听/嵌套;
  • 3)服务逻辑优化:列表数据移除了 redis 的发布订阅流程,简化了代码架构;
  • 4)资源利用率提升:减少冗余请求(只有一批数据时,客户端不用再次请求问询服务)。

4)SSE 对性能有提升吗?

通过分析请求流程(建立链接 -> 发送请求 -> 响应数据传输)和其原理,发现 HTTP 1.1 和 2 支持链路复用,因此链接建立的次数本质上没有变化。在传输通道和数据压缩方式保持不变的情况下,响应数据传输的耗时也不会有明显变化。

SSE 的核心性能优势在于减少了请求发送的次数,其性能增益取决于具体的使用场景:

a)当服务端响应耗时大于网络传输耗时,性能提升有限。

使用 SSE 与传统串行请求的性能实验数据对比:

b)当网络传输耗时大于服务端处理耗时,减少请求次数可以显著降低整体延迟。

5、方案选型

目前市面上很多服务端推送的技术解决方案:SSE、轮询/串行、Websocket 等,我们从易用性,资源开销,使用场景等多维度对比了几个使用较多的主流方案,最终选择了 SSE。

5.1 服务端推送

简单几行代码实现服务端SSE推送。

SSE 的数据传输规范中有 4 个关键字段 event、data、id 和 retry,用于定义和传输事件数据。

具体是:

  • 1)even:定义消息的事件类型,客户端可以根据事件类型触发不同的处理逻辑;
  • 2)data:消息的主体内容;
  • 3)id:为消息设置一个唯一的 ID,用于客户端断线重连时标识最后接收的消息;
  • 4)retry:服务端指定客户端在连接断开后重新连接的时间间隔(单位为毫秒)。

这些字段共同构成了 SSE 消息的基本格式,每条消息以两个换行符 \n\n 结束,确保客户端能够正确解析和处理事件数据。

前端使用样例:

// 创建 EventSource const evtSource = new EventSource("接口地址"); // 监听服务端推送的数据 evtSource.onmessage = function (event) {   console.log("接收到的消息:", event); }; // 监听连接建立 evtSource.onopen = function () {   console.log("连接已建立"); }; // 监听报错 evtSource.onerror = function (err) {   console.error("发生异常:", err); };

服务使用样例(以 Nodejs 为例):

const http = require("http"); http .createServer((req, res) => {     // 设置Response Header     res.writeHead(200, {       "Content-Type": "text/event-stream",       "Cache-Control": "no-cache",       Connection: "keep-alive",     });     // 不断推送数据给客户端     const pushData = setInterval(() => {       res.write(data);     }, 1000);     req.on("close", () => clearInterval(pushData));   })   .listen(3000);

5.2 内部SSE实践方案

调研发现公司内部有两套实践方案:

  • 1)自定义响应式网关:实现网关轮询服务批量获取数据,从而实现流式传输。绕开公司链路层,没有通用性;
  • 2)前端轮询下沉BFF(服务):前端与BFF建立SSE通道,BFF不断轮询向上游批量获取数据。轮询位置发生变化,并未实现全链路的流式通信。

在携程企业级网络生态架构下,从通用性和完整度分析对比了两套方案,并没有真正意义上从前到后打通整条链路。

仅仅只是简单接入SSE是远远不够的,离不开全链路(SSE技术选型,多层网络架构的适配,服务间的流式通信等等)的支持,所以最终决定联合携程框架、SRE、机票前后端团队共同来实现对SSE全链路的适配,真正意义上实现全公司通用的普适方案。

5.3 SSE技术选型

确定好整体技术方案后,我们在实际测试过程中发现了 2 个 Web 原生 SSE 的局限性问题。

具体是:

  • 1)仅支持 Get 请求:对需要传递一些复杂请求体的场景不友好;
  • 2)不支持自定义 http header:无法支持自定义 header 透传,鉴权等场景,目前市面大部分解决方案是使用 Cookie 来携带自定义参数。

针对上述问题,调研发现微软开源的 SSE 网络库 @microsoft/fetch-event-source(以下简称 fes)能够很好的解决。fes 是基于 Fetch 和 ReadableStream 来实现的 SSE 功能,旨在提供更加灵活便利的调用方式。

原生 SSE 和 fes 的对比:

fetch-event-source 详解:fes 的核心原理是通过 Fetch 发送请求,ReadableStream 读取响应流,在 JS 侧实现字节流数据的解析。通过对比原生 SSE(chromium 内核中 EventSource)和 fes 的代码,发现整体流程与实现方案大致相同,关键区别在于流的解析,原生 SSE 在浏览器内核由 C++实现,fes 在 JS 侧实现。

fes 的流解析:

  • 1)核心方法:getBytes、getLines  和  getMessages;
  • 2)getBytes:通过 ReadableStream 读取响应字节流,获取每个字节块;
  • 3)getLines:将 getBytes 获取到的字节块解析为 EventSource 行缓冲区,处理这些字节块并解析为行,然后调用  onLine  回调函数处理每一行;
  • 4)getMessages:创建 EventSourceMessage 对象,将行缓冲区数据解析并进行组装,处理完成后回调给调用方。

export async function getBytes(stream: ReadableStream<Uint8Array>, onChunk: (arr: Uint8Array) => void) {     const reader = stream.getReader();     let result: ReadableStreamDefaultReadResult<Uint8Array>;     while (!(result = await reader.read()).done) {         onChunk(result.value);     } }

export function getMessages(     onId: (id: string) => void,     onRetry: (retry: number) => void,     onMessage?: (msg: EventSourceMessage) => void ) {     let message = newMessage();     const decoder = new TextDecoder();     // return a function that can process each incoming line buffer:     return function onLine(line: Uint8Array, fieldLength: number) {         if (line.length === 0) {             // empty line denotes end of message. Trigger the callback and start a new message:             onMessage?.(message);             message = newMessage();         } else if (fieldLength > 0) { // exclude comments and lines with no values             // line is of format "<field>:<value>" or "<field>: <value>"             // [url=https://html.spec.whatwg.org/multipage/server-sent-events.html#event-stream-interpretation]https://html.spec.whatwg.org/mul ... ream-interpretation[/url]             const field = decoder.decode(line.subarray(0, fieldLength));             const valueOffset = fieldLength + (line[fieldLength + 1] === ControlChars.Space ? 2 : 1);             const value = decoder.decode(line.subarray(valueOffset));             switch (field) {                 case 'data':                     // if this message already has data, append the new value to the old.                     // otherwise, just set to the new value:                     message.data = message.data                         ? message.data + '\n' + value                         : value;                     break;                 case 'event':                     message.event = value;                     break;                 case 'id':                     onId(message.id = value);                     break;                 case 'retry':                     const retry = parseInt(value, 10);                     if (!isNaN(retry)) {                         onRetry(message.retry = retry);                     }                     break;             }         }     } }

export function getLines(onLine: (line: Uint8Array, fieldLength: number) => void) {     let buffer: Uint8Array | undefined;     let position: number; // current read position     let fieldLength: number; // length of the `field` portion of the line     let discardTrailingNewline = false;     return function onChunk(arr: Uint8Array) {         if (buffer === undefined) {             buffer = arr;             position = 0;             fieldLength = -1;         } else {             buffer = concat(buffer, arr);         }         const bufLength = buffer.length;         let lineStart = 0; // index where the current line starts         while (position < bufLength) {             if (discardTrailingNewline) {                 if (buffer[position] === ControlChars.NewLine) {                     lineStart = ++position; // skip to next char                 }                 discardTrailingNewline = false;             }             let lineEnd = -1; // index of the \r or \n char             for (; position < bufLength && lineEnd === -1; ++position) {                 switch (buffer[position]) {                     case ControlChars.Colon:                         if (fieldLength === -1) { // first colon in line                             fieldLength = position - lineStart;                         }                         break;                     case ControlChars.CarriageReturn:                         discardTrailingNewline = true;                     case ControlChars.NewLine:                         lineEnd = position;                         break;                 }             }             if (lineEnd === -1) {                 break;             }             onLine(buffer.subarray(lineStart, lineEnd), fieldLength);             lineStart = position; // we're now on the next line             fieldLength = -1;         }         if (lineStart === bufLength) {             buffer = undefined; // we've finished reading it         } else if (lineStart !== 0) {             buffer = buffer.subarray(lineStart);             position -= lineStart;         }     } }

6、全链路打通

企业级应用时,在非直连多层网络架构的环境下,应用SSE不仅需要考虑前后端的使用,还需要考虑链路层、框架层、数据层等多环节的支持。通过不同团队(如框架、SRE、机票前端和后端团队)的协作,开发出一个在公司范围内通用的解决方案。

6.1 链路层

在携程海外上云、多地多活服务架构、多层网络架构的背景下,携程框架及SRE团队提供了大力支持,完整打通了各链路层之间的流式传输。

多层网络架构:

  • 1)7层加速节点(akamai/aws):提供全球范围内的快速数据传输;
  • 2)流量接入层(slb):确保高可用性和负载均衡
  • 3)中间转发节点(虫洞):优化跨Region数据传输路径,减少延迟;
  • 4)sidecar(envoy/nginx):容器流量管理,增强了应用的可维护性和扩展性。

对于绝大部分负载均衡,一般只保证完整报文的交付,并不保证报文的交付形式(流式/聚合),聚合场景下会导致"数据碎片"被聚合再交付,无法实现流式分批传输(如下图所示)。

以Nginx为例:Nginx 会缓存代理服务器的响应(聚合类型),服务推送的数据被 Nginx 缓存到缓冲区,导致客户端没有实时收到数据,而是等到服务所有数据推送完后,客户端才一次性收到了所有数据。

适配方案:禁用缓存功能,服务端响应时除了设置 SSE 所必须的 Response Header 外,还需要添加非标 Header:X-Accel-Buffering: no,告知 Nginx 不缓存响应,确保数据实时发送到客户端。

值得注意的是:在多层网络架构的环境下 X-Accel-Buffering: no Header 在各层网关之间转发时会丢失,所以在多层网络架构下 Nginx 需要添加 proxy_pass_header X-Accel-Buffering,来确保整条链路上 Header 的传递。

6.2 框架层

前端框架团队基于fes实现SSE网络请求,合并到公司基础网络框架,共享网络优化,监控等基建能力,全公司通用。服务端基于Reactor + Dubbo Streaming实现服务间上下游全链路响应式流式传输。

通过链路层的支持,从前端到服务端实现了统一的全链路流式传输通信,确保数据的高效传输和处理。

6.3 数据层

数据传输需注意代理服务器或 Web 容器(Nginx、Tomcat)对SSE MIME Type:text/event-stream的支持,未正确配置,服务端推送的数据不会经过任何压缩,传输数据大,导致客户端响应耗时增加。

适配方案:根据不同的服务器类型进行配置。

Nginx:

Tomcat:

7、全链路打通

本文介绍了 SSE 在携程机票前端全链路企业级应用实践,解决了服务向前端实时推送数据的问题。

通过合理的技术选型、流式数据解析和链路传输层优化,从链路层,框架层,数据层全链路实现全公司通用的普适方案。降低了前后端代码复杂度,提升了资源利用率。

随着流式通信技术的不断发展,SSE 将在更多场景中(覆盖更多客户端,支持更多网络协议)发挥重要作用,为实时数据处理提供更高效的解决方案。(本文已同步发布于:http://www.52im.net/thread-4832-1-1.html)

8、参考资料

[1] 新手入门贴:史上最全Web端即时通讯技术原理详解

[2] Web端即时通讯技术盘点:短轮询、Comet、Websocket、SSE

[3] SSE技术详解:一种全新的HTML5服务器推送事件技术

[4] 使用WebSocket和SSE技术实现Web端消息推送

[5] 详解Web端通信方式的演进:从Ajax、JSONP 到 SSE、Websocket

[6] 网页端IM通信技术快速入门:短轮询、长轮询、SSE、WebSocket

[7] 搞懂现代Web端即时通讯技术一文就够:WebSocket、socket.io、SSE

[8] 全民AI时代,大模型客户端和服务端的实时通信到底用什么协议?

[9] 大模型时代多模型AI网关的架构设计与实现

9、更多Web端即时通讯技术

一文读懂前端技术演进:盘点Web前端20年的技术变迁史

Comet技术详解:基于HTTP长连接的Web端实时通信技术

新手快速入门:WebSocket简明教程

理论联系实际:从零理解WebSocket的通信原理、协议格式、安全性

WebSocket从入门到精通,半小时就够!

LinkedIn的Web端即时通讯实践:实现单机几十万条长连接

Web端即时通讯技术的发展与WebSocket、Socket.io的技术实践

长连接网关技术专题(四):爱奇艺WebSocket实时推送网关技术实践

Web端即时通讯实践干货:如何让你的WebSocket断网重连更快速?

本文系转载,前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文系转载,前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

评论
登录后参与评论
暂无评论
推荐阅读
编辑精选文章
换一批
函数式编程:Lambda 表达式
曾经读过的依然令我感动的句子,生活总是不如意,但往往是在无数痛苦中,但往往是在无数痛苦中,在重重矛盾
RainbowSea
2023/03/06
7370
lambda表达式与函数式编程
lambda表达式是java支持函数式编程的实现方案,很多高级语言已经支持lambda表达式,像python、javascript等。lambda表达式使代码更加简洁,而且在理解了语法之后,可读性也更加好。
搬砖俱乐部
2019/09/25
6400
Java 8的变革:函数式编程和Lambda表达式探索
1.局部变量:Lambda 表达式可以访问它们所在方法的局部变量,但是这些变量必须是隐式最终或实际上是最终的(final)。这意味着变量一旦赋值后不再改变。Lambda 表达式内部不允许修改这些局部变量的值,否则编译器会报错。
忆愿
2025/01/18
1680
Java 8的变革:函数式编程和Lambda表达式探索
java函数式编程Function(java函数式编程实战)
JAVA版本最新的目前已经发布到11了,但目前市面上大多数公司依然在使用Java7之前版本的语法,然而这些编程模式已经渐渐的跟不上新时代的技术了。比如时下潮流前沿spring framework5中的响应式编程就是使用到了函数式编程的风格。
全栈程序员站长
2022/08/02
2.3K0
java函数式编程Function(java函数式编程实战)
Java8函数式编程以及Lambda表达式
尽管距离Java8发布已经过去7、8年的时间,但时至今日仍然有许多公司、项目停留在Java7甚至更早的版本。即使已经开始使用Java8的项目,大多数程序员也仍然采用“传统”的编码方式。
用户1148394
2020/03/25
5310
Java8函数式编程以及Lambda表达式
Java之Lambda表达式与方法引用实战
从JDK1.8开始为了简化使用者进行代码开发,专门提供有Lambda表达式的支持,利用此操作形式可以实现函数式的编程,对于函数式编程比较著名的语言:haskell,Scala,利用函数式的编程可以避免掉面向对象编程之中的一些繁琐的问题。
用户5224393
2019/08/13
5740
Java函数式编程和Lambda表达式
相信大家都使用过面向对象的编程语言,面向对象编程是对数据进 行抽象,而函数式编程是对行为进行抽象。函数式编程让程序员能够写出更加容易阅读的代码。那什么时候函数式编程呢?
程序那些事
2020/07/08
7700
java8系列01——函数式编程思想与Lambda表达式
java8引入了函数式编程,在工作中应用得特别广泛,如果不学习可能会看不懂公司中同事的代码。
半旧518
2022/10/26
4150
java8系列01——函数式编程思想与Lambda表达式
Lambda表达式大揭秘:轻松玩转JDK 8的函数式魔法
Lambda表达式是Java 8中引入的一个核心特性,它提供了一种简洁、灵活的方式来表示一段可以传递的代码。Lambda表达式的本质是一个匿名函数,它允许我们将行为作为方法参数,或者将代码本身作为数据来处理。
王也518
2024/04/16
2810
Java的函数式编程
JDK8开始引入的函数式编程,大大降低了Java编码的复杂度。它是一种编程范式,即一切都是数学函数。在Java中,函数式编程与lambda表达式密不可分。本文从最基础的编译原理的Statements && Expressions讲起,一步步带你深入浅出函数式编程。
Mark Sun
2022/12/31
1K1
java8实战读书笔记:Lambda表达式语法与函数式编程接口
测试:如下语句是否是正确的lambda表达式。 (1) () -> {} (2) () -> "Raoul" (3) () -> {return "Mario";} (4) (Integer i) -> return "Alan" + i; (5) (String s) -> {"IronMan";}
丁威
2019/06/11
5930
java8实战读书笔记:Lambda表达式语法与函数式编程接口
函数式接口:Java 中的函数式编程利器
在现代编程语言中,函数式编程正变得越来越重要。Java 8引入了函数式编程的支持,其中的函数式接口是实现函数式编程的基石。本文将深入探讨函数式接口的概念、注解、自定义、以及常用的函数接口,以帮助您更好地理解和应用这一强大的编程范式。
IT_陈寒
2023/12/13
3840
函数式接口:Java 中的函数式编程利器
深入探寻JAVA8 part1:函数式编程与Lambda表达式
在很久之前粗略的看了一遍《Java8 实战》。客观的来,说这是一本写的非常好的书,它由浅入深的讲解了JAVA8的新特性以及这些新特性所解决的问题。最近重新拾起这本书并且对书中的内容进行深入的挖掘和沉淀。接下来的一段时间将会结合这本书,以及我自己阅读JDK8源码的心路历程,来深入的分析JAVA8是如何支持这么多新的特性的,以及这些特性是如何让Java8成为JAVA历史上一个具有里程碑性质的版本。
眯眯眼的猫头鹰
2019/10/08
6730
Java函数式编程快速入门: Lambda表达式与Stream API
函数式编程(Functional Programming)是一种编程范式。它已经有近60年的历史,因其更适合做并行计算,近年来开始受到大数据开发者的广泛关注。Python、JavaScript等当红语言对函数式编程支持都不错,Scala更是以函数式编程的优势在大数据领域攻城略地,即使是老牌的Java为了适应函数式编程,也加大对函数式编程的支持。未来的程序员或多或少都要了解一些函数式编程思想。本文抛开一些数学推理等各类复杂的概念,从使用的角度带领读者入门函数式编程。
PP鲁
2020/02/26
1.3K0
java8新特性(一):Lambda表达式
Lambda 是一个匿名函数,我们可以把Lambda表达式理解为是一段可以传递的代码(将代码像数据一样进行传递)。可以写出更简洁、更灵活的代码。作为一种更紧凑的代码风格,使Java的语言表达能力得到了提升。
周三不加班
2019/09/03
4320
java8新特性(一):Lambda表达式
Java基础篇(05):函数式编程概念和应用
函数式编程是一种结构化编程的范式,主要思想是把运算过程尽量写成系列嵌套的函数调用。函数编程的概念表述带有很抽象的感觉,可以基于案例看:
知了一笑
2021/03/10
4550
Java基础篇(05):函数式编程概念和应用
JDK1.8新特性之Lambda表达式
Java8中引入了一个新的操作符“ -> ”,该操作符被称为箭头操作符或Lambda操作符,箭头操作符将Lambda表达式拆分成两部分:
程序员波特
2024/01/19
2070
Java8 Lambda表达式.md什么是λ表达式λ表达式的类型λ表达式的使用其它相关概念
为了支持函数式编程,Java 8引入了Lambda表达式. 在Java 8中采用的是内部类来实现Lambda表达式.具体实现代码,可以通过debug看, 同时通过字节码查看工具及反编译工具来验证.
一个会写诗的程序员
2018/08/20
1.7K0
【Java 基础篇】Java Lambda表达式详解
Lambda表达式是Java编程语言中引入的一个强大的特性,它使得编写更加简洁、可读性更强的代码变得更容易。本文将详细介绍Lambda表达式的概念、语法、用法以及示例,以帮助基础的Java开发者理解和应用Lambda表达式。
繁依Fanyi
2023/10/12
7130
【Java 基础篇】Java Lambda表达式详解
Java中lambda表达式详解
上面的代码中,e是一个lambda的对象,根据java的继承的特性,我们可以说e对象的类型是继承自eat接口。而e1是一个正常的匿名类的对象.
付威
2018/12/05
4.8K0
Java中lambda表达式详解
推荐阅读
相关推荐
函数式编程:Lambda 表达式
更多 >
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档