你好,我是四火。
今天我们来谈谈 Ops 的三部曲之二,集群部署。毕竟一台物理机能够承载的请求数是十分有限的,同时,一台物理机还存在着单点故障(Single Point Failure)问题,因此我们通常需要把多台 Web 服务器组成集群,来提供服务。
还记得我们在 [第 28 讲] 中介绍的反向代理吗?负载分担,又叫负载均衡,也就是 Load Balancer,就是反向代理设备中非常常见的一种,它可以高效地将访问请求按某种策略发送到内网相应的后端服务器上,但是对外却只暴露单一的一个地址。
除了作为名词特指设备,负载分担还可以作为动词指用来分配请求负载的行为,它可大可小,小可以到使用 F5 等负载均衡的专用设备来将请求映射到几台服务器上,但也可以大到用 DNS 来实现广域网的路由。例如同样访问 www.google.com,DNS 会对于不同地区的人解析为不同且就近的 IP 地址,而每个 IP 地址,实际又是一个更小的负载分担的子网和服务器集群。
上图演示了这样一个过程:
对于 DNS 的记录查询,我们曾在 [第 21 讲] 动手实践过,如果你忘记了可以回看。
负载分担需要把请求发送到相应的服务器上,但是怎么选择那一台服务器,这里就涉及到策略算法了,这个算法可以很简单,也可以非常复杂。常见的包括这样几种:
从上面负载分担的策略算法可以看出,大部分的策略算法是适合服务器“无状态”的场景,换言之,来自于同一个浏览器的请求,很可能这一个被转发给了服务器 1,紧接着的下一个就被转给服务器 2 了。在没有状态的情况下,这第二个请求被转给哪台服务器都无所谓。
对于很多 Web 业务来说,我们恰恰希望服务器是“有状态”的。比如说,登陆是一个消耗资源较为明显的行为,在登陆的过程中,服务器要进行鉴权,去数据库查询用户的权限,获取用户的信息等操作。那么用户在登陆以后,一定时间内活跃的访问,我们希望可以直接完成,而不需要再次进行重复的鉴权、权限查询和用户信息获取等操作。
这就需要服务端存储的“会话”(Session)对象来实现了。Web 服务器在内存中存放一个临时对象,这个对象就可以存放针对特定用户的具体信息,比如上面提到的用户信息和用户权限信息等等。这样,当用户再一次的请求访问到来的时候,就可以优先去会话对象中查看,如果用户已经登录,已经具备了这些信息,那么就不需要再执行这些重复的鉴权、信息获取等操作了,从而省下大量的资源。
当然,我们也不知道用户什么时候就停止了对网站的使用,他可能会主动“登出”,这就要求我们主动将会话过期或销毁;他也可能默默地离开,这就需要一个会话管理的超时机制,在一定时间以后也要“被动”销毁这个会话,以避免会话信息无效的资源占用或潜在的安全隐患,这个时间就叫做会话超时时间。
说完了服务端 Session,我再来说说浏览器 Cookie。
浏览器可以以文本的形式在本地存放少量的信息。比如,在最近一次访问服务器、创建会话之后,服务器会生成一个标记用户身份的随机串,这样在这个用户下次访问同一个服务器的时候,就可以带上这个随机串,那么服务器就能够根据这个随机串得知,哦,是老用户到访,欢迎欢迎。
这个随机串,以文本的形式在浏览器端的存储,就被称为 Cookie。这个存储可以仅仅是在内存中的,因而浏览器退出就失效了;也可以存储在硬盘上,那么浏览器重新启动以后,请求发送依然可以携带这个信息。
从这套机制中,你可能已经发现了,它们在努力做的其实就是一件事——给 HTTP 通信填坑。
回想一下,我们已经介绍过 HTTP 版本的天生缺陷。在 [第 02 讲] 我们介绍了,缺乏数据加密传输的安全性大坑,被 HTTPS 给填了;在 [第 03 讲] 我们学习了,只能由客户端主动发起消息传递的交互模式上的坑,被服务端推送等多种技术给填了。
现在,我们来填第三个坑——HTTP 协议本身无法保持状态的坑。既然协议本身无法保持状态,那么协议的两头只好多做一点工作了,而客户端 Cookie 和服务端 Session 都能够保存一定的状态信息,这就让客户端和服务端连续的多次交互,可以建立在一定状态的基础上进行。
集群带来了无单点故障的好处,因为无单点故障,是保证业务不中断的前提。但是,每当有 bug 修复,或是新版本发布,我们就需要将新代码部署到线上环境中,在这种情况下,我们该怎样保证不间断地提供服务呢?
在软件产品上线的实践活动中,有多种新版本的部署策略,它们包括:
既然使用集群,一大目的就是保证可用性,避免停机时间,而上面这六种中的第一种——重建部署,显然是存在停机时间的,因此很少采用。
在互联网大厂(包括我所经历的 Amazon 和 Oracle),对于一般的服务来说,绝大多数服务的部署,采用的都是滚动部署。为什么?我们来看一下其它几项的缺点,你就清楚了。
那对于一般的系统,部署会按照 50% - 50% 进行,即将部署分为两个阶段。第一个阶段,50% 的服务器保持不动,另 50% 的服务器部署新版本;完成后,在第二个阶段,将这 50% 的老版本给更新了,从而达成所有节点的新版本。对于流量比较大的服务,也有采取 33% - 33% - 34% 这样三阶段进行的。
下图来自这篇文章,很好地展示了这个滚动部署渐进的过程:
在应用部署的实践过程中,程序员一般不会忽略对于程序异常引发服务中断的处理。比如说,新版本部署怎样进行 Sanity Test(对于部署后的新版本代码,进行快速而基本的测试),确保其没有大的问题,在测试通过以后再让负载分担把流量引导过来;再比如说,如果新版本出现了较为严重的问题,服务无法支撑,就要“回滚”(Rollback),退回到原有的版本。
但是,我们除了要考虑程序,还要考虑数据,特别是数据和版本的兼容问题。数据造成的问题更大,单纯因为程序有问题还可能回滚,但若数据有问题却是连回滚的机会都没有的。我来举个真实的例子。
某 Web 服务提供了数据的读写功能,现在在新版本的数据 schema 中增加了新的属性“ratio”。于是,相应的,新版本代码也进行了修改,于是无论老数据还是新数据,无论数据的 schema 中有没有这个 ratio,都可以被正确处理。
但是,老代码是无法顺利处理这个新数据加入的 ratio 属性的。在采用滚动部署的过程中,新、老版本的代码同时运行的时候,如果新代码写入了这个带有 ratio 的数据,之后又被老版本代码读取到,就会引发错误。我用一张图来说明这个问题:
读到这里,你可能会说,那采用蓝绿部署等方式,一口气将旧代码切换到新代码不就行了吗?
没错,但是这是在没有异常发生的情况下。事实上,所有的部署都需要考虑异常情况,如果有异常情况,需要回滚代码,这突然变得不可能了——因为新数据已经写到了数据库中,一旦回滚到老代码,这些新数据就会导致程序错误。这依然会让负责部署任务的程序员陷入两难的境地。
因此,从设计开始,我们要就考虑数据和版本的兼容问题:
那如果真是这样,有什么解决办法吗?
有的,虽然这会有一些麻烦。办法就是引入一个新版本 V2 和老版本 V1 之间的中间版本 V1.5,先部署 V1.5,而这个 V1.5 的代码更新就做一件事,去兼容这个新的数据属性 ratio——代码 V1.5 可以同时兼容数据有 ratio 和无 ratio 两种情况,请注意这时候实际的数据还没有 ratio,因此这时候如果出了异常需要回滚代码也是没有任何问题的。
之后再来部署 V2,这样,如果有了异常,可以回滚到 V1.5,这就不会使我们陷入“两难”的境地了。但是,在这完成之后,项目组应该回过头来想一想,为什么 V1 的设计如此僵硬,增加一个新的 ratio 属性就引起了如此之大的数据不兼容问题,后续是否有改进的空间。
今天我详细介绍了负载分担下的集群和新代码部署的方式,也介绍了服务端 Session 和客户端 Cookie 的原理,希望你能有所启发,有效避坑。
现在我来提两个问题:
最后,对于 Session 和 Cookie 的部分,今天还有选修课堂,可以帮助你通过具体实践,理解原理,加深印象,希望你可以继续阅读。如果有体会,或者有问题,欢迎你在留言区留言,我们一起讨论。
还记得 [第 10 讲] 的选修课堂吗?我们来对当时创建的 ${CATALINA_HOME}/webapps/ROOT/WEB-INF/BookServlet.java 稍作修改,在文件开头的地方引入 java.util.logging.Logger 这个日志类,再在 BookServlet 中创建日志对象,最后在我们曾经实现的 doGet 方法中添加打印 Session 中我们存放的上一次访问的 categoryName 信息,完整代码如下:
import java.util.logging.Logger;
import java.io.IOException;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
public class BookServlet extends HttpServlet {
private Logger logger = Logger.getLogger(BookServlet.class.getName());
protected void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
String category = request.getParameter("category");
String lastCategoryName = (String) request.getSession().getAttribute("lastCategoryName");
this.logger.info("Last category name: " + lastCategoryName);
request.getSession().setAttribute("lastCategoryName", category);
request.setAttribute("categoryName", category);
request.getRequestDispatcher("/book.jsp").forward(request, response);
}
}
你看,这里新添加的逻辑主要是,尝试从 Session 中获取 lastCategoryName 并打印,同时把这次请求携带的 category 存放到 Session 中去,以便下次获取。
老规矩,编译一下:
javac BookServlet.java -classpath ${CATALINA_HOME}/lib/servlet-api.jar
现在启动 Tomcat:
catalina run
打开 Chrome,点击菜单栏的“文件”“打开新的隐身窗口”,用这种方式以避免过去访问产生的 Cookies 引发的干扰:
然后,打开开发者工具,并切换到 Network 标签:
接着,访问 http://localhost:8080/books?category=art,你应该可以看到命令行打印了类似这样的日志:
09-Oct-2019 22:03:07.161 INFO [http-nio-8080-exec-1] BookServlet.doGet Last category name: null
这就是说,这次访问 Session 里面的 lastCategoryName 是空。
再来看看 Chrome 开发者工具上的 Network 标签,请求被捕获,并可以看到这个请求的响应中,一个 Set-Cookie 的头,这说明服务器没有发现这个 Session,因此给这个浏览器用户创建了一个 Session 对象,并且生成了一个标记用户身份的随机串(名为 JSESSIONID)传回:
现在再访问 http://localhost:8080/books?category=life,注意这时 URL 中的 category 参数变了。命令行打印:
09-Oct-2019 22:04:25.977 INFO [http-nio-8080-exec-4] BookServlet.doGet Last category name: art
果然,我们把前一次存放的 lastCategoryName 准确打印出来了。
我们还是回到开发者工具的 Network 标签,这次可以看到,请求以 Cookie 头的形式,带上了这个服务器上次传回的 JSESSIONID,也就是因为它,服务器认出了这个“老用户”:
当然,我们可以再访问几次这个 URL,在 Session 超时时间内,只有第一次的访问服务端会在响应的 Set-Cookie 头部放置新生成的 JSESSIONID,而后续来自浏览器的所有请求,都会在 Cookie 头上带上这个 JSESSIONID 以证明自己的身份。
用一张图来揭示这个过程吧:
上图中,浏览器一开始携带的 JSESSIONID=123 已经在服务端过期,因此是一个无效的 JSESSIONID,于是服务端通过 Set-Cookie 返回了一个新的 456。
再联想到我们今天讲到负载分担,负载分担常常支持的一个重要特性被称为“Session Stickiness”,这指的就是,能够根据这个会话的随机串,将请求转发到相应的服务器上。这样,我们就能够保证在集群部署的环境下,来自于同一客户的的请求,可以落到同一服务器上,这实际上是许多业务能够正常进行的前提要求。
Session Stickiness 其实是属于前面介绍的负载分担策略算法中的一部分,它是整个策略算法中的优先策略,即在匹配 Cookie 能够匹配上的情况下,就使用这个策略来选择服务器;但是如果匹配不上,就意味着是一个新的用户,会按照前面介绍的一般策略算法来决定路由。另外,在一些特殊的项目中,我们可能会选择一些其它的优先策略,例如 IP Stickiness,这就是说,使用源 IP 地址来作为优先策略选择服务器。
好,希望你已经完全理解了这套机制。
评论