B/S 类项目改善的一些建议
要分享的议题
- 性能提升:在访问量逐渐增大的同时,如何增大单台服务器的 PV2 上限,增加 TPS3 ?
- RESTful:相较于传统的 SOAP1,RESTful 风格架构有哪些优点?做法有哪些区别?
- 微服务:随着企业越来越大,系统会越来越大,越来越难维护,如何在保证“稳”的同时,还保证有小企业的“灵活”?
简要的介绍
性能提升
最常用的性能提高方式可以通过使用服务器的集群来解决,简单粗暴的理解就是增加银行柜员的数量。但是,一味的只考虑从服务端提供性能,并不是聪明的做法 —— 应该讲求性价比。当然,核心必须是提高服务器的 TPS,即在最短的时间内给最多的客户提供服务。服务器集群可以大幅提升整体的性能,但是我们要讨论的是如何提升单台服务器的性能。
- 服务器的压力主要来源于三个方面:CPU、网络和磁盘 IO。磁盘作为最容易达到瓶颈的一方,必须想办法减少 IO 操作。数据库作为数据持久性存储、磁盘开销的大户,这里主要就是要减少或合并数据库操作。
- 系统的流畅性取决于服务端和客户端的良好配合。网站类的项目,充分利用浏览器资源,不仅能降低服务器压力,还能提供更好地客户体验。现代化的浏览器,一般都符合 RFC26164 规范。其中很重要的报头有:ETag 和 Last-Modified 报头 —— 浏览器的缓存设置开关,可以最大限度的利用客户端资源。
RESTful 架构风格
好比面向过程编程和面向对象编程,这两者并没有明确的界限。在适当的地方用适当风格的架构,重点是物尽其用。但 RESTful 作为新兴的风格,必然有其优势:
- 在 RESTful 架构中,关注点在于资源。每个都有一个地址,资源本身就是方法调用的目标。方法列表对所有资源都是一样的。这些方法都是标准方法,包括 HTTP GET、POST、PUT、DELETE,还可能包括 PATCH、HEADER 和 OPTIONS。其指导思想是远端提供了一系列资源,客户端需要下载、展现、编辑和提交更改,重点放在本地。
- 在 RPC 架构中,关注点在于方法。在客户端看来,就是在客户端组合条件,然后在服务器中执行,最终再反馈给客户端。其指导思想是隐藏实现细节,或者关联其它 RPC 服务运算,重点放在了服务器。
微服务和单体式应用
现代化的单体式应用,通常采用模块化的方式,围绕核心模块并行开发。最终他们需要联合测试,部署成一个单体式的应用:C# 会部署成 IIS 的一个网站,Java 会打包成 War 格式部署 Tomcat 上。
随着时间的推移,单体式在应对越来越多的新需求后,会变得越来越大。更不幸的是,因为公司资源和需求的不对等,许多仓促应对的代码会添加到应用中。这些代码在短期内不会出现问题,但是修正 Bug 和正常的新功能添加会变得越来越困难,因为通常会涉及到多个模块,牵一发而动全身。此时就是单体式应用的瓶颈期,会考虑拆分成多个子系统。当然,这将会再维持一段时间,直到再出现相似的问题。
许多公司,比如 Amazon、eBay 和 NetFlix,通过采用微处理结构模式解决了上述问题。其思路不是开发一个巨大的单体式的应用,而是将应用分解为小的、互相连接的微服务。
一个微服务一般完成某个特定的功能,比如下单管理、客户管理等等。每一个微服务都有自己的业务逻辑和适配器。一些微服务还会发布 Api 给其它微服务和应用客户端使用。其它微服务完成一个 Web UI。
性能提升
- 数据库静态化:数据库不包含运算逻辑,所有运算逻辑在程序内完成。
- 减少外部 IO:使用数据缓存、合并数据库操作、读写分离。
- 异步化:对于非必须的方法,异步执行使其不影响当前逻辑。
- 子系统拆分:拆分长时间运行的逻辑为 Windows 服务或 Job 。
一个栗子:电子商务系统,下单操作起始涉及到了对多个模块的调用。但是用户下单的时候,并不关心这些,只要得到一个下单成功的结果就可以了。我们可以分析一下:系统首先要对用户提交的信息有效性校验,再就是业务数据准确性校验,最后提交到数据库。一个成功的电商系统,前两者必须能在很短的时间内完成,并且在秒杀特卖这种场景时不会造成数据库的崩溃。
基于以上两点,我们分析下如何优化秒杀特卖这种场景下的操作流程。
- 服务端对信息有效性的校验,操作频率最密集、速度要最快,所以不应该涉及除内存运算之外的操作,比如:Redis 和数据库读写、TCP/HTTP 远程调用等。
- 业务性数据校验,关联模块很多、速度要求较快,所以不应涉及慢速的 IO 操作,比如:数据库读写、HTTP 远程调用等。
- 写数据库频繁,在较短的时间内给数据库造成很大的持续压力、速度要求很快,所以这里可以采用立即反馈,稍后写入的方式执行。
数据库静态化
数据库的操作都是有锁的:Select 语句发布共享锁5,Insert、Update 和 Delete 发布排它锁6。所以说在操作同一张表的前提下,数据库操作都是串行7的。
基于以上考虑,让数据库只做存储容器,不负责运算才是正途。正是因为数据库的操作是串行的,在大并发量写入时,任何一点的提升都是要争取的,所以这里要把运算的任务提到程序中执行。
- 存储过程因为把程序逻辑放在数据库,一般来说肯定包含运算任务,考虑一般开发的水平不能保证先用临时表存储预先计算好的数据(能做到也太繁琐了,很容易出现异常),最后再统一执行,所以首先要摒弃包含数据库写入类的存储过程。
- 程序内的运算,如果是在开启事务后仍然存在,也要算入数据库的运算任务。因为数据库事务开启后,独占的串行已经开始了,程序的运算时间不仅占用了程序的运算时间,还占用了数据库事务的开启时长,在本质上并没有减少数据库事务的开启时长。严格来算的话,这种做法甚至还不如上一条的做法优化。
所以,真正的数据库静态化是:首先在程序内运算,产生数据库要执行的 SQL 写入语句和参数,持续运算直到产生所有的数据库写入命令;再开启数据库事务,按照先进先出原则顺序连续执行数据库写入命令(此段时间内不能包含其它非数据库运算)。只有这样,才能保证命令的执行都是静态化的写入,并且锁定数据库的时间最短,保证最大化的降低数据库压力。
另外一个,正是因为数据库的操作是串行的,所以在执行数据库写入的情况下,是不能读取的,要避免出现脏读,数据库的读写分离就很有必要了。建立从库,由主库负责写入,从库负责读取,将数据库的压力均分到多台上。
数据库的读写分离要注意:刚写入数据库的数据,同步到从库需要 2 到 3 秒的时间,需要在业务上更改流程,以便于在用户检索时数据已同步。
减少外部 IO
由于磁盘的限制,其读写速度和内存不成比例,所以这里是第二个可能出现瓶颈的地方。可以考虑将配置信息预先读取到缓存的方式解决。
因为数据库是依托于硬盘而存在的,所以数据库的读写相对于有效性验证和业务验证来说,是时间消耗大户。在单纯的考虑数据库写入的情况下,可以从系统内剥离订单的数据库写入业务。一来可以省掉无意义等待数据库写入的时间;二来可以减少 CPU 时间片的占用,将时间
另一种是数据库的读写操作,在一个数据库事务内,是不能有第二个数据库执行相同的操作的。考虑到数据静态化中的介绍,数据库事务开启后,程序内的运算其实也是数据库的运算时间的。此时可以考虑推迟数据库事务的开启时间:首先在程序内运算产生要执行的数据库命令,再开启数据库事务,在连续的时间内执行批量执行数据库事务。即合并数据库操作到一次数据库执行中。
异步化
在开发的过程中,不可避免的要和其关联的模块交互,而这些交互并不会对当前的业务逻辑产生影响。这种操作就应该改成异步的方式。在数据库交互的过程中,如果用户不需要等待数据库的返回值,还可以将数据库执行异步化,在最短的时间内反馈执行结果。
一个栗子:用户下单的过程中,提交订单的操作,其实并不关心提交成功失败,只是在后续跳转到订单详情的时候才会看到订单详情。这个流程就可以将数据库执行异步化,在服务器接收到用户的提交请求时,可以在校验数据后,直接反馈提交成功的响应给用户。接下来,通过异步队列的方式,保存到数据库。客户端在接收到提交成功的反馈后,提示用户提交成功,但是不给出订单的任何信息。用户只有主动点击了查看订单列表,才会执行数据库查询。这个时间差足够系统处理订单的真正提交操作了。
这样做最直接的好处是提高了网站的响应速度,优化了用户体验,在提升服务器 TPS 的同时,还没有提升数据库的压力。在秒杀特卖时,能够最大限度的避免超卖的情况。
子系统拆分
继续上面的例子,数据库的提交订单操作,和网站并没有多少的关系。此时就可以考虑到将这一部分拆分出来,做成一个 Windows 服务,两者通过消息队列的方式通讯。队列的串行读取正好符合了数据库的串行执行,在高峰时段也没有超过数据库的极限,造成宕机的情况。在超过服务极限的情况下,处理慢比不能处理总是要好的。
从操作系统上来说,系统调度针对每个进程都是平等的。此处将数据库执行操作从网站拆分出来,减少了数据库操作对 CPU 时间片的占用,侧面提升了网站的服务能力。
RESTful 架构风格与 SOAP 架构风格
- 属性路由:使用渐进式的 URI 替代传统平板式的方法名称 URI。
- 客户端缓存报头:使用 ETag 和 Last-Modified 报头减轻服务器压力。
- 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
标记,服务器可在稍后使用它来判断页面是否已经被修改。本质上,客户端通过将该记号传回服务器要求服务器验证其(客户端)缓存。过程如下:
- 客户端请求一个页面(A)。
- 服务器返回页面A,并在给A加上一个
Last-Modified
/ETag
。 - 客户端展现该页面,并将页面连同
Last-Modified
/ETag
一起缓存。 - 客户再次请求页面A,并将上次请求时服务器返回的
Last-Modified
/ETag
一起传递给服务器。 - 服务器检查该
Last-Modified
或ETag
,并判断出该页面自上次客户端请求之后还未被修改,直接返回状态码 304 和一个空的响应体。
这里的客户端一般指浏览器,通过编程方式使用的客户端,一般不会处理这两个 HTTP 请求头。
微服务
目前不做深入讨论。
SOAP(Simple Object Access Protocol)简单对象访问协议,是交换数据的一种协议规范,是一种轻量的、简单的、基于XML(标准通用标记语言下的一个子集)的协议,它被设计成在WEB上交换结构化的和固化的信息。 ↩
PV:(Page View)即页面浏览量,通常是衡量一个网络新闻频道或网站甚至一条网络新闻的主要指标。网页浏览数是评价网站流量最常用的指标之一,简称为 PV。监测网站 PV 的变化趋势和分析其变化原因是很多站长定期要做的工作。Page Views 中的 Page 一般是指普通的 HTML 网页,也包含 PHP、JSP 等动态产生的 HTML 内容。来自浏览器的一次 HTML 内容请求会被看作一个 PV,逐渐累计成为 PV 总数。 ↩
TPS:(Transaction Per Second)每秒钟系统能够处理的交易或事务的数量。它是衡量系统处理能力的重要指标。TPS 是 LoadRunner 中重要的性能参数指标。 ↩
RFC2616:目前该规范已有部分更新。 ↩
共享锁:类似于读写锁中的读锁。可以多个一起读,但是排斥写锁。只有读锁释放后,才能进入写锁。 ↩
排它锁:类似于读写锁中的写锁。只能一个写,其余的操作都必须等待,直到当前写锁释放后。 ↩
串行:同一时间只允许一个线程操作,其余线程只能等待完成后,才能继续执行操作。 ↩
datetime 类型的约束,如果采用
/
做分隔符,必须放在最后一个,并且采用*
前导:{*x:datetime}
。目前,也只有这种写法可以跨多个 URI 段。 ↩decimal
和double
两种数字类型,如果包含小数点将不能被正常解析,目前可以算 WebApi 框架的一个 Bug 。 ↩