架构
1.架构发展
(1)单体架构:
可理解为传统的前后端未分离的架构。 一个典型的单体架构就是将所有的业务场景的表现层,业务逻辑层,数据访问层放在一个工程中最终经过编译,打包,部署在一台服务器上。
(2)垂直架构:
可理解为前后端分离架构。
(3)SOA架构:
可理解为按服务类别,业务流量,服务间依赖关系等服务化的架构,如以前的单体架构ERP项目,划分为订单服务,采购服务,物料服务和销售服务等。 SOA(Service Oriented Architecture)“面向服务的架构”是一种设计方法,其中包含多个服务,服务之间通过相互依赖最终提供一系列的功能。一个服务通常以独立的形式存在与操作系统进程中。各个服务之间通过网络调用。 SOA架构特点:系统集成,站在系统的角度,解决企业系统间的通信问题,把原先散乱、无规划的系统间的网状结构,梳理成规整、可治理的系统间星形结构,这一步往往需要引入一些产品,比如ESB(企业服务总线)、以及技术规范、服务管理规范;这一步解决的核心问题是【有序】。
(4)微服务:
微服务架构就是将单一程序开发成一个微服务,每个微服务运行在自己的进程中,并使用轻量级的机制通信,通常是HTTP RESTFUL API。 这些服务围绕业务能力来划分,并通过自动化部署机制来独立部署。这些服务可以使用不同的编程语言,不同数据库,以保证最低限度的集中式管理。 总结起来微服务就是将一个单体架构的应用按业务划分为一个个的独立运行的程序即服务,它们之间通过HTTP协议进行通信(也可以采用消息队列来通信,如RoocketMQ,Kafaka等),可以采用不同的编程语言,使用不同的存储技术,自动化部署(如Jenkins)减少人为控制,降低出错概率。服务数量越多,管理起来越复杂,因此采用集中化管理。 例如Eureka,Zookeeper等都是比较常见的服务集中化管理框架。 去中心化,每个微服务有自己私有的数据库持久化业务数据,每个微服务只能访问自己的数据库,而不能访问其它服务的数据库,某些业务场景下,需要在一个事务中更新多个数据库。这种情况也不能直接访问其它微服务的数据库,而是通过对于微服务进行操作。数据的去中心化,进一步降低了微服务之间的耦合度,不同服务可以采用不同的数据库技术(SQL、NoSQL等)。在复杂的业务场景下,如果包含多个微服务,通常在客户端或者中间层(网关)处理。 基础设施自动化(devops、自动化部署),Java EE部署架构,通过展现层打包WARs,业务层划分到JARs最后部署为EAR一个大包,而微服务则打开了这个黑盒子,把应用拆分成为一个一个的单个服务,应用Docker技术,不依赖任何服务器和数据模型,是一个全栈应用,可以通过自动化方式独立部署,每个服务运行在自己的进程中,通过轻量的通讯机制联系,经常是基于HTTP资源API,这些服务基于业务能力构建,能实现集中化管理。
(5)SOA与微服务区别:
SOA:大块业务逻辑、通常松耦合、公司架构任何类型、着重中央管理、目标确保应用能够交互操作。 微服务:单独任务或小块业务逻辑、总是松耦合、公司架构小型、专注于功能、交叉团队、着重分散管理、目标执行新功能快速拓展开发团队。 SOA是根据企业服务总线(ESB)模式来整合集成大量单一庞大的系统,微服务可以说是SOA的一种实现,将复杂的业务组件化。但它比ESB实现的SOA更加的轻便敏捷和简单。
2.微服务的优缺点
优点: 将复杂的业务拆分成多个小的业务,每个业务拆分成一个服务,将复杂的问题简单化。利于分工,降低新人的学习成本。 微服务系统是分布式系统,业务与业务之间完全解耦,随着业务的增加可以根据业务再拆分,具有极强的横向扩展能力。面对搞并发的场景可以将服务集群化部署,加强系统负载能力。 服务间采用HTTP协议通信,服务与服务之间完全独立。每个服务可以根据业务场景选取合适的编程语言和数据库。 微服务每个服务都是独立部署的,每个服务的修改和部署对其他服务没有影响。 松耦合,聚焦单一业务功能,无关开发语言,团队规模降低。在开发中,不需要了解多有业务,只专注于当前功能,便利集中,功能小而精。微服务一个功能受损,对其他功能影响并不是太大,可以快速定位问题。微服务只专注于当前业务逻辑代码,不会和html、css或其他界面进行混合。可以灵活搭配技术,独立性比较舒服。
缺点: 随着服务数量增加,管理复杂,部署复杂,服务器需要增多,服务通信和调用压力增大,运维工程师压力增大,人力资源增多,系统依赖增强,数据一致性,性能监控。
3.CAP定理
CAP理论告诉我们,一个分布式系统不可能同时满足一致性,可用性和分区容错性这三个基本需求,最多只能同时满足其中的2个。 Consistency:中文叫做”一致性”。意思是,写操作之后的读操作,必须返回该值。举例来说,某条记录是v0,用户向G1发起一个写操作,将其改为v1,接下来,用户的读操作就会得到v1。这就叫一致性。 Availability:中文叫做”可用性”,意思是只要收到用户的请求,服务器就必须给出回应。用户可以选择向G1或G2发起读操作。不管是哪台服务器,只要收到请求,就必须告诉用户,到底是v0还是v1,否则就不满足可用性。 Partition tolerance:中文叫做”分区容错”,大多数分布式系统都分布在多个子网络。每个子网络就叫做一个区(partition)。分区容错的意思是,区间通信可能失败。比如,一台服务器放在中国,另一台服务器放在美国,这就是两个区,它们之间可能无法通信。
CA:满足原子和可用,放弃分区容错,说白了就是一个整体的应用。 CP:满足原子和分区容错,放弃可用性。当系统被分区,为了保证原子性,必须放弃可用性,让服务停用。 AP:满足可用性和分区容错,放弃原子性。当出现分区,必须让节点继续对外服务,导致失去原子性。
在一般情况下,都是要满足分区容错性的。 Eureka的AP特性:Eureka各个节点都是平等的,几个节点挂掉不会影响正常节点的工作,剩余的节点依然可以提供注册和查询服务。而Eureka的客户端在向某个Eureka注册或时如果发现连接失败,则会自动切换至其它节点,只要有一台Eureka还在,就能保证注册服务可用(保证可用性),只不过查到的信息可能不是最新的(不保证强一致性),其中说明了,eureka是不满足强一致性,但还是会保证最终一致性,所以可以得出一个结论,eureka不是不满足一致性,只是在同等情况下,eureka会首先保证可用性,在一定程度内再去进行一致性的同步。eureka是AP原则,可用性和分区容错性。eureka各个节点是平等的,一个节点挂掉,其他节点仍会正常保证服务。 Zookeeper的CP特性:同样我们来看zookeeper,zookeeper在选举leader时,会停止服务,直到选举成功之后才会再次对外提供服务,这个时候就说明了服务不可用,但是在选举成功之后,因为一主多从的结构,zookeeper在这时还是一个高可用注册中心,只是在优先保证一致性的前提下,zookeeper才会顾及到可用性。zookeeper是CP原则,强一致性和分区容错性。zookeeper当主节点故障时,zk会在剩余节点重新选择主节点,耗时过长,虽然最终能够恢复,但是选取主节点期间会导致服务不可用,这是不能容忍的。 CAP其实在分布式系统中,是优先保证满足其中两个特性,而不是传统意义上的单纯只满足其中两个特性而舍弃另一个特性。
4.BASE理论
BASE理论由eBay架构师Dan Pritchett提出,在2008年上被分表为论文,并且eBay给出了他们在实践中总结的基于BASE理论的一套新的分布式事务解决方案。 BASE理论是对CAP中一致性和可用性权衡的结果,其来源于对大规模互联网系统分布式实践的总结,是基于CAP定理逐步演化而来的,它大大降低了我们对系统的要求。 BASE理论的核心思想是即使无法做到强一致性,但每个应用都可以根据自身业务特点,采用适当的方式来使系统达到最终一致性。也就是牺牲数据的一致性来满足系统的高可用性,系统中一部分数据不可用或者不一致时,仍需要保持系统整体“主要可用”。 针对数据库领域,BASE思想的主要实现是对业务数据进行拆分,让不同的数据分布在不同的机器上,以提升系统的可用性,当前主要有以下两种做法:按功能划分数据库、分片(如开源的Mycat、Amoeba等)。 由于拆分后会涉及分布式事务问题,所以eBay在该BASE论文中提到了如何用最终一致性的思路来实现高性能的分布式事务。
基本可用(basically available):是指分布式系统在出现不可预知故障的时候,允许损失部分可用性。但是,这绝不等价于系统不可用。响应时间上的损失:正常情况下,一个在线搜索引擎需要在0.5秒之内返回给用户相应的查询结果,但由于出现故障,查询结果的响应时间增加了1~2秒。系统功能上的损失:正常情况下,在一个电子商务网站上进行购物的时候,消费者几乎能够顺利完成每一笔订单,但是在一些节日大促购物高峰的时候,由于消费者的购物行为激增,为了保护购物系统的稳定性,部分消费者可能会被引导到一个降级页面。 软状态(soft-state):指允许系统中的数据存在中间状态,并认为该中间状态的存在不会影响系统的整体可用性,即允许系统在不同节点的数据副本之间进行数据同步的过程存在延时。 最终一致性(eventually consistent):强调的是系统中所有的数据副本,在经过一段时间的同步后,最终能够达到一个一致的状态。因此,最终一致性的本质是需要系统保证最终数据能够达到一致,而不需要实时保证系统数据的强一致性。
系统设计
1.幂等性的接口该如何设计
所谓幂等,就是任意多次执行所产生的影响均与一次执行的影响相同。 幂等性接口是指可以使用相同参数重复执行,并能获得相同结果的接口。 系统对某接口的多次请求,都应该返回同样的结果!避免因为各种原因,重复请求导致的业务重复处理。
场景案例:(a)客户端第一次请求后,网络异常导致收到请求执行逻辑但是没有返回给客户端,客户端的重新发起请求。(b)客户端迅速点击按钮提交,导致同一逻辑被多次发送到服务器。 对于查询,内部不包含其他操作,属于只读性质的那种业务必然符合幂等性要求的。 对于删除,重复做删除请求至少不会造成数据杂乱,不过也有些场景更希望重复点击提示的是删除成功,而不是目标不存在的提示。 对于新增,需要避免重复插入。 对于修改,需要避免进行无效的重复修改。
实现方式: 客户端做某一请求的时候带上识别参数标识,服务端对此标识进行识别,重复请求则重复返回第一次的结果即可。比如添加请求的表单里,在打开添加表单页面的时候,就生成一个AddId标识,这个AddId跟着表单一起提交到后台接口。 后台接口根据这个AddId,服务端就可以进行缓存标记并进行过滤,缓存值可以是AddId作为缓存key,返回内容作为缓存Value,这样即使添加按钮被多次点下也可以识别出来。这个AddId什么时候更新呢?只有在保存成功并且清空表单之后,才变更这个AddId标识,从而实现新数据的表单提交。 AddId为全局ID,可以使用雪花算法等生成。
2.分布式事务
第一类:传统应用的事务管理:
(1)两阶段提交方案(2PC)
两阶段提交,有一个事务管理器的概念,负责协调多个数据库(资源管理器)的事务,事务管理器先问问各个数据库你准备好了吗?如果每个数据库都回复ok,那么就正式提交事务,在各个数据库上执行操作;如果任何其中一个数据库回答不ok,那么就回滚事务。 这种分布式事务方案,比较适合单块应用里,跨多个库的分布式事务,而且因为严重依赖于数据库层面来搞定复杂的事务,效率很低,不适合高并发的场景。 这个方案,我们很少用,一般来说某个系统内部如果出现跨多个库的这么一个操作,是不合规的。如果要操作别的服务对应的库,不允许直连别的服务的库,违反微服务架构的规范,这样的一套服务是没法治理的,可能会出现数据被别人改错,自己的库被别人写挂等情况。必须是通过调用别的服务的接口来实现。
第二类:微服务下的事务管理:
(1)通知型:最大努力通知方案
系统A本地事务执行完之后,发送个消息到MQ。这里会有个专门消费MQ的最大努力通知服务,这个服务会消费MQ然后写入数据库中记录下来,或者是放入个内存队列也可以,接着调用系统B的接口。要是系统B执行成功就ok了;要是系统B执行失败了,那么最大努力通知服务就定时尝试重新调用系统B,反复N次,最后还是不行就放弃。 最大努力通知型对于时效性保证比较差,所以对于数据一致性的时效性要求比较高的系统无法使用。这种模式通常使用在不同业务平台服务或者对于第三方业务服务的通知,如银行通知、商户通知等。
(2)通知型:可靠事件通知方案
(a)同步事件
主服务完成后将结果通过事件(常常是消息队列)传递给从服务,从服务在接受到消息后进行消费,完成业务,从而达到主服务与从服务间的消息一致性。首先能想到的也是最简单的就是同步事件通知,业务处理与消息发送同步执行。
public void trans() {
try {
// 1. 操作数据库
bool result = dao.update(data);// 操作数据库失败,会抛出异常
// 2. 如果数据库操作成功则发送消息
if(result){
mq.send(data);// 如果方法执行失败,会抛出异常
}
} catch (Exception e) {
roolback();// 如果发生异常,就回滚
}
}
不足的地方:在微服务的架构下,有可能出现网络IO问题或者服务器宕机的问题,如果这些问题出现使得消息投递后无法正常通知主服务(网络问题),或无法继续提交事务(宕机),那么主服务将会认为消息投递失败,回滚主服务业务,然而实际上消息已经被从服务消费,那么就会造成主服务和从服务的数据不一致。 事件服务(在这里就是消息服务)与业务过于耦合,如果消息服务不可用,会导致业务不可用。应该将事件服务与业务解耦,独立出来异步执行,或者在业务执行后先尝试发送一次消息,如果消息发送失败,则降级为异步发送。
(b)异步事件
为了解决同步事件的问题,异步事件通知模式被发展了出来,业务服务和事件服务解耦,事件异步进行,由单独的事件服务保证事件的可靠投递。 当业务执行时,在同一个本地事务中将事件写入本地事件表,同时投递该事件,如果事件投递成功,则将该事件从事件表中删除。如果投递失败,则使用事件服务定时地异步统一处理投递失败的事件,进行重新投递,直到事件被正确投递,并将事件从事件表中删除。这种方式最大可能地保证了事件投递的实效性,并且当第一次投递失败后,也能使用异步事件服务保证事件至少被投递一次。 不足之处,那便是业务仍旧与事件服务有一定耦合(第一次同步投递时),更为严重的是,本地事务需要负责额外的事件表的操作,为数据库带来了压力,在高并发的场景,由于每一个业务操作就要产生相应的事件表操作,几乎将数据库的可用吞吐量砍了一半,这无疑是无法接受的。正是因为这样的原因,可靠事件通知模式进一步地发展-外部事件服务出现在了人们的眼中。 外部事件服务在本地事件服务的基础上更进了一步,将事件服务独立出主业务服务,主业务服务不再对事件服务有任何强依赖。 可靠事件通知模式的注意事项:事件的正确发送和事件的重复消费。通过异步消息服务可以确保事件的正确发送,然而事件是有可能重复发送的,那么就需要消费端保证同一条事件不会重复被消费,简而言之就是保证事件消费的幂等性。如果事件本身是具备幂等性的状态型事件,如订单状态的通知(已下单、已支付、已发货等),则需要判断事件的顺序。一般通过时间戳来判断,既消费过了新的消息后,当接受到老的消息直接丢弃不予消费。如果无法提供全局时间戳,则应考虑使用全局统一的序列号。对于不具备幂等性的事件,一般是动作行为事件,如扣款100,存款200,则应该将事件id及事件结果持久化,在消费事件前查询事件id,若已经消费则直接返回执行结果;若是新消息,则执行,并存储执行结果。
举例: 直接基于MQ来实现事务。比如阿里的RocketMQ就支持消息事务。A系统先发送一个prepared消息到mq,如果这个prepared消息发送失败那么就直接取消操作别执行了。 如果这个消息发送成功过了,那么接着执行本地事务,如果成功就告诉mq发送确认消息,如果失败就告诉mq回滚消息。如果发送了确认消息,那么此时B系统会接收到确认消息,然后执行本地的事务。 mq会自动定时轮询所有prepared消息回调你的接口,问你这个消息是不是本地事务处理失败了,所有没发送确认的消息,是继续重试还是回滚?一般来说这里你就可以查下数据库看之前本地事务是否执行,如果回滚了,那么这里也回滚吧。这个就是避免可能本地事务执行成功了,而确认消息却发送失败了。 这个方案里,要是系统B的事务失败了咋办?重试咯,自动不断重试直到成功,如果实在是不行,要么就是针对重要的资金类业务进行回滚,比如B系统本地回滚后,想办法通知系统A也回滚;或者是发送报警由人工来手工回滚和补偿。 这个还是比较合适的,目前国内互联网公司大都是这么玩儿的,要不你用RocketMQ支持的,要不你就自己基于类似ActiveMQ?RabbitMQ?自己封装一套类似的逻辑出来,总之思路就是这样子的。
(3)补偿型:TCC方案
TCC的全称是:Try、Confirm、Cancel。 Try阶段:这个阶段说的是对各个服务的资源做检测以及对资源进行锁定或者预留。 Confirm阶段:这个阶段说的是在各个服务中执行实际的操作。 Cancel阶段:如果任何一个服务的业务方法执行出错,那么这里就需要进行补偿,就是执行已经执行成功的业务逻辑的回滚操作。 这种方案很少人使用,但是也有使用的场景。因为这个事务回滚实际上是严重依赖于你自己写代码来回滚和补偿了,会造成补偿代码巨大,非常之恶心。 比如说我们,一般来说跟钱相关的,跟钱打交道的,支付、交易相关的场景,我们会用TCC,严格保证分布式事务要么全部成功,要么全部自动回滚,严格保证资金的正确性,保证在资金上不会出现问题。而且最好是你的各个业务执行的时间都比较短。自己手写回滚逻辑,或者是补偿逻辑,实在太恶心了,那个业务代码是很难维护的。
举例: (a)转出100元 try阶段,服务做了两件事,1:业务检查,这里是检查小明的帐户里的钱是否多余100元;2:预留资源,将100元从余额中划入冻结资金。 confirm阶段,这里不再进行业务检查,因为try阶段已经做过了,同时由于转账已经成功,将冻结资金扣除。 cancel阶段,释放预留资源,既100元冻结资金,并恢复到余额。
(b)转入100元 try阶段进行,预留资源,将100元冻结。 confirm阶段,使用try阶段预留的资源,将100元冻结资金划入余额。 cancel阶段,释放try阶段的预留资源,将100元从冻结资金中减去。
3.单点登录
(1)共享Session
共享Session可谓是实现单点登录最直接、最简单的方式。将用户认证信息保存于Session中,即以Session内存储的值为用户凭证,这在单个站点内使用是很正常也很容易实现的,而在用户验证、用户信息管理与业务应用分离的场景下即会遇到单点登录的问题,在应用体系简单,子系统很少的情况下,可以考虑采用Session共享的方法来处理这个问题。 这个架构我使用了基于Redis的Session共享方案。将Session存储于Redis上,然后将整个系统的全局Cookie Domain设置于顶级域名上,这样SessionID就能在各个子系统间共享。 这个方案存在着严重的扩展性问题,Session中所涉及的类型必须是子系统中共同拥有的(即程序集、类型都需要一致),这导致Session的使用受到诸多限制;跨顶级域名的情况完全无法处理;
(2)基于OpenId的单点登录
这种单点登录将用户的身份标识信息简化为OpenId存放于客户端,当用户登录某个子系统时,将OpenId传送到服务端,服务端根据OpenId构造用户验证信息,多用于C/S与B/S相结合的系统,这套单点登录依赖于OpenId的传递,其验证的基础在于OpenId的存储以及发送。 这套单点登录验证机制的主要问题在于基于C/S架构下将用户的OpenId存储于客户端,在子系统之间发送OpenId,而B/S模式下要做到这一点就显得较为困难。 为了处理这个问题我们将引出下一种方式,这种方式将解决B/S模式下的OpenId的存储、传递问题。
当用户第一次登录时,将用户名密码发送给验证服务;
验证服务将用户标识OpenId返回到客户端;
客户端进行存储;
访问子系统时,将OpenId发送到子系统;
子系统将OpenId转发到验证服务;
验证服务将用户认证信息返回给子系统;
子系统构建用户验证信息后将授权后的内容返回给客户端。
(3)基于Cookie的OpenId存储方案
我们知道,Cookie的作用在于充当一个信息载体在Server端和Browser端进行信息传递,而Cookie一般是以域名为分割的,例如a.xxx.com与b.xxx.com的Cookie是不能互相访问的,但是子域名是可以访问上级域名的Cookie的。即a.xxx.com和b.xxx.com是可以访问xxx.com下的Cookie的,于是就能将顶级域名的Cookie作为OpenId的载体。 验证步骤和上第二个方法非常相似。在以上两种方法中我们都可以看到通过OpenId解耦了Session共享方案中的类型等问题,并且构造用户验证信息将更灵活,子系统间的验证是相互独立的,但是在第三种方案里,我们基于所有子系统都是同一个顶级域名的假设,而在实际生产环境里有多个域名是很正常的事情,那么就不得不考虑跨域问题究竟如何解决。
在提供验证服务的站点里登录;
将OpenId写入顶级域名Cookie里;
访问子系统(Cookie里带有OpenId)
子系统取出OpenId通过并向验证服务发送OpenId
返回用户认证信息
返回授权后的内容
(4)B/S多域名环境下的单点登录处理
在多个顶级域名的情况下,我们将无法让各个子系统的OpenId共享。验证步骤如下:
用户通过登录子系统进行用户登录;
用户登录子系统记录了用户的登录状态、OpenId等信息;
用户使用业务子系统;
若用户未登录业务子系统则将用户跳转至用户登录子系统;
用户子系统通过JSONP接口将用户OpenId传给业务子系统;
业务子系统通过OpenId调用验证服务;
验证服务返回认证信息、业务子系统构造用户登录凭证;(此时用户客户端已经与子业务系统的验证信息已经一一对应)
将用户登录结果返回用户登录子系统,若成功登录则将用户跳转回业务子系统;
将授权后的内容返回客户端;
(5)跨域SSO实现过程
用户访问产品a,域名是 http://www.a.cn。
由于用户没有携带在a服务器上登录的a cookie,所以a服务器重定向到SSO服务器的地址。
由于用户没有携带在SSO服务器上登录的TGC(Ticket Granting Cookie),所以SSO服务器判断用户未登录,给用户显示统一登录界面。
登录成功后,SSO服务器构建用户在SSO登录的TGT (Ticket Grangting Ticket),同时返回一个http重定向(包含sso服务器派发的ST)。
重定向的http response中包含写cookie。这个cookie代表用户在SSO中的登录状态,它的值是TGC。
浏览器重定向到产品a。此时重定向的url中携带着SSO服务器生成的ST。根据ST,a服务器向SSO服务器发送请求,SSO服务器验证票据的有效性。验证成功后,a服务器知道用户已经在sso登录了,于是a服务器构建用户登录session。
用户访问产品b,域名是 http://www.b.cn。
由于用户没有携带在b服务器上登录的b cookie,所以b服务器重定向到SSO服务器,去询问用户在SSO中的登录状态。
浏览器重定向到SSO服务器。由于已经向浏览器写入了携带TGC 的cookie,所以此时SSO服务器可以拿到,根据TGC去查找TGT,如果找到,就判断用户已经在sso登录过了。
SSO服务器返回一个重定向,重定向携带ST。
浏览器带ST重定向到b服务器。
b服务器根据票据向SSO服务器发送请求,票据验证通过后,b服务器知道用户已经在sso登录了,于是生成b session,向浏览器写入b cookie。
4.如何设计一个秒杀系统
业务特点:
(1)高并发:秒杀的特点就是这样时间极短、 瞬间用户量大。 (2)库存量少:一般秒杀活动商品量很少,这就导致了只有极少量用户能成功购买到。 (3)业务简单:流程比较简单,一般都是下订单、扣库存、支付订单。 (4)恶意请求,数据库压力大。
设计原则:
(1)请求数尽量少: 用户请求的页面返回后,浏览器渲染这个页面还要包含其他的额外请求,比如说,这个页面依赖的CSS/JavaScript、图片,以及Ajax请求等等都定义为“额外请求”,这些额外请求应该尽量少。 因为浏览器每发出一个请求都多少会有一些消耗,例如建立连接要做三次握手,有的时候有页面依赖或者连接数限制,一些请求(例如 JavaScript)还需要串行加载等。另外,如果不同请求的域名不一样的话,还涉及这些域名的DNS解析,可能会耗时更久。所以你要记住的是,减少请求数可以显著减少以上这些因素导致的资源消耗。 例如,减少请求数最常用的一个实践就是合并CSS和JavaScript文件,把多个JavaScript文件合并成一个文件。
(2)数据尽量少: 用户请求的数据能少就少,因为这些数据在网络上传输需要时间,其次不管是请求数据还是返回数据都需要服务器处理,而服务器在写网络的时候通常都要做压缩和字符编码,这些都非常消耗CPU,所以减少传输的数据量可以显著减少CPU的使用。 同样,数据尽量少还要求系统依赖的数据能少就少,包括系统完成某些业务逻辑需要读取和保存的数据,这些数据一般是和后台服务以及数据库打交道的。调用其他服务会涉及数据的序列化和反序列化,这也是CPU的一大杀手,同样也会增加延时。而且数据库本身也很容易成为瓶颈,因此越少和数据库打交道越好。
(3)路径要尽量短: 路径指的是用户发出请求到返回数据这个过程中需要经过的中间节点的数量。通常,这些节点可以表示为一个系统或者一个新的Socket连接,每经过一个节点,一般都会产生一个新的Socket连接。每增加一个连接都会增加新的不确定性。 所以缩短请求路径不仅可以增加可用性,同样可以有效提升性能(减少中间节点可以减少数据的序列化与反序列化),并减少延时(可以减少网络传输耗时)。要缩短访问路径可以将多个相互有强依赖的应用合并部署在一起,将远程过程调用变成JVM内部的方法调用。
(4)依赖要尽量少: 所谓依赖,指的是要完成一次用户请求必须依赖的系统或者服务。比如说你要展示秒杀页面,而这个页面必须强依赖商品信息、用户信息,还有其他如优惠券、成交列表等这些对秒杀不是非要不可的信息(弱依赖),这些弱依赖在紧急情况下就可以去掉。
(5)不要有单点: 不能有单点,因为单点意味着没有备份,风险不可控,设计分布式系统的一个最重要的原则就是消除单点。避免将服务的状态和机器绑定,即把服务无状态化,这样服务就可以在机器中随意移动了。
具体实践案例:
(1)前后端分离,减少没必要的请求。
(2)发现热点数据,并预处理,如提前进行缓存。如何发现:构建异步系统,用来收集交易链路上各个环节中的热点数据,把日志汇总到聚合和分析集群中,然后把符合一定规则的热点数据,通过订阅分发系统再推送到相应的系统中。下游系统可以订阅这些数据,然后根据自己的需求决定如何处理这些数据。
(3)流量削峰,秒杀请求在时间上是高度集中于某一特定的时间点的,这样一来会有一个特别高的流量峰值,它对资源的消耗是瞬时的。削峰主要是为了能够让服务端处理变得更加平稳,也为了能够节省服务器的资源成本。从秒杀这个场景来说,就是更多延缓用户请求的发出,以便减少或者过滤掉一些无效请求,遵从请求数要尽量少的原则。 (a)用消息队列缓冲瞬时流量,将同步的直接调用转换成异步的间接推送,中间通过一个队列在一端承接瞬时的流量洪峰,在另外一端平滑地将信息推送出去。但是如果流量峰值持续一段时间,超过了消息队列的处理上限,还是会被压垮的。其他常见的排队方式有:利用线程池加锁等待、先进先出等常用的内存排队算法的实现、将请求序列化到文件当中然后再顺序读文件。 (b)答题,防止部分买家使用秒杀器在参加秒杀时作弊,也能延缓请求,起到对请求流量进行削峰的作用,从而让系统能够更好地支持瞬时的流量高峰。这个重要的功能就是把峰值的下单请求拉长。 (c)分层过滤,采用漏斗式的设计,假如大部分数据和流量在用户浏览器或者CDN上获取,这一层可以拦截大部分数据的读取,经过第二层(即前台系统)时数据(包括强一致性的数据)尽量得走 Cache,过滤一些无效的请求,再到第三层后台系统,主要做数据的二次检验,对系统做好保护和限流,这样数据量和请求就进一步减少,最后在数据层完成数据的强一致性校验。分层过滤的核心思想是:在不同的层次尽可能地过滤掉无效请求,让漏斗最末端的才是有效的请求。而达到这种效果,我们就必须对数据做分层的校验。
解决方案:
(1)前端:页面资源静态化,按钮控制,使用答题校验码可以防止秒杀器的干扰,让更多用户有机会抢到。 (2)nginx:校验恶意请求,转发请求,负载均衡;动静分离,不走tomcat获取静态资源;gzip压缩,减少静态文件传输的体积,节省带宽,提高渲染速度。 (3)业务层:集群,多台机器处理,提高并发能力。 (4)redis:集群保证高可用,持久化数据;分布式锁(悲观锁);缓存热点数据(库存)。 (5)mq:削峰限流,MQ堆积订单,保护订单处理层的负载,Consumer根据自己的消费能力来取Task,实际上下游的压力就可控了。重点做好路由层和MQ的安全。 (6)数据库:读写分离,拆分事务提高并发度。
影响性能的因素:
(1)性能的定义: 关于秒杀,我们主要讨论系统服务端的性能,一般使用QPS来衡量,还有一个影响和QPS息息相关,即响应时间(Response Time, RT),可以理解为服务器处理响应的耗时。 正常情况下响应时间越短,一秒钟处理的请求数就会越多,这在单线程处理的情况下看起来是线性关系,即我们只要把每个请求的响应时间降到最低,那么性能就会最高。而在多线程当中,总QPS =(1000ms/ 响应时间)x 线程数,从这个角度上来看,性能和两个因素相关,一个是一次响应的服务端的耗时,一个是处理请求的线程数。 要提升性能,我们就要减少CPU的执行时间,另外就是要设置一个合理的并发线程数量,通过这两方面来显著提升服务器的性能。 响应时间:对于大部分的Web系统而言,响应时间一般是由CPU执行时间和线程等待时间组成的,即服务器在处理一个请求时,一部分是CPU本身在做运算,还有一部分是各种等待。 理解了服务器处理请求的逻辑,估计你会说为什么我们不去减少这种等待时间。很遗憾,根据我们实际的测试发现,减少线程等待时间对提升性能的影响没有我们想象得那么大,它并不是线性的提升关系,这点在很多代理服务器(Proxy)上可以做验证。 如果代理服务器本身没有CPU消耗,我们在每次给代理服务器代理的请求加个延时,即增加响应时间,但是这对代理服务器本身的吞吐量并没有多大的影响,因为代理服务器本身的资源并没有被消耗,可以通过增加代理服务器的处理线程数,来弥补响应时间对代理服务器的QPS的影响。 其实,真正对性能有影响的是CPU的执行时间。这也很好理解,因为CPU的执行真正消耗了服务器的资源。经过实际的测试,如果减少CPU一半的执行时间,就可以增加一倍的QPS。 线程数:并不是线程数越多越好,总QPS就会越大,因为线程本身也消耗资源,会受到其他因素的制约。例如,线程越多系统的线程切换成本就会越高,而且每个线程都会耗费一定的内存。默认的配置一般为:线程数 = 2 x CPU核数 + 1,还有一个根据最佳实践得出来的公式为:线程数 = ((线程等待时间 + 线程CPU时间) / 线程CPU时间) x CPU数量。
(2)如何发现瓶颈 对于秒杀,瓶颈更容易发生在CPU上。其实有很多CPU诊断工具可以发现CPU的消耗,最常用的就是JProfiler和Yourkit这两个工具,它们可以列出整个请求中每个函数的CPU执行时间,可以发现哪个函数消耗的CPU时间最多,以便你有针对性地做优化。 当然还有一些办法也可以近似地统计CPU的耗时,例如通过jstack定时地打印调用栈,如果某些函数调用频繁或者耗时较多,那么那些函数就会多次出现在系统调用栈里,这样相当于采样的方式也能够发现耗时较多的函数。 怎样简单地判断CPU是不是瓶颈呢?一个办法就是看当QPS达到极限时,你的服务器的CPU使用率是不是超过了95%,如果没有超过,那么表示CPU还有提升的空间,要么是有锁限制,要么是有过多的本地I/O等待发生。
(3)如何优化系统 减少编码:Java的编码运行比较慢,在很多场景下,只要涉及字符串的操作都会比较消耗CPU资源,不管是磁盘IO还是网络IO,因为都需要将字符转换成字节,这个转换必须编码。每个字符的编码都需要查表,而这种查表的操作非常耗资源,所以减少字符到字节或者相反的转换、减少字符编码会非常有成效。减少编码就可以大大提升性能。 减少序列化:序列化也是Java性能的一大天敌,减少Java当中的序列化操作也能大大提升性能。又因为序列化往往是和编码同时发生的,所以减少序列化也就减少了编码。序列化大部分是在RPC中发生的,因此避免或者减少RPC就可以减少序列化,当然当前的序列化协议也已经做了很多优化来提升性能。 Java秒杀场景的针对性优化:对大流量的Web系统做静态化改造,让大部分请求和数据直接在Nginx服务器或者Web代理服务器,而Java层只需处理少量数据的动态请求。数据输出时推荐使用JSON而不是模板引擎来输出页面。 并发读优化:缓存及应用层的内存缓存。
秒杀系统设计小结:
秒杀系统就是一个“三高”系统,即高并发、高性能和高可用的分布式系统。
秒杀设计原则:前台请求尽量少,后台数据尽量少,调用链路尽量短,尽量不要有单点。
秒杀高并发方法:访问拦截、分流、动静分离。
秒杀数据方法:减库存策略、热点、异步、限流降级。
访问拦截主要思路:通过CDN和缓存技术,尽量把访问拦截在离用户更近的层,尽可能地过滤掉无效请求。
分流主要思路:通过分布式集群技术,多台机器处理,提高并发能力。
5.库存扣减问题
(1)减库存的方式
下单减库存:即当买家下单之后,在商品的总库存中减去买家购买的数量。这种方式控制最精确,下单时直接通过数据库的事务机制控制商品库存,这样一定不会出现超卖的现象。但是有些人下完单以后并不会付款。 付款减库存:即买家下单后,并不立即减库存,而是等到有用户付款后才真正减库存,否则库存一直保留给其他买家。但因为付款时才减库存,如果并发比较高,有可能出现买家下单后付不了款的情况,因为可能商品已经被其他人买走了。 预扣库存:这种方式相对复杂一些,买家下单后,库存为其保留一定的时间(如10分钟),超过这个时间,库存将会自动释放,释放后其他买家就可以继续购买。在买家付款前,系统会校验该订单的库存是否还有保留:如果没有保留,则再次尝试预扣;如果库存不足(也就是预扣失败)则不允许继续付款;如果预扣成功,则完成付款并实际地减去库存。但是恶意买家完全可以在10分钟后再次下单,或者采用一次下单很多件的方式把库存减完。针对这种情况,解决办法还是要结合安全和反作弊的措施来制止。例如,给经常下单不付款的买家进行识别打标(可以在被打标的买家下单时不减库存)、给某些类目设置最大购买件数(例如,参加活动的商品一人最多只能买3件),以及对重复下单不付款的操作进行次数限制等。
对于一般业务系统而言,一般是预扣库存的方案,超出有效付款时间订单就会自动释放。而对于秒杀场景,一般采用下单减库存。“下单减库存”在数据一致性上,主要就是保证大并发请求时库存数据不能为负数,也就是要保证数据库中的库存字段值不能为负数。 一般我们有多种解决方案:一种是在应用程序中通过事务来判断,即保证减后库存不能为负数,否则就回滚;另一种办法是直接设置数据库的字段数据为无符号整数,这样减后库存字段值小于零时会直接执行SQL语句来报错。 如果秒杀商品的减库存逻辑非常单一可以把秒杀商品减库存直接放到缓存系统中实现,但是如果有比较复杂的减库存逻辑,或者需要使用事务,还是必须在数据库中完成减库存。 为了防止单个热点商品会影响整个数据库的性能,要把热点商品放到单独的热点库中。但是这无疑会带来维护上的麻烦,比如要做热点数据的动态迁移以及单独的数据库等。
(2)基于redis实现扣减库存的具体实现
使用redis的lua脚本来实现扣减库存。 由于是分布式环境下所以还需要一个分布式锁来控制只能有一个服务去初始化库存。 需要提供一个回调函数,在初始化库存的时候去调用这个函数获取初始化库存。
6.服务熔断、服务降级
服务熔断的作用类似于我们家用的保险丝,当某服务出现不可用或响应超时的情况时,为了防止整个系统出现雪崩,暂时停止对该服务的调用。 服务降级是从整个系统的负荷情况出发和考虑的,对某些负荷会比较高的情况,为了预防某些功能(业务场景)出现负荷过载或者响应慢的情况,在其内部暂时舍弃对一些非核心的接口和数据的请求,而直接返回一个提前准备好的fallback(退路)错误处理信息。这样,虽然提供的是一个有损的服务,但却保证了整个系统的稳定性和可用性。
熔断与降级异同:
相同点:目标一致,都是从可用性和可靠性出发,为了防止系统崩溃;用户体验类似,最终都让用户体验到的是某些功能暂时不可用。 不同点:触发原因不同,服务熔断一般是某个服务(下游服务)故障引起,而服务降级一般是从整体负荷考虑。
7.项目是如何处理重复请求/并发请求的?
(1)重复的场景有可能是:
黑客拦截了请求,重放
前端/客户端因为某些原因请求重复发送了,或者用户在很短的时间内重复点击了。
网关重发
(2)方案
计算请求参数的摘要作为参数标识。
String KEY = "dedup:U="+userId + "M=" + method + "P=" + reqParamMD5;
实例:
public class ReqDedupHelper {
/**
*
* @param reqJSON 请求的参数,这里通常是JSON
* @param excludeKeys 请求参数里面要去除哪些字段再求摘要
* @return 去除参数的MD5摘要
*/
public String dedupParamMD5(final String reqJSON, String... excludeKeys) {
String decreptParam = reqJSON;
TreeMap paramTreeMap = JSON.parseObject(decreptParam, TreeMap.class);
if (excludeKeys!=null) {
List<String> dedupExcludeKeys = Arrays.asList(excludeKeys);
if (!dedupExcludeKeys.isEmpty()) {
for (String dedupExcludeKey : dedupExcludeKeys) {
paramTreeMap.remove(dedupExcludeKey);
}
}
}
String paramTreeMapJSON = JSON.toJSONString(paramTreeMap);
String md5deDupParam = jdkMD5(paramTreeMapJSON);
log.debug("md5deDupParam = {}, excludeKeys = {} {}", md5deDupParam, Arrays.deepToString(excludeKeys), paramTreeMapJSON);
return md5deDupParam;
}
private static String jdkMD5(String src) {
String res = null;
try {
MessageDigest messageDigest = MessageDigest.getInstance("MD5");
byte[] mdBytes = messageDigest.digest(src.getBytes());
res = DatatypeConverter.printHexBinary(mdBytes);
} catch (Exception e) {
log.error("",e);
}
return res;
}
}
public static void main(String[] args) {
//两个请求一样,但是请求时间差一秒
String req = "{\n" +
"\"requestTime\" :\"20190101120001\",\n" +
"\"requestValue\" :\"1000\",\n" +
"\"requestKey\" :\"key\"\n" +
"}";
String req2 = "{\n" +
"\"requestTime\" :\"20190101120002\",\n" +
"\"requestValue\" :\"1000\",\n" +
"\"requestKey\" :\"key\"\n" +
"}";
//全参数比对,所以两个参数MD5不同
String dedupMD5 = new ReqDedupHelper().dedupParamMD5(req);
String dedupMD52 = new ReqDedupHelper().dedupParamMD5(req2);
System.out.println("req1MD5 = "+ dedupMD5+" , req2MD5="+dedupMD52);
//去除时间参数比对,MD5相同
String dedupMD53 = new ReqDedupHelper().dedupParamMD5(req,"requestTime");
String dedupMD54 = new ReqDedupHelper().dedupParamMD5(req2,"requestTime");
System.out.println("req1MD5 = "+ dedupMD53+" , req2MD5="+dedupMD54);
}
完整的去重解决方案,如下:
String userId= "12345678";//用户
String method = "pay";//接口名
String dedupMD5 = new ReqDedupHelper().dedupParamMD5(req,"requestTime");//计算请求参数摘要,其中剔除里面请求时间的干扰
String KEY = "dedup:U=" + userId + "M=" + method + "P=" + dedupMD5;
long expireTime = 1000;// 1000毫秒过期,1000ms内的重复请求会认为重复
long expireAt = System.currentTimeMillis() + expireTime;
String val = "expireAt@" + expireAt;
// NOTE:直接SETNX不支持带过期时间,所以设置+过期不是原子操作,极端情况下可能设置了就不过期了,后面相同请求可能会误以为需要去重,所以这里使用底层API,保证SETNX+过期时间是原子操作
Boolean firstSet = stringRedisTemplate.execute((RedisCallback<Boolean>) connection -> connection.set(KEY.getBytes(), val.getBytes(), Expiration.milliseconds(expireTime), RedisStringCommands.SetOption.SET_IF_ABSENT));
// 是否重复请求标识
final boolean isConsiderDup;
if (firstSet != null && firstSet) {
isConsiderDup = false;
} else {
isConsiderDup = true;
}
8.接口token机制
接口的安全性主要围绕token、timestamp和sign三个机制展开设计,保证接口的数据不会被篡改和重复调用。
Token授权机制:用户使用用户名密码登录后服务器给客户端返回一个Token(通常是UUID),并将Token-UserId以键值对的形式存放在缓存服务器中。服务端接收到请求后进行Token验证,如果Token不存在,说明请求无效。Token是客户端访问服务端的凭证。 时间戳超时机制:用户每次请求都带上当前时间的时间戳timestamp,服务端接收到timestamp后跟当前时间进行比对,如果时间差大于一定时间(比如5分钟),则认为该请求失效。时间戳超时机制是防御DOS攻击的有效手段。 签名机制:将Token和时间戳加上其他请求参数再用MD5或SHA-1算法(可根据情况加点盐)加密,加密后的数据就是本次请求的签名sign,服务端接收到请求后以同样的算法得到签名,并跟当前的签名进行比对,如果不一样,说明参数被更改过,直接返回错误标识。签名机制保证了数据不会被篡改。 拒绝重复调用(非必须):客户端第一次访问时,将签名sign存放到缓存服务器中,超时时间设定为跟时间戳的超时时间一致,二者时间一致可以保证无论在timestamp限定时间内还是外 URL都只能访问一次。如果有人使用同一个URL再次访问,如果发现缓存服务器中已经存在了本次签名,则拒绝服务。如果在缓存中的签名失效的情况下,有人使用同一个URL再次访问,则会被时间戳超时机制拦截。这就是为什么要求时间戳的超时时间要设定为跟时间戳的超时时间一致。拒绝重复调用机制确保URL被别人截获了也无法使用(如抓取数据)。
流程如下:
客户端通过用户名密码登录服务器并获取Token。 客户端生成时间戳timestamp,并将timestamp作为其中一个参数。 客户端将所有的参数,包括Token和timestamp按照自己的算法进行排序加密得到签名sign。 将token、timestamp和sign作为请求时必须携带的参数加在每个请求的URL后边。 服务端写一个过滤器对token、timestamp和sign进行验证,只有在token有效、timestamp未超时、缓存服务器中不存在sign三种情况同时满足,本次请求才有效。
效果:
在以上三中机制的保护下,如果有人劫持了请求,并对请求中的参数进行了修改,签名就无法通过; 如果有人使用已经劫持的URL进行DOS攻击,服务器则会因为缓存服务器中已经存在签名或时间戳超时而拒绝服务,所以DOS攻击也是不可能的; 如果签名算法和用户名密码都暴露了,那可以通过IP等机制保障。
9.在项目中,如何应对高并发流量
(1)应对大流量的一些思路
首先,我们来说一下什么是大流量?大流量,我们很可能会冒出:TPS(每秒事务量),QPS(每秒请求量),1W+,5W+,10W+,100W+…。其实并没有一个绝对的数字,如果这个量造成了系统的压力,影响了系统的性能,那么这个量就可以称之为大流量了。 其次,应对大流量的一些常见手段是什么?
缓存:说白了,就是让数据尽早进入缓存,离程序近一点,不要大量频繁的访问DB。
降级:如果不是核心链路,那么就把这个服务降级掉。打个比喻,现在的APP都讲究千人千面,拿到数据后,做个性化排序展示,如果在大流量下,这个排序就可以降级掉!
限流:大家都知道,北京地铁早高峰,地铁站都会做一件事情,就是限流了!想法很直接,就是想在一定时间内把请求限制在一定范围内,保证系统不被冲垮,同时尽可能提升系统的吞吐量。
(2)限流的常用方式
限流的常用处理手段有:计数器、滑动窗口、漏桶、令牌。
(a)计数器: 计数器是一种比较简单的限流算法,用途比较广泛,在接口层面,很多地方使用这种方式限流。在一段时间内,进行计数,与阀值进行比较,到了时间临界点,将计数器清0。 这里需要注意的是,存在一个时间临界点的问题。举个栗子,在12:01:00到12:01:58这段时间内没有用户请求,然后在12:01:59这一瞬时发出100个请求,OK,然后在12:02:00这一瞬时又发出了100个请求。这里你应该能感受到,在这个临界点可能会承受恶意用户的大量请求,甚至超出系统预期的承受。
(b)滑动窗口: 由于计数器存在临界点缺陷,后来出现了滑动窗口算法来解决。 滑动窗口的意思是说把固定时间片,进行划分,并且随着时间的流逝,进行移动,这样就巧妙的避开了计数器的临界点问题。也就是说这些固定数量的可以移动的格子,将会进行计数判断阀值,因此格子的数量影响着滑动窗口算法的精度。
(c)漏桶:
虽然滑动窗口有效避免了时间临界点的问题,但是依然有时间片的概念,而漏桶算法在这方面比滑动窗口而言,更加先进。有一个固定的桶,进水的速率是不确定的,但是出水的速率是恒定的,当水满的时候是会溢出的。
(d)令牌桶 注意到,漏桶的出水速度是恒定的,那么意味着如果瞬时大流量的话,将有大部分请求被丢弃掉(也就是所谓的溢出)。为了解决这个问题,令牌桶进行了算法改进。 生成令牌的速度是恒定的,而请求去拿令牌是没有速度限制的。这意味,面对瞬时大流量,该算法可以在短时间内请求拿到大量令牌,而且拿令牌的过程并不是消耗很大的事情。(有一点生产令牌,消费令牌的意味)不论是对于令牌桶拿不到令牌被拒绝,还是漏桶的水满了溢出,都是为了保证大部分流量的正常使用,而牺牲掉了少部分流量,这是合理的,如果因为极少部分流量需要保证的话,那么就可能导致系统达到极限而挂掉,得不偿失。
(3)限流神器:Guava RateLimiter
Guava不仅仅在集合、缓存、异步回调等方面功能强大,而且还给我们封装好了限流的API! Guava RateLimiter基于令牌桶算法,我们只需要告诉RateLimiter系统限制的QPS是多少,那么RateLimiter将以这个速度往桶里面放入令牌,然后请求的时候,通过tryAcquire()方法向RateLimiter获取许可(令牌)。
N.参考