HTTP是全球最大规模的分布式系统网络的基础之一,也采用了传统的服务器-客户端的通信设计模式。从1.0版本到1.1版本再到2.0版本,HTTP始终占据着分布式系统通信领域重要的一席之地。
一.HTTP的设计思路
首先,在报文编码方式上,HTTP采用了面向程序员的文本(ASClI)编码方式而非面向计算机的二进制编码方式。该设计非常关键,这是因为文本编码数据很直观,文本编码协议甚至不用编写额外冗长的接口说明文档就很容易被程序员理解,也非常方便我们准备模拟数据编写单元测试,而当线上系统出现Bug 时,运维人员也很容易根据客户端记录的文本报文日志来快速定位故障。文本编码协议正因为有这么多优点,所以始终在网络协议中占据着重要的位置,而很多复杂的分布式系统可能会同时采用文本与二进制这两种编码方式的协议。
其次,HTTP是无状态的请求-应答协议。在笔者看来,无状态的设计是个严重缺乏前瞻性的设计,但考虑到在HTTP诞生之初网上没什么资源,也根本不存在可以跟用户交互的网站,因此这个设计思路也是完全可以理解的。最初的HTTP (0.9版)只提供了GET方法,这是因为其作者认为网上所有的资源(网页)都是静态的,远程用户是不能修改的,浏览器所能做的就是从远程服务器上「获取(GET)」指定网页并以只读方式展示给用户,在用户获取网页之后就立即中断与服务器的连接,从而节省宽带和服务器的宝贵资源。
随着Internet 的加速发展,特别是图片和音视频等多媒体内容的出现和流行,原先只面向文本资源对象的HTTP已不能满足人们的需求,所以HTTP做了一个较大的升级(1.0版本)︰首先,增加了POST方法,使得客户端可以提交(上传)文件到服务器端;其次,通过引入Content-Type这个Header,支持除文本外的多媒体数据的传输支持。需要注意的是,此时在HTTP的报文里是可以出现二进制数据的,比如文件附件,但从整体来看,HTTP报文仍然是文本协议的报文,只是在报文的尾部可以增加一些二进制编码数据。在增加POST方法并且支持文件上传功能之后,在 HTTP里出现了一个概率Bug的设计。原本的问题如下:如果用户通过POST方式可以上传多个文件,那么我们应该怎么设计HTTP来支持它?
一个非计算机系的人面对这个问题,可能会这样考虑:既然HTTP一开始是没有考虑二进制传输的,那么现在的确存在二进制传输这种新的需求,所以我们应该考虑如何引入新的二进制传输协议来支持此需求,比如文件传输的数据可以用[文件名][长度][文件内容]这样的二进制编码格式定义,就很容易支持多个文件传输。
但对于典型的「IT直男」来说,上面这种突变的设计与之前的设计格格不入,直接违背了他们遵循的一致性审美原则,同时增加了代码实现的复杂度,这就很难让人接受了。所以,他们把电子邮件协议(SMTP&Pop3)中处理二机制附件的做法照搬了过来。电子邮件协议采用的是文本协议,用一个随机生成的boundary字符串来区分多个文件(附件)的数据。这个boundary字符串虽然是随机生成的,也有一定长度,但谁也无法保证它永远不会跟文件内容中的一段字符串重复,这就导致了随机 Bug 的问题。很有意思的是,当初制定电子邮件协议的人们也从程序逻辑思维的角度制定出来一个无限层附件嵌套附件的协议规范。笔者当初开发Java 版的邮件服务器时,特意模拟过一个3层嵌套的电子邮件,结果让163等常见的WebMail 都挂了,因为其无法识别嵌套的邮件附件。
HTTP在1.0版本中引入了一个重要的设计,即在报文中增加了Header属性列表,每个Header都是一个Key/Value 键值对,整个Header列表可以被视为一个Map 的数据结构,用来在客户端(浏览器)与服务器端传递控制类数据。由于Header 与请求或应答的正文内容相互独立,并且用户可以灵活扩展,增加新的Header属性,同时这些Header数据会被HTTP代理服务器透传到远程服务器中,所以用HTTP构建分布式系统具有其他应用层协议没有的独特优势。HTTP最大的优势可以一句话概括为:采用了HTTP作为通信协议的分布式系统天然具备了无侵入性的基础设施能力全面改进的优势。
上述优势使得HTTP在大规模的分布式系统,特别是目前越来越热的云原生系统中得到应用。随着HTTP2.0的进一步升级和发展,基于HTTP2.0的微服务架构、服务网格风起云涌。所以理解HTTP,有助于我们深入理解常见的分布式系统架构的设计与原理实现。
二.HTTP 如何保持状态
我们都知道,HTTP 在设计之初就是无状态的协议,但随着互联网的快速发展,越来越多的软件开始以 Web 网站的方式提供服务,一个 Web 网站同时服务成千上万个互联网用户。此时,编程人员开始面对一个棘手的问题,即如何识别同一个用户的连续多次的请求?比如在典型的网购行为中,客户登录系统,挑选商品,将商品添加购物车,最后下单付款。一个客户网购的整个过程会涉及几十次甚至上百次的网页交互,这就意味着我们必须为无状态的 HTTP 引入某种状态机制,而具体的实现机制就是 HTTP Cookie。
HTTP Cookie
新增了两个扩展性的 HTTP Header
,其中一个是 Set-Cookie
。
Set-Cookie 是服务端专用的 Header,用来告知客户端(浏览器):「刚才的用户通过了身份验证,我现在设置了一个 Cookie,里面记录了他的身份信息及有效期,你必须把它的内容保存下来,当该用户继续发送请求给我时,你自动在每个请求的 HTTP Header 上添加这个 Cookie 的内容后再发送过来,这样我就可以持续跟踪这个用户的后续请求了,请务必遵守要求,直到 Cooker有效期结束才能删除 Cookie,我不想用户反复登录及证明身份。」
下面是一个典型的 Set-Cookie 的完整内容,其中,id 给出了用户的标识,Expires 部分则指出了该 Cookie 的有效期,有效期越长,用户越方便,但风险越大:
Set-Cookie: id=a3fwa; Expires=Wed,21 Oct 2015 07:28:00 GMT;
Java 开发人员最熟悉的是下面这种 Set-Cookie 例子:
Set-Cookie: jsessionid=5AC6268DD8D4D5D1FDF5D41E9F2FD960;Expires-wed,lct2015 07:28:00GMT;Secure; HttpOnly
注意,jsessionid 是 Tomcat 服务器用来标识用户的,而其他 JEE Server 各有各的名称,在 PHP 中则通常使用 phpsessionid。
服务器在收到上述请求后,就会检查 Cookie 里的数据,抽取用户 ID 并对应到服务器端的用户会话(Session)对象。通常在 Session 中会保存更多的用户数据,比如用户的昵称、角色、权限及更多的特定数据。与在 Cookie 中保存的数据相比,在 Session 中保存的内容通常是一些复杂的对象和结构体。因此,Cookie 与 Session 的关系再清楚不过了:一个用来在浏览器端保存用户状态数据,一个则用来在服务器端保存用户会话数据,两者相辅相成,实现了有状态的 HTTP。
对于 Cookie,我们需要注意以下事实。
- Set-Cookie 可以多次使用,并且可以放置更多的 Key-Value 数据,其中的每一个 Key-Value 数据项都是一个独立的 Cookie,服务器通常会传送多个不同的 Cookie 到浏览器端,每个 Cookie 都对应特定的业务目标。
- Cookie 的值虽然都是字符串,但可以很长,具体多长呢?RFC 规范没有给出具体的值,但一些测试表明,绝大多数浏览器都支持 4096 个字节长度的 Cookie 的内容。
- Cookies 的内容是需要被保存在浏览器中的,通常浏览器会用本地文件保存这些 Cookie 的内容。同时,服务器端需要提供 Session 对象,因此用户的状态是由浏览器与服务器双方配合实现的,任何一方的缺失都会导致用户状态信息的缺失。
- 在 Cookies 中不要存储用户的敏感(机密)信息,特别注意不要存储用户的明文密码,但可以考虑存储某种安全加密的信息,并且定期自动更新,避免被盗用和破解。
一个有趣的问题:在开发电商(类似的)系统时,我们是否可以把用户的购物车列表数据放入 Cookie 中呢?会带来哪些意想不到的好处?又面临哪些新问题?欢迎探讨。
三.Session 的秘密
对于很多 Web 开发人员甚至架构师来说,服务器端的 Session 很神秘:只知道应用服务器会给每个用户都创建一个 Session 会话来保持其状态,可以放置任意对象到 Session 中,也可以查找这些对象来实现业务逻辑判断并渲染用户页面,但往往不太清楚其具体工作原理和工作机制。
1.Session 究竟是什么
Cookie 是由 RFC6265 标准规范规定的一个概念,有对应的呈现标准和呈现方式,总体来说,我们可以将 Cookie 理解为 HTTP 的一部分,因此所有人都可以准确理解、表达并且进行标准化实现。与 Cookie 不同,Session 属于 Web 应用开发中一个抽象的概念,它对应 Cookie,用来在应用服务器端表示和保存用户的信息。但是,Session 并没有标准化的定义及实现方式,因此在不同的 Web 编程语言里都有不同的理解和实现方式,即使在同一种 Web 编程语言中,不同的应用服务器的实现方式也有所不同。这就导致了一个显而易见的事实:不同厂家的应用服务器不通过某种第三方手段是无法做到「单点登录」的,虽然单点登录存在 Session、鉴权和相互信任的复杂问题。
2.Session 是在什么时候被创建的
从前一节 Cookie 的分析中我们知道,Set-Cookie 指令是服务器第一次验证用户身份后回应给浏览器的,此时服务器已经生成用户的身份信息(如 jsessionid),因此我们可以确定一个事实:该用户对应的 Session 会话此时也生成了,并且由我们的 Web Server 控制整个生命周期。
3.Session 中的数据被存储在哪里
Session 中的数据通常被存储在应用服务器的内存中,准确理解这一点对于我们编程和设计架构来说很关键!哪些用户数据适合被放在 Session 中?能放多少数据?什么时候清理这些数据?对于这些问题的答案,需要综合考虑业务层面的要求、性能及内存占用等几个关键因素。
这里主要分析 Session 中数据占用服务器内存对系统所造成的影响,因为我们在具体实践中经常忽略了这个问题,导致 Session 被滥用。在用户量突然增加以后,很多系统都无法支撑高并发,会出现内存溢出的严重问题,而且这个问题很难从根本上解决,只能在前期加以规范和引导并在开发阶段予以杜绝。
以电商系统的购物车为例,如果我们把用户购物车对象放入 Session 中,则以 Java 为例,定义如下数据结构(对象):
public class ShopCartItem {
private Long id;
private String title;
private Long picId;
private Double price;
private short count;
}
以 ShopCartItem 的 title 为 10 个中文字符为例,则上述Java 对象占据的实际内存将超过 2000 个字节,而不是几十个字节!一个用户的购物车里平均有 5 件商品,则每个用户的购物车对象占用的内存超过 1 万个字节,如果我们有 10 万个用户,则仅这些用户的购物车对象占用的内存将达到 1GB 左右!考虑到这还是个很简单的 Java 对象,当我们把某些翻页查询的结果集都随意放入 Session 中时,后果会有多严重?这也是为什么目前 Go 这种非面向对象的编程语言会在 Web 服务器领域发力并且对 Java 造成一定的冲击。
从上面的分析结果来看,面对大规模的用户访问,我们能做的有以下几方面。
- 尽可能少放「大尺寸」的数据在用户 Session 中,并且尽可能及早清除无效数据,释放 Session 占用的内存。
- 考虑到把更多的 Session 数据转移到浏览器端的 Cookie 中,所以通过「甩锅」方式减少服务器端的压力。
- 前端积极采用 HTML5 技术,Cookie 不适合用于大量数据的存储,并且 Cookie 每次都会被增加到 HTTP 的请求头中并传输到服务器端,这也增加了网络流量的压力,因此 HTML5 提供了在用户端的浏览器中存储数据的新方法:localStorage 与 sessionStorage,后者就是专门解决服务器端 Session 存储难题的「利器」。
- 考虑到引入分布式存储机制,所以可以采用集群方式来应对单一服务器的 Session 存储瓶颈。
四.再谈 Token
通过前面的学习,我们知道用户会话中的用户身份标识(如 SessionID)信息被存放在 Cookie 中并保留在用户端的浏览器上。实际上,Cookie 的内容是被存放在磁盘中的,其他人是有可能直接访问到 Cookie 文件的;另外,Cookie 中的信息是明文保存的,意味着攻击者可以通过猜测并伪造 Cookie 数据破解系统。避免这种漏洞的直接防护手段就是用数字证书对敏感数据进行加密签名,在加密签名后这串字符串就是我们所说的 Token,这样攻击者就无法伪造 Token 了,因此 Token 在本质上是 Session(SessionID)的改进版。与 Session 将用户状态保留在服务器端的常规做法不同,Token 机制则把用户状态信息保存在 Token 字符串里,服务器端不再维护客户状态,服务器端就可以做到无状态,集群也更容易扩展。那么,Token 数据是被放在哪里的呢?标准的做法是将其放在专用的 HTTP Header「X-Auth-Token」中保存并传输,但客户端在拿到 Token 以后可以将其在本地保存,比如在 App 程序中,Token 信息可以被保存在手机中,而 Web 应用中,Token 也可以被保存到 H5 的 localstorage 中。需要注意,Token 与 Cookie 是完全无关的!总结下来,Token 有以下特点。
- 在 Token 中包含足够多的用户信息,JWT 能轻松实现单点登录,因为用户的状态已经被传送到了客户端。
- 不存在 Cookie 跨域的限制问题,也不存在 Cookie 相关的一些攻击漏洞,例如 CSRF。
- 因为有签名,所以 JWT 可以防止被篡改。
- 适用于 API 的安全机制,适用于移动客户端与 PC 客户端的开发,此时 Cookie 是不被支持的;Token 方案则简单有效,可以用一套 Token 认证代码来应对浏览器类客户端和非浏览器类客户端。
- Token 已经标准化,有成熟的标准化规范——JSON Web Token(JWT),多种主流语言也都提供了支持(如.NET、Ruby、Java、Python、PHP)。
目前被广泛使用的 JWT 规范是一个轻量级的规范,每个完整的 JWT 对象实际上都是一个字符串,它由三部分组成:头部(Header)、载荷(Payload)与签名(Signature),其中 Header 声明了该 JWT 所用的签名算法是哪种:对称加密算法(HMAC SHA256,简称 HS256)还是非对称加密算法(RSA)。需要注意的是,虽然 JWT 支持对称加密算法来做签名,但正常情况下,我们都应该使用非对称加密算法即私钥来签名,并且我们要妥善保管私钥,谨防泄密,客户端用公钥证书去验证签名。Payload 部分是我们重点关注的内容,我们可以将 Payload 理解为一个 Map 字典,里面的 exp 字段表明 JWT 的失效时间,是确保安全的重要字段。此外,我们可以在 Payload 里增加自定义的私有字段,用来保存更多的用户特定信息,特别注意的是,Payload 是明文传输的,所以我们不能把私密信息放入 Payload 里,比如用户密码。Signature 部分则是将 Header 与 Payload 的内容加在一起,用在 Header 里声明的签名算法进行签名而得到的一个字符串,即完整的 JWT 字符串组成为:Header(明文).Payload(明文).Signature(签名/密文)。
任何一方在收到这个 JWT 字符串的 Token 后,都可以通过解析得到 Header 与 Payload 的完整内容。为了证实这两段信息是否是某个组织所发出的真实信息,我们可以用该组织的公钥证书对签名信息进行验证。JWT 标准是不加密的,但我们可以再加密,即在生成原始 JWT Token 后再把这个字符串当作普通字符串加密,但这种做法的意义不大,因为加密解密会涉及大量 CPU 计算,增加系统的负载。此外,我们需要注意,JWT 一旦签发,在有效期内将会一直有效,有效期的长短也会影响安全的级别。因此,可考虑不同安全等级要求的 API 接口给予不同时效的 Token,对于某些重要的 API 接口,用户在使用时应该每次都进行身份验证。为了减少盗用和窃取,JWT 不建议使用 HTTP 来传输代码,而是使用加密的 HTTPS 传输代码。
如何采用 JWT Token 机制代替普通的 Session 机制呢?答案很简单,就是在用户访问时拦截请求,检查 HTTP Header 中的 Token 是否有效,如果无效则重定向到登录界面,在登录成功后,服务端生成 JWT Token 并将其放入 Header 中返还客户端,客户端保存 JWT Token 并在随后的请求里带上 Header 发起访问即可,如下图所示。注意,如果 Token 接近失效时间,则需要重新访问服务端获取新的 Token。
(JWT)Token 也多用于服务网关的鉴权架构中,如下图所示。
Client 在访问系统的内部 Service 时,通过 API 网关来完成统一的服务鉴权功能。首先,Client 通过 Auth Server 获取合法的 Token;然后,持有此 Token,在后面发起服务调用请求时都带上此 Token;在 API 网关拦截到请求时,先验证 Token 的有效性,再转发请求到具体的 Service。
如果想要更深入地了解 JWT 相关的技术与应用,则建议继续学习 OAuth 2.0 与 OpenID 的相关技术。
五.分布式 Session
最早的成熟的分布式 Session 技术被应用于 J2EE 领域,主要采用 Session 复制的技术(Session Replication)将用户会话的数据复制到 J2EE 集群的其他机器上。考虑到复制的代价和内存占用成本,一个用户的 Session 通常只会被复制到集群中的一台服务器上,即主从复制模式。这种方式类似于 MySQL 的主从复制技术,当集群中的机器数量多于 2 台时,必须要求前置的负载均衡器(软件或硬件)支持会话亲和性(Session Affinity),可以准确地把不同用户的请求转到对应的两台服务器上。应用 Session 复制技术的典型代表之一是 Weblogic Server,但是 Session 复制的技术从总体来看相对复杂,而且集群的整体性能下降很明显,因此在 J2EE 领域之外很少被使用。
另外一种应用更广泛的分布式 Session 技术就是把 Session 数据彻底从应用服务器中「剥离」,单独集中存储在外部的内存中间件(如 Redis、Memcache、JBossCache)中,这样做的好处是整体架构更加清晰,也更加灵活,集群的数量可以轻松达到几十台甚至上百台的规模。同时,整个系统的运维变得更有条理性,故障排查和故障恢复也更为容易。采用这种分布式 Session 的系统,其整体规模、性能、特性主要取决于不同的后端存储中间件的能力。目前应用非常广泛的后端存储为 Redis,并通过 Redis 集群来获取更大规模的用户量支持能力。
如下图所示是一个典型的基于 Spring Boot 的分布式 Session 集群架构案例。
六.HTTP 与 Service Mesh
Service Mesh 可以说是当前最热门的一个架构了,自带云原生的光环,一经问世就立刻吸引了 Google 与 IBM 这两个软件巨头,他们联合发起了相关的重量级开源项目——Istio。不过,在 Service Mesh 的各种实现类产品中一致选择了 HTTP 作为服务之间的基础通信协议,而不是其他二进制通信协议。并且,Service Mesh 的核心功能或特性几乎全部依赖 HTTP 的特性才得以实现。为什么呢?笔者的答案是:围绕 HTTP 建立一个所有编程语言都适用的、高度统一并且足够灵活的微服务架构,是非常容易成功的选择。
所以,Service Mesh 从一开始就是围绕 HTTP 而「精准」构建的新框架!
如下所示是一张简化版的 Service Mesh 架构图,在该图中特意将 SideCar(边车)画成 U 型,这是为了方便表示 Sidecar 其实「包围但又不是完全包裹」它对应的 Service 实例的这一关键特性。即在进程角度,Sidecar 是完全独立的进程(可以是一个或多个),与对应的 Service 实例不产生任何进程和代码级别的纠缠,非常像一个独立进程的代理。
考虑到我们的 Service 其实是一个 HTTP 服务器进程(微服务),我们可以理解把 SideCar 理解为一个特殊定制的 Nginx 代理。另外一个细节需要注意:进入任意一个 Service 实例的请求都要从 SideCar 代理后才能抵达 Service 实例本身,在这个过程中,SideCar 可以做任何 HTTP 能做的事情,比如黑白名单的检查、服务限速及服务路由等功能,这些恰恰就是 Service Mesh 的核心特性之一。而借助于 HTTP 的特性,整个过程无须修改业务代码本身,只需要配置一些规则(类似于 Nginx 的配置)即可生效。
下面以 Service Mesh 的核心功能之—服务路由为例来简单说明其中的实现原理。在如下所示的示例中,Service B 有两个实例。Service B 的虚拟地址为 http://serviceb:8080,两个实例的地址分别为 http://192.168.18.1:8080 及 http://192.168.18.2:8080,当 Service A 调用 Service B 时会有路由选择问题。
此时,Service A 上的 SideCar 会配置类似于如下路由规则(示例):
然后,当 Service A 发出对 Service B 的服务调用(HTTP 请求 http://serviceb:8080)时,Service A 的 SideCar 代理进程先通过 iptables 规则「劫持」这一请求,在对照自己的路由配置规则后发现有两个地址,于是按照默认的轮询机制选择一个目的地址转发出去。比如到达了 192.168.18.2 这台机器,此时 Service B 对应的 SideCar 进程也采用同样方式「劫持」进来的请求流量,在经过一定的处理逻辑后,再转给 Service B 进程处理。这就实现了基本的负载均衡功能。在这个过程中,已有的用户业务进程如 Service A、Service B 等都无须有任何代码改造,这一切都通过基本的 HTTP 代理机制即可完成。其他诸如金丝雀流量控制,比如有 10% 的流量到某个服务的升级版本,有 90% 的流量到老版本,以及基于不同的终端用户(或者 HTTP URL&Param)来实现更细粒度的路由控制,这对 HTTP 的代理来说简直是小菜一碟。
我们知道,大规模的分布式系统都存在一个很难解决的问题,即一旦在运行中出现性能问题或者故障,则很难快速诊断和发现问题的成因。因为在微服务架构下,一个调用链从终端到达最终的服务端,中间可能跨越十几个远程调用,这意味着我们需要把分布在这十几台机器上的独立请求都「精确串联」起来,才能知道问题出在哪个环节。解决这个问题的思路有以下两种。
- 第 1 种思路,在编程时在调用发起的地方手工生成唯一的 TraceID,确保这个 TraceID 被正确传递到后端的所有调用;准确记录每段调用的耗时、是否异常等必要诊断信息,并在日志中打印出来;最后通过日志分析和汇总每条链路的信息。
- 第 2 种思路,采用面向 AOP 编程的思路,由框架来实现第 1 种思路的所有编程。
毫无疑问,第 2 种思路是最好的,但这里面临一个棘手的问题:如何在不侵入业务代码的情况下完整地将每个 TraceID 都传输到后面的调用过程中?答案是用 HTTP 的自定义 Header 来实现统一注入 TraceID 和其他相关参数,并通过 SideCar 的代理拦截能力去实现所需的细节和数据收集工作。
可以说,Service Mesh 之前的任何一种通用的分布式架构都没能完美解决安全问题,除了 ZeroC Ice。很巧的是,二者实现安全机制的做法异曲同工,都是通过自动包裹(代理)SSL 安全连接来实现远程调用的安全加密能力。其中,ZeroC Ice 采用的是 SSL+TCP;Istio 等主流 Service Mesh 的实现则采用了 HTTPS+HTTP 来实现自动加密功能,这些只需简单配置文件和 CA 证书即可实现。
参考:从分布式到微服务