1.5 HTTP的前世今生

HTTP是全球最大规模的分布式系统网络的基础之一,也采用了传统的服务器-客户端的通信设计模式。从1.0版本到1.1版本再到2.0版本,HTTP始终占据着分布式系统通信领域重要的一席之地。

1.5.1 HTTP的设计思路

首先,在报文编码方式上,HTTP采用了面向程序员的文本(ASCII)编码方式而非面向计算机的二进制编码方式。该设计非常关键,这是因为文本编码数据很直观,文本编码协议甚至不用编写额外冗长的接口说明文档就很容易被程序员理解,也非常方便我们准备模拟数据编写单元测试,而当线上系统出现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等常见的Web Mail都挂了,因为其无法识别嵌套的邮件附件。

HTTP在1.0版本中引入了一个重要的设计,即在报文中增加了Header属性列表,每个Header都是一个Key/Value键值对,整个Header列表可以被视为一个Map的数据结构,用来在客户端(浏览器)与服务器端传递控制类数据。由于Header与请求或应答的正文内容相互独立,并且用户可以灵活扩展,增加新的Header属性,同时这些Header数据会被HTTP代理服务器透传到远程服务器中,所以用HTTP构建分布式系统具有其他应用层协议没有的独特优势。HTTP最大的优势可以一句话概括为:采用了HTTP作为通信协议的分布式系统天然具备了无侵入性的基础设施能力全面改进的优势。

上述优势使得HTTP在大规模的分布式系统,特别是目前越来越热的云原生系统中得到应用。随着HTTP 2.0的进一步升级和发展,基于HTTP 2.0的微服务架构、服务网格风起云涌。所以理解HTTP,有助于我们深入理解常见的分布式系统架构的设计与原理实现。

1.5.2 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的有效期,有效期越长,用户越方便,但风险越大:

Java开发人员最熟悉的是下面这种Set-Cookie例子:

注意,jsessionid是Tomcat服务器用来标识用户的,而其他JEE Server各有各的名称,在PHP中则通常使用phpsessionid。

浏览器在收到服务器的响应时,会检查在响应报文中的Header里是否有Set-Cookie指令,如果有,就会遵守规范,从中抽出相关的Cookie信息,并且在该用户随后的HTTP请求的Header中自动加入新的Cookie Header,再发送给服务器端。下面是一个对应的例子:

服务器在收到上述请求后,就会检查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中呢?会带来哪些意想不到的好处?又面临哪些新问题?欢迎探讨。

1.5.3 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为例,定义如下数据结构(对象):

以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存储瓶颈。

1.5.4 再谈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的相关技术。

1.5.5 分布式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集群架构案例。

1.5.6 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证书即可实现。