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