B/S 类项目改善

时间:2020-12-09 15:43:25

B/S 类项目改善的一些建议

 

要分享的议题

  1. 性能提升:在访问量逐渐增大的同时,如何增大单台服务器的 PV2 上限,增加 TPS3 ?
  2. RESTful:相较于传统的 SOAP1,RESTful 风格架构有哪些优点?做法有哪些区别?
  3. 微服务:随着企业越来越大,系统会越来越大,越来越难维护,如何在保证“稳”的同时,还保证有小企业的“灵活”?

简要的介绍

性能提升

最常用的性能提高方式可以通过使用服务器的集群来解决,简单粗暴的理解就是增加银行柜员的数量。但是,一味的只考虑从服务端提供性能,并不是聪明的做法 —— 应该讲求性价比。当然,核心必须是提高服务器的 TPS,即在最短的时间内给最多的客户提供服务。服务器集群可以大幅提升整体的性能,但是我们要讨论的是如何提升单台服务器的性能。

  1. 服务器的压力主要来源于三个方面:CPU、网络和磁盘 IO。磁盘作为最容易达到瓶颈的一方,必须想办法减少 IO 操作。数据库作为数据持久性存储、磁盘开销的大户,这里主要就是要减少或合并数据库操作。
  2. 系统的流畅性取决于服务端和客户端的良好配合。网站类的项目,充分利用浏览器资源,不仅能降低服务器压力,还能提供更好地客户体验。现代化的浏览器,一般都符合 RFC26164 规范。其中很重要的报头有:ETag 和 Last-Modified 报头 —— 浏览器的缓存设置开关,可以最大限度的利用客户端资源。

RESTful 架构风格

好比面向过程编程和面向对象编程,这两者并没有明确的界限。在适当的地方用适当风格的架构,重点是物尽其用。但 RESTful 作为新兴的风格,必然有其优势:

  1. 在 RESTful 架构中,关注点在于资源。每个都有一个地址,资源本身就是方法调用的目标。方法列表对所有资源都是一样的。这些方法都是标准方法,包括 HTTP GET、POST、PUT、DELETE,还可能包括 PATCH、HEADER 和 OPTIONS。其指导思想是远端提供了一系列资源,客户端需要下载、展现、编辑和提交更改,重点放在本地。
  2. 在 RPC 架构中,关注点在于方法。在客户端看来,就是在客户端组合条件,然后在服务器中执行,最终再反馈给客户端。其指导思想是隐藏实现细节,或者关联其它 RPC 服务运算,重点放在了服务器。

微服务和单体式应用

现代化的单体式应用,通常采用模块化的方式,围绕核心模块并行开发。最终他们需要联合测试,部署成一个单体式的应用:C# 会部署成 IIS 的一个网站,Java 会打包成 War 格式部署 Tomcat 上。

随着时间的推移,单体式在应对越来越多的新需求后,会变得越来越大。更不幸的是,因为公司资源和需求的不对等,许多仓促应对的代码会添加到应用中。这些代码在短期内不会出现问题,但是修正 Bug 和正常的新功能添加会变得越来越困难,因为通常会涉及到多个模块,牵一发而动全身。此时就是单体式应用的瓶颈期,会考虑拆分成多个子系统。当然,这将会再维持一段时间,直到再出现相似的问题。

许多公司,比如 Amazon、eBay 和 NetFlix,通过采用微处理结构模式解决了上述问题。其思路不是开发一个巨大的单体式的应用,而是将应用分解为小的、互相连接的微服务。

一个微服务一般完成某个特定的功能,比如下单管理、客户管理等等。每一个微服务都有自己的业务逻辑和适配器。一些微服务还会发布 Api 给其它微服务和应用客户端使用。其它微服务完成一个 Web UI。

性能提升

  1. 数据库静态化:数据库不包含运算逻辑,所有运算逻辑在程序内完成。
  2. 减少外部 IO:使用数据缓存、合并数据库操作、读写分离。
  3. 异步化:对于非必须的方法,异步执行使其不影响当前逻辑。
  4. 子系统拆分:拆分长时间运行的逻辑为 Windows 服务或 Job 。

一个栗子:电子商务系统,下单操作起始涉及到了对多个模块的调用。但是用户下单的时候,并不关心这些,只要得到一个下单成功的结果就可以了。我们可以分析一下:系统首先要对用户提交的信息有效性校验,再就是业务数据准确性校验,最后提交到数据库。一个成功的电商系统,前两者必须能在很短的时间内完成,并且在秒杀特卖这种场景时不会造成数据库的崩溃。

基于以上两点,我们分析下如何优化秒杀特卖这种场景下的操作流程。

  1. 服务端对信息有效性的校验,操作频率最密集、速度要最快,所以不应该涉及除内存运算之外的操作,比如:Redis 和数据库读写、TCP/HTTP 远程调用等。
  2. 业务性数据校验,关联模块很多、速度要求较快,所以不应涉及慢速的 IO 操作,比如:数据库读写、HTTP 远程调用等。
  3. 写数据库频繁,在较短的时间内给数据库造成很大的持续压力、速度要求很快,所以这里可以采用立即反馈,稍后写入的方式执行。

数据库静态化

数据库的操作都是有锁的:Select 语句发布共享锁5,Insert、Update 和 Delete 发布排它锁6。所以说在操作同一张表的前提下,数据库操作都是串行7的。

基于以上考虑,让数据库只做存储容器,不负责运算才是正途。正是因为数据库的操作是串行的,在大并发量写入时,任何一点的提升都是要争取的,所以这里要把运算的任务提到程序中执行

  1. 存储过程因为把程序逻辑放在数据库,一般来说肯定包含运算任务,考虑一般开发的水平不能保证先用临时表存储预先计算好的数据(能做到也太繁琐了,很容易出现异常),最后再统一执行,所以首先要摒弃包含数据库写入类的存储过程
  2. 程序内的运算,如果是在开启事务后仍然存在,也要算入数据库的运算任务。因为数据库事务开启后,独占的串行已经开始了,程序的运算时间不仅占用了程序的运算时间,还占用了数据库事务的开启时长,在本质上并没有减少数据库事务的开启时长。严格来算的话,这种做法甚至还不如上一条的做法优化。

所以,真正的数据库静态化是:首先在程序内运算,产生数据库要执行的 SQL 写入语句和参数,持续运算直到产生所有的数据库写入命令;再开启数据库事务,按照先进先出原则顺序连续执行数据库写入命令(此段时间内不能包含其它非数据库运算)。只有这样,才能保证命令的执行都是静态化的写入,并且锁定数据库的时间最短,保证最大化的降低数据库压力。

另外一个,正是因为数据库的操作是串行的,所以在执行数据库写入的情况下,是不能读取的,要避免出现脏读,数据库的读写分离就很有必要了。建立从库,由主库负责写入,从库负责读取,将数据库的压力均分到多台上。

数据库的读写分离要注意:刚写入数据库的数据,同步到从库需要 2 到 3 秒的时间,需要在业务上更改流程,以便于在用户检索时数据已同步。

减少外部 IO

由于磁盘的限制,其读写速度和内存不成比例,所以这里是第二个可能出现瓶颈的地方。可以考虑将配置信息预先读取到缓存的方式解决。

因为数据库是依托于硬盘而存在的,所以数据库的读写相对于有效性验证和业务验证来说,是时间消耗大户。在单纯的考虑数据库写入的情况下,可以从系统内剥离订单的数据库写入业务。一来可以省掉无意义等待数据库写入的时间;二来可以减少 CPU 时间片的占用,将时间

另一种是数据库的读写操作,在一个数据库事务内,是不能有第二个数据库执行相同的操作的。考虑到数据静态化中的介绍,数据库事务开启后,程序内的运算其实也是数据库的运算时间的。此时可以考虑推迟数据库事务的开启时间:首先在程序内运算产生要执行的数据库命令,再开启数据库事务,在连续的时间内执行批量执行数据库事务。即合并数据库操作到一次数据库执行中。

异步化

在开发的过程中,不可避免的要和其关联的模块交互,而这些交互并不会对当前的业务逻辑产生影响。这种操作就应该改成异步的方式。在数据库交互的过程中,如果用户不需要等待数据库的返回值,还可以将数据库执行异步化,在最短的时间内反馈执行结果。

一个栗子:用户下单的过程中,提交订单的操作,其实并不关心提交成功失败,只是在后续跳转到订单详情的时候才会看到订单详情。这个流程就可以将数据库执行异步化,在服务器接收到用户的提交请求时,可以在校验数据后,直接反馈提交成功的响应给用户。接下来,通过异步队列的方式,保存到数据库。客户端在接收到提交成功的反馈后,提示用户提交成功,但是不给出订单的任何信息。用户只有主动点击了查看订单列表,才会执行数据库查询。这个时间差足够系统处理订单的真正提交操作了。

这样做最直接的好处是提高了网站的响应速度,优化了用户体验,在提升服务器 TPS 的同时,还没有提升数据库的压力。在秒杀特卖时,能够最大限度的避免超卖的情况。

子系统拆分

继续上面的例子,数据库的提交订单操作,和网站并没有多少的关系。此时就可以考虑到将这一部分拆分出来,做成一个 Windows 服务,两者通过消息队列的方式通讯。队列的串行读取正好符合了数据库的串行执行,在高峰时段也没有超过数据库的极限,造成宕机的情况。在超过服务极限的情况下,处理慢比不能处理总是要好的。

从操作系统上来说,系统调度针对每个进程都是平等的。此处将数据库执行操作从网站拆分出来,减少了数据库操作对 CPU 时间片的占用,侧面提升了网站的服务能力。

RESTful 架构风格与 SOAP 架构风格

  1. 属性路由:使用渐进式的 URI 替代传统平板式的方法名称 URI。
  2. 客户端缓存报头:使用 ETag 和 Last-Modified 报头减轻服务器压力。
  3. CRUD:使用 GET、POST、PUT 和 DELETE 方法区分数据库 CRUD 操作。

属性路由

第一个 WebApi 版本使用的是基于公约的路由。在该类型的路由中, 你可以定义一个或多个被参数化字符串的模版。当这个框架接收到一个请求时,它匹配一个 URI 到路由模版。

基于公约的路由的一个优势就是:这个模版被定义在一个单独的地方,路由规则一致的被应用于所有的控制器。不幸的是,基于公约的路由是很难支持确切的URI模式,而这个确切的 URI 模式在 RESTful Api 中是很普遍的。比如,资源经常包含子资源:客户下了订单,电影有演员,书有作者等等,它是很自然的创建这些 URI 来反应这些关系:

/customers/3/orders

这种类型的 URI 在基于公约的路由下是比较难实现的。尽管它能做到,但是如果你有许多控制器或者很多资源类型时,不能很好的被扩展。但对于属性路由,它是很容易的为这个 URI 定义一个路由,你可以简单的添加一个属性到控制器的动作上:

[Route("customers/{customerId}/orders")]
public IEnumerable<Order> GetOrdersByCustomerId(int customerId) { ... }

方便的 Api 版本控制

有时候我们需要开发一个功能的新版本,但是并不想对现有的功能产生影响,比如:api/v1/products 和 api/v2/products 可以被路由到不同的控制器。在开发阶段就做出较好了区分,并且当新的版本正式商用后,也可以方便的对 V1 版本的控制器过期或停用。

重载 URI 片段

在下面的例子中,12306 表示一个特定的车票,而 notravelled 表示未出行的车票集合。

/tickets/12306
/tickets/notravelled

通过自然语义,人们可以很容易的理解这些 URI 的含义,但是基于公约的方式并不能很方便的解决这个问题。

路由约束

属性路由添加了公约路由时代所没有的约束特性,可以让你在路由模版中限制参数被匹配。常规的语法是 {parameter:constraint},例如:

[Route("users/{id:int}"]
public User GetUserById(int id) { ... } [Route("users/{name}"]
public User GetUserByName(string name) { ... }

如果 URI 的 id 片段是一个 int 类型的,那么第一个路由将会被选择,否则第二个路由将会被选择。属性路由约定特殊规则的路由优先匹配,最后才匹配没有任何约束的路由。注意不要出现两种可能的匹配,否则会出现多匹配的问题,比如:

[Route("{id:int}")]
public string Get(int id) [Route("{id:decimal}")]
public string Get(decimal id)

这里需要注意的是,WebApi 框架有一个 Bug,不支持小数点,比如:/values/v1/8.3 将不会被解析成 decimal 类型。

下面是被支持的约束列表:

约束 描述 用法演示
bool 类型匹配(Boolean 类型)  
datetime 类型匹配(DateTime 类型) {x:datetime8}
decimal 类型匹配(Decimal 类型) {x:decimal9}
double 类型匹配(64 位浮点数) {x:double9}
float 类型匹配(32 位浮点数)  
guid 类型匹配(Guid)  
int 类型匹配(32 位整数)  
long 类型匹配(64 位整数)  
alpha 字符组成(必须由拉丁字母组成)  
regex 字符组成(必须与指定的正则表达式匹配)  
max 值范围(小于或等于指定的最大值)  
min 值范围(大于或等于指定的最小值)  
range 值范围(在指定的最小值和最大值之间)  
maxlength 字符串最大长度(小于或等于指定的长度)  
minlength 字符串最小长度(大于或等于指定的长度)  
length 字符串长度(等于指定的长度或者长度在指定的范围内)  

客户端缓存报头

基础知识

什么是 Last-Modified

在浏览器第一次请求某一个 URL 时,服务器端的返回状态会是 200,内容是你请求的资源,同时有一个 Last-Modified 的属性标记此文件在服务期端最后被修改的时间,格式类似这样:

Last-Modified: Fri, 12 May 2006 18:53:33 GMT

客户端第二次请求此 URL 时,根据 HTTP 协议的规定,浏览器会向服务器传送 If-Modified-Since 报头,询问该时间之后文件是否有被修改过:

If-Modified-Since: Fri, 12 May 2006 18:53:33 GMT

如果服务器端的资源没有变化,则自动返回 HTTP 304(Not Modified)状态码,内容为空,这样就节省了传输数据量。当服务器端代码发生改变或者重启服务器时,则重新发出资源,返回和第一次请求时类似。从而保证不向客户端重复发出资源,也保证当服务器有变化时,客户端能够得到最新的资源。

什么是 ETag

HTTP 协议规格说明定义 ETag 为被请求变量的实体值;另一种说法是,ETag 是一个可以与 Web 资源关联的记号(Token):典型的 Web 资源可以一个 HTML 页,但也可能是 JSON 或 XML 文档。服务器单独负责判断记号是什么及其含义,并在 HTTP 响应头中将其传送到客户端,以下是服务器端返回的格式:

ETag: W/"9e10cdada3f741f6b0802ee31179837d"

客户端的查询更新格式是这样的:

If-None-Match: W/"9e10cdada3f741f6b0802ee31179837d"

如果 ETag 没改变,则返回状态码 304 内容不返回,这也和 Last-Modified 一样。本人测试 ETag 主要在断点下载时比较有用。

Last-Modified 和 ETag 如何帮助提高性能?

聪明的开发者会把 Last-Modified 和 ETag 跟请求的 HTTP 报头一起使用,这样可利用客户端(例如浏览器)的缓存。因为服务器首先产生 Last-Modified/ETag 标记,服务器可在稍后使用它来判断页面是否已经被修改。本质上,客户端通过将该记号传回服务器要求服务器验证其(客户端)缓存。过程如下:

  1. 客户端请求一个页面(A)。
  2. 服务器返回页面A,并在给A加上一个 Last-Modified/ETag
  3. 客户端展现该页面,并将页面连同 Last-Modified/ETag 一起缓存。
  4. 客户再次请求页面A,并将上次请求时服务器返回的 Last-Modified/ETag 一起传递给服务器。
  5. 服务器检查该 Last-Modified 或 ETag,并判断出该页面自上次客户端请求之后还未被修改,直接返回状态码 304 和一个空的响应体。

这里的客户端一般指浏览器,通过编程方式使用的客户端,一般不会处理这两个 HTTP 请求头。

微服务

目前不做深入讨论。


  1. SOAP(Simple Object Access Protocol)简单对象访问协议,是交换数据的一种协议规范,是一种轻量的、简单的、基于XML(标准通用标记语言下的一个子集)的协议,它被设计成在WEB上交换结构化的和固化的信息。 

  2. PV:(Page View)即页面浏览量,通常是衡量一个网络新闻频道或网站甚至一条网络新闻的主要指标。网页浏览数是评价网站流量最常用的指标之一,简称为 PV。监测网站 PV 的变化趋势和分析其变化原因是很多站长定期要做的工作。Page Views 中的 Page 一般是指普通的 HTML 网页,也包含 PHP、JSP 等动态产生的 HTML 内容。来自浏览器的一次 HTML 内容请求会被看作一个 PV,逐渐累计成为 PV 总数。 

  3. TPS:(Transaction Per Second)每秒钟系统能够处理的交易或事务的数量。它是衡量系统处理能力的重要指标。TPS 是 LoadRunner 中重要的性能参数指标。 

  4. RFC2616:目前该规范已有部分更新。 

  5. 共享锁:类似于读写锁中的读锁。可以多个一起读,但是排斥写锁。只有读锁释放后,才能进入写锁。 

  6. 排它锁:类似于读写锁中的写锁。只能一个写,其余的操作都必须等待,直到当前写锁释放后。 

  7. 串行:同一时间只允许一个线程操作,其余线程只能等待完成后,才能继续执行操作。 

  8. datetime 类型的约束,如果采用 / 做分隔符,必须放在最后一个,并且采用 * 前导:{*x:datetime}。目前,也只有这种写法可以跨多个 URI 段。 

  9. decimal 和 double 两种数字类型,如果包含小数点将不能被正常解析,目前可以算 WebApi 框架的一个 Bug 。