简明入门讲义——如何实现可扩展的 Web 服务

一. 服务器

可扩展的应用服务器(Application Server)集群藏身于负载均衡器(Load balance,LB)背后,LB 将负载(即用户请求)平均地分配到各个组或集群的应用服务器上,此时负载均衡器可能运行在 TCP 层(Layer 4),分配请求的方式默认是简单的轮询(Round-Robin),即假设有服务器 A-D,请求依次从 A 分配到 D,列表循环。

现在,小明向你的 Web 服务发起请求,第一个请求可能被分配到服务器 A,第二个请求可能被分配到服务器 C,要求小明每次请求总能获得相同的返回结果,无论请求最终落到哪个服务器上。

不改变设计,可能出现下面的情况,小明发起第一次请求,填完密码登录,提示 3 天内直接进入无需填写密码。结果再次登录时,路由到另一个服务器,又一次提示小明登录!原因很简单,这台服务器上面没有小明的 Session。

这个示例引出了扩展性的第一个黄金法则:每个服务器都包含完全相同的代码库,不在本地磁盘或内存上存储任何与用户相关的数据,例如会话(Session)或个人资料。

怎么实现会话保持(Sticky Session) 是水平扩展服务器中的常见问题。

假设请求随机到任一服务器,则必须有一个中心化的存储服务用来保存 Session,并且所有应用服务器都可以访问。这项存储服务独立于应用服务器之外,可以是持久化的数据库或者缓存。

如果没有额外的存储服务怎么办,假设现在只有负载均衡器和应用服务器?

我们可以让负载均衡器监听 HTTP 层(Layer 7,应用层) 的请求,当小明第一次请求分配到服务器 A 时,产生一个随机数 r,将它写到 Cookie 中随请求返回。当小明再次请求时,负载均衡器层通过一个哈希函数,计算 Cookie 中的随机数 r,请求即可再次路由到服务器 A。

这个方案节省了存储空间,但引入一个问题,服务器 A 挂掉之后,小明还需重新登录,所幸这不是非常关键的数据,还可以接受。但独立存储也存在自己的问题,最明显的,怎么解决单点问题(Single Point Of Failure)?这个后文再谈。

现在你的关键问题是,如何使多个应用服务器发布时都存有同一份代码?可以借助 capistrano 这个开源项目。

将用户数据移出应用服务器,并解决完全相同代码库问题后,就可以打包为服务器镜像进行统一部署了。

二. 数据库

完成第一步用户数据中心化隔离、代码库同步后,不费多大力气就可以添加多个应用服务器,使你的 Web 服务处理大量并发请求。但 Web 服务还是会变慢甚至挂掉,原因就在中心化的数据库上!

最好从一开始就走反范式的数据设计方式,数据库只做简单的写入和查询操作,其他复杂的操作、约束都通过代码解决。这样你的数据库会更容易进行水平扩展,更方便做迁移,单个数据库实例也不需要很大。

否则,数据量一大,迁移、修改等操作,还是会由于数据库外键约束,导致长时间锁表等问题,或者比反范式设计的数据库消耗更多的内存和 CPU,成本与日俱增。

在进行数据库复制(Replication)时,一般使用主从模式(Master-Slave)。这里的主从采用读写分离,主库负责写,从库定时同步主库的数据,接收读请求。当主库挂掉的时候,从库也不能提升为主库。

为了解决这个问题,在主库上引入双主(Master-Master)或者待命(Standby)模式,双主即两个主库(或者两个集群)都可以接收写请求,无论哪一方收到写请求,另一方会立刻同步。待命模式则同一时刻只有一个接收写请求,另一方只做同步。当主库宕机,待命方将自己升为主库,继续提供服务。

当你引入了多个数据库(集群)时,最好不要通过硬编码(Hard-code)来解决故障重连问题,开发同学没必要了解你的架构拓扑,而且在你扩展或者收缩集群的时候,开发同学可不想跟着你加班发布。

这时同样可以引入负载均衡器来解决扩展问题。如果你还需要根据用户名分区操作,比如小明分到了新手区 Z,小红分配到新手区 X,那么负载均衡器可能解决不了,因为 MySQL 请求内容是二进制的,对 LB 是透明的。

你可以引入分库分表的中间件,在代码层面解决。对于业务开发团队而言,这个中间件的处理过程同样是透明的。

但这个时候请求也只是“可用”,还不够快,是时候考虑引入缓存了。

三. 缓存

相同配置下,以 Redis 为例,缓存在读取和写入上要远胜于 MySQL 这样的关系型数据库。建议只使用 Redis 或者 Memcached 这类基于内存的缓存服务,不要使用基于文件的缓存,这会使数据迁移和复制(水平扩展)变得复杂。

保存缓存数据一般有两种方式

请注意,这里不是在讨论缓存更新的模式,如果感兴趣,可以阅读 缓存更新的套路。

其一是基于数据库查询(SQL-Based)来缓存,不难理解,就是把数据库的查询结果保存到缓存中,键名(Key)可以是查询的 SQL 语句哈希,简单粗暴。但这会存在问题,例如前面我们已经用了反范式的设计,尽量避免使用 JOIN 查询,一个语句有时候解决不了查询,怎么办?

这就有第二种方式,直接缓存对象(Object-Based)。一个请求(多次)查询后的数据在代码中“组装”(Assemble)完毕后。例如一个嵌套的数据结构,查询一个小明的个人信息和他的订单,其中订单数组中是一个个独立的订单对象。可以在代码中将数据组装完毕后,直接缓存整个对象。

想想看如果是第一种,你还需要分开缓存多个查询,下次读缓存还要读两次,再组装数据返回给用户,太麻烦了,用户可等不及!

四. 异步

做完了上面的三个步骤,用户可能还在抱怨我不想等!Web 服务的设计可不能像排队买所谓的网红奶茶一样,让一排用户在原地死等。

想象一下你到一个面包店买蛋糕,有这样的情况:

1.你要的蛋糕已经提前做好了,店员直接给你,交易完成2.你要的蛋糕卖完了,新一批晚上才出炉3.你要的蛋糕有,但你是给小明祝寿的,上面要有小明寿比南山的字。

情形一对应 Web 服务中的第一种异步模式,提前把内容生产好,等用户消费。典型的场景是个人博客、新闻网站等,提前将 HTML 渲染完毕,通过自动的定时任务或者手动执行脚本将内容上传到服务器,必要的时候配合 CDN(Content Delivery Network)进行加速,用户就可以快速的访问你的网页内容了。

情形二、三你肯定不想在蛋糕店干等,而是希望制作完成的时候,蛋糕店通知你来取就可以了。面包店有自己的订单队列,有些订单可能是加急(加钱)订单,面包店还需要优先处理。这对应 Web 服务中第二种异步方式,将用户的请求转化为异步任务在后台排队处理,用户注册一个监听器,处理完成后就会收到通知了。

相关的服务例如 RabbitMQ、Celery 等等。一旦你发现 Web 服务中有需要等的动作,务必将它异步处理。

原创文章,作者:zhang, yanling,如若转载,请注明出处:https://www.yidc.net/archives/16669