谈谈业务中使用分布式的场景
首先,需要了解系统为什么使用分布式。 随着互联网的发展,传统单工程项目的很多性能瓶颈越发凸显,性能瓶颈可以有几个方面: 1.应用服务层:随着用户量的增加,并发量增加,单项目难以承受如此大的并发请求导致的性能瓶颈。 2.底层数据库层:随着业务的发展,数据库压力越来越大,导致的性能瓶颈。 #场景1:应用系统集群的 Session 共享 应用系统集群最简单的就是服务器集群,比如:Tomcat 集群。应用系统集群的时候,比较凸显的问题是 Session 共享,Session 共享我们一是可以通过服务器插件来解决。另外一种也可以通过 Redis 等中间件实现。 #场景2:应用系统的服务化拆分 服务化拆分,是目前非常火热的一种方式。现在都在提微服务。通过对传统项目进行服务化拆分,达到服务独立解耦,单服务又可以横向扩容。服务化拆分遇到的经典问题就是分布式事务问题。目前,比较常用的分布式事务解决方案有几种:消息最终一致性、TCC 补偿型事务等。 #场景3:底层数据库的压力分摊 如果系统的性能压力出现在数据库,那我们就可以读写分离、分库分表等方案进行解决。
Session 分布式方案
#基于 nfs(net filesystem) 的 Session 共享 将共享服务器目录 mount 各服务器的本地 session 目录,session 读写受共享服务器 io 限制,不能满足高并发。 #基于关系数据库的 Session 共享 这种方案普遍使用。使用关系数据库存储 session 数据,对于 mysql 数据库,建议使用 heap 引擎。这种方案性能取决于数据库的性能,在高并发下容易造成表锁(虽然可以采用行锁的存储引擎,性能会下降),并且需要自己实现 session 过期淘汰机制。 #基于 Cookie 的 Session 共享 这种方案也在大型互联网中普遍使用,将用户的 session 加密序列化后以 cookie 的方式保存在网站根域名下(比如 taobao.com),当用户访问所有二级域名站点式,浏览器会传递所有匹配的根域名的 cookie 信息,这样实现了用户 cookie 化 session 的多服务共享。此方案
能够节省大量服务器资源,缺点是存储的信息长度受到 http 协议限制;cookie 的信息还需要做加密解密;请求任何资源时都会将 cookie 附加到 http 头上传到服务器,占用了一定带宽。 #基于 Web 容器的 Session 机制 利用容器机制,通过配置即可实现。 #基于 Zookeeper 的分布式 Session 存储 #基于 Redis/Memcached 的 Session 共享存储 这些 key/value 非关系存储有较高的性能,轻松达到 2000 左右的 qps,内置的过期机制正好满足 session 的自动实效特性。
分布式锁的场景与实现-
#使用场景 首先,我们看这样一个场景:客户下单的时候,我们调用库存中心进行减库存,那我们一般的操作都是: update store set num = $num where id = $id 这种通过设置库存的修改方式,我们知道在并发量高的时候会存在数据库的丢失更新,比如 a, b 当前两个事务,查询出来的库存都是 5,a 买了 3 个单子要把库存设置为 2,而 b 买了 1 个单子要把库存设置为 4,那这个时候就会出现 a 会覆盖 b 的更新,所以我们更多的都是
会加个条件: update store set num = $num where id = $id and num = $query_num 即乐观锁的方式来处理,当然也可以通过版本号来处理乐观锁,都是一样的,但是这是更新一个表,如果我们牵扯到多个表呢,我们希望和这个单子关联的所有的表同一时间只能被一个线程来处理更新,多个线程按照不同的顺序去更新同一个单子关联的不同数据,出现死锁的概率比较大。
对于非敏感的数据,我们也没有必要去都加乐观锁处理,我们的服务都是多机器部署的,要保证多进程多线程同时只能有一个进程的一个线程去处理,这个时候我们就需要用到分布式锁。分布式锁的实现方式有很多,我们今天分别通过数据库,Zookeeper, Redis 以及 Tair 的实现逻辑。 #数据库实现 #加 xx 锁 更新一个单子关联的所有的数据,先查询出这个单子,并加上排他锁,在进行一系列的更新操作 begin transaction; select ...for update; doSomething(); commit(); 这种处理主要依靠排他锁来阻塞其他线程,不过这个需要注意几点: 1.查询的数据一定要在数据库里存在,如果不存在的话,数据库会加 gap 锁,而 gap 锁之间是兼容的,这种如果两个线程都加了gap 锁,另一个再更新的话会出现死锁。不过一般能更新的数据都是存在的 2.后续的处理流程需要尽可能的时间短,即在更新的时候提前准备好数据,保证事务处理的时间足够的短,流程足够的短,因为开启事务是一直占着连接的,如果流程比较长会消耗过多的数据库连接的 #唯一键 通过在一张表里创建唯一键来获取锁,比如执行 saveStore 这个方法 insert table lock_store ('method_name') values($method_name) 其中 method_name 是个唯一键,通过这种方式也可以做到,解锁的时候直接删除改行记录就行。不过这种方式,锁就不会是阻塞式的,因为插入数据是立马可以得到返回结果的。 那针对以上数据库实现的两种分布式锁,存在什么样的优缺点呢? #优点 简单,方便,快速实现 #缺点 基于数据库,开销比较大,性能可能会存在影响 基于数据库的当前读来实现,数据库会在底层做优化,可能用到索引,可能不用到索引,这个依赖于查询计划的分析 #Zookeeper 实现 #获取锁 1.先有一个锁跟节点,lockRootNode,这可以是一个永久的节点 2.客户端获取锁,先在 lockRootNode 下创建一个顺序的瞬时节点,保证客户端断开连接,节点也自动删除 3.调用 lockRootNode 父节点的 getChildren() 方法,获取所有的节点,并从小到大排序,如果创建的最小的节点是当前节点,则返回 true,获取锁成功,否则,关注比自己序号小的节点的释放动作(exist watch),这样可以保证每一个客户端只需要关注一个节点,不需要关
注所有的节点,避免羊群效应。 4.如果有节点释放操作,重复步骤 3 #释放锁 只需要删除步骤 2 中创建的节点即可 使用 Zookeeper 的分布式锁存在什么样的优缺点呢? #优点 客户端如果出现宕机故障的话,锁可以马上释放 可以实现阻塞式锁,通过 watcher 监听,实现起来也比较简单 集群模式,稳定性比较高 #缺点 一旦网络有任何的抖动,Zookeeper 就会认为客户端已经宕机,就会断掉连接,其他客户端就可以获取到锁。当然 Zookeeper 有重试机制,这个就比较依赖于其重试机制的策略了 性能上不如缓存 #Redis 实现 我们先举个例子,比如现在我要更新产品的信息,产品的唯一键就是 productId #简单实现 1 public boolean lock(String key, V v, int expireTime){ int retry = 0; //获取锁失败最多尝试10次 while (retry < failRetryTimes){ //获取锁 Boolean result = redis.setNx(key, v, expireTime); if (result){ return true; } try { //获取锁失败间隔一段时间重试 TimeUnit.MILLISECONDS.sleep(sleepInterval); } catch (InterruptedException e) { Thread.currentThread().interrupt(); return false; } } return false; } public boolean unlock(String key){ return redis.delete(key); } public static void main(String[] args) { Integer productId = 324324; RedisLock<Integer> redisLock = new RedisLock<Integer>(); redisLock.lock(productId+"", productId, 1000); } } 这是一个简单的实现,存在的问题: 1.可能会导致当前线程的锁误被其他线程释放,比如 a 线程获取到了锁正在执行,但是由于内部流程处理超时或者 gc 导致锁过期,这个时候b线程获取到了锁,a 和 b 线程处理的是同一个 productId,b还在处理的过程中,这个时候 a 处理完了,a 去释放锁,可能就会导
致 a 把 b 获取的锁释放了。 2.不能实现可重入 3.客户端如果第一次已经设置成功,但是由于超时返回失败,此后客户端尝试会一直失败 针对以上问题我们改进下: 1.v 传 requestId,然后我们在释放锁的时候判断一下,如果是当前 requestId,那就可以释放,否则不允许释放 2.加入 count 的锁计数,在获取锁的时候查询一次,如果是当前线程已经持有的锁,那锁技术加 1,直接返回 true #简单实现 2 private static volatile int count = 0; public boolean lock(String key, V v, int expireTime){ int retry = 0; //获取锁失败最多尝试10次 while (retry < failRetryTimes){ //1.先获取锁,如果是当前线程已经持有,则直接返回 //2.防止后面设置锁超时,其实是设置成功,而网络超时导致客户端返回失败,所以获取锁之前需要查询一下 V value = redis.get(key); //如果当前锁存在,并且属于当前线程持有,则锁计数+1,直接返回 if (null != value && value.equals(v)){ count ++; return true; } //如果锁已经被持有了,那需要等待锁的释放 if (value == null || count <= 0){ //获取锁 Boolean result = redis.setNx(key, v, expireTime); if (result){ count = 1; return true; } } try { //获取锁失败间隔一段时间重试 TimeUnit.MILLISECONDS.sleep(sleepInterval); } catch (InterruptedException e) { Thread.currentThread().interrupt(); return false; } } return false; } public boolean unlock(String key, String requestId){ String value = redis.get(key); if (Strings.isNullOrEmpty(value)){ count = 0; return true; } //判断当前锁的持有者是否是当前线程,如果是的话释放锁,不是的话返回false if (value.equals(requestId)){ if (count > 1){ count -- ; return true; } boolean delete = redis.delete(key); if (delete){ count = 0; } return delete; } return false; } public static void main(String[] args) { Integer productId = 324324; RedisLock<String> redisLock = new RedisLock<String>(); String requestId = UUID.randomUUID().toString(); redisLock.lock(productId+"", requestId, 1000); } 这种实现基本解决了误释放和可重入的问题,这里说明几点: 1.引入 count 实现重入的话,看业务需要,并且在释放锁的时候,其实也可以直接就把锁删除了,一次释放搞定,不需要在通过 count 数量释放多次,看业务需要吧 2.关于要考虑设置锁超时,所以需要在设置锁的时候查询一次,可能会有性能的考量,看具体业务吧 3.目前获取锁失败的等待时间是在代码里面设置的,可以提出来,修改下等待的逻辑即可 #错误实现 获取到锁之后要检查下锁的过期时间,如果锁过期了要重新设置下时间,大致代码如下: public boolean tryLock2(String key, int expireTime){ long expires = System.currentTimeMillis() + expireTime; // 获取锁 Boolean result = redis.setNx(key, expires, expireTime); if (result){ return true; } V value = redis.get(key); if (value != null && (Long)value < System.currentTimeMillis()){ // 锁已经过期 String oldValue = redis.getSet(key, expireTime); if (oldValue != null && oldValue.equals(value)){ return true; } } return false; } 这种实现存在的问题,过度依赖当前服务器的时间了,如果在大量的并发请求下,都判断出了锁过期,而这个时候再去设置锁的时候,最终是会只有一个线程,但是可能会导致不同服务器根据自身不同的时间覆盖掉最终获取锁的那个线程设置的时间。 #Tair 实现 通过 Tair 来实现分布式锁和 Redis 的实现核心差不多,不过 Tair 有个很方便的 api,感觉是实现分布式锁的最佳配置,就是 Put api 调用的时候需要传入一个 version,就和数据库的乐观锁一样,修改数据之后,版本会自动累加,如果传入的版本和当前数据版本不一
致,就不允许修改。
分布式事务
#分布式一致性 在分布式系统中,为了保证数据的高可用,通常,我们会将数据保留多个副本(replica),这些副本会放置在不同的物理的机器上。为了对用户提供正确的 CRUD 等语义,我们需要保证这些放置在不同物理机器上的副本是一致的。 为了解决这种分布式一致性问题,前人在性能和数据一致性的反反复复权衡过程中总结了许多典型的协议和算法。其中比较著名的有二阶提交协议(Two Phase Commitment Protocol)、三阶提交协议(Three Phase Commitment Protocol) 和 Paxos 算法。 #分布式事务 分布式事务是指会涉及到操作多个数据库的事务。其实就是将对同一库事务的概念扩大到了对多个库的事务。目的是为了保证分布式系统中的数据一致性。分布式事务处理的关键是必须有一种方法可以知道事务在任何地方所做的所有动作,提交或回滚事务的决定必须产生统一的结
果(全部提交或全部回滚) 在分布式系统中,各个节点之间在物理上相互独立,通过网络进行沟通和协调。由于存在事务机制,可以保证每个独立节点上的数据操作可以满足 ACID。但是,相互独立的节点之间无法准确的知道其他节点中的事务执行情况。所以从理论上讲,两台机器理论上无法达到一致的状态。如
果想让分布式部署的多台机器中的数据保持一致性,那么就要保证在所有节点的数据写操作,要不全部都执行,要么全部的都不执行。但是,一台机器在执行本地事务的时候无法知道其他机器中的本地事务的执行结果。所以他也就不知道本次事务到底应该 commit 还是 rollback。所
以,常规的解决办法就是引入一个“协调者”的组件来统一调度所有分布式节点的执行。 #XA 规范 X/Open 组织(即现在的 Open Group )定义了分布式事务处理模型。 X/Open DTP 模型( 1994 )包括应用程序( AP )、事务管理器( TM )、资源管理器( RM )、通信资源管理器( CRM )四部分。一般,常见的事务管理器( TM )是交易中间件,常见的资源管理
器( RM )是数据库,常见的通信资源管理器( CRM )是消息中间件。 通常把一个数据库内部的事务处理,如对多个表的操作,作为本地事务看待。数据库的事务处理对象是本地事务,而分布式事务处理的对象是全局事务。 所谓全局事务,是指分布式事务处理环境中,多个数据库可
能需要共同完成一个工作,这个工作即是一个全局事务,例如,一个事务中可能更新几个不同的数据库。对数据库的操作发生在系统的各处但必须全部被提交或回滚。此时一个数据库对自己内部所做操作的提交不仅依赖本身操作是否成功,还要依赖与全局事务相关的其它数据库的操
作是否成功,如果任一数据库的任一操作失败,则参与此事务的所有数据库所做的所有操作都必须回滚。 一般情况下,某一数据库无法知道其它数据库在做什么,因此,在一个 DTP 环境中,交易中间件是必需的,由它通知和协调相关数据库的提交或回滚。而一个数据库只将其自己所做
的操作(可恢复)影射到全局事务中。 XA 就是 X/Open DTP 定义的交易中间件与数据库之间的接口规范(即接口函数),交易中间件用它来通知数据库事务的开始、结束以及提交、回滚等。 XA 接口函数由数据库厂商提供。 二阶提交协议和三阶提交协议就是根据这一思想衍生出来的。可以说二阶段提交其实就是实现 XA 分布式事务的关键(确切地说:两阶段提交主要保证了分布式事务的原子性:即所有结点要么全做要么全不做) #2PC 二阶段提交(Two-phaseCommit)是指,在计算机网络以及数据库领域内,为了使基于分布式系统架构下的所有节点在进行事务提交时保持一致性而设计的一种算法(Algorithm)。通常,二阶段提交也被称为是一种协议(Protocol))。在分布式系统中,每个节点虽然可以知晓自己的操
作时成功或者失败,却无法知道其他节点的操作的成功或失败。当一个事务跨越多个节点时,为了保持事务的ACID特性,需要引入一个作为协调者的组件来统一掌控所有节点(称作参与者)的操作结果并最终指示这些节点是否要把操作结果进行真正的提交(比如将更新后的数据写入磁盘等
等)。因此,二阶段提交的算法思路可以概括为:参与者将操作成败通知协调者,再由协调者根据所有参与者的反馈情报决定各参与者是否要提交操作还是中止操作。 所谓的两个阶段是指:第一阶段:准备阶段(投票阶段) 和第二阶段:提交阶段(执行阶段)。 #准备阶段 事务协调者(事务管理器)给每个参与者(资源管理器)发送 Prepare 消息,每个参与者要么直接返回失败(如权限验证失败),要么在本地执行事务,写本地的 redo 和 undo 日志,但不提交,到达一种“万事俱备,只欠东风”的状态。 可以进一步将准备阶段分为以下三个步骤: 1.协调者节点向所有参与者节点询问是否可以执行提交操作(vote),并开始等待各参与者节点的响应。 2.参与者节点执行询问发起为止的所有事务操作,并将 Undo 信息和 Redo 信息写入日志。(注意:若成功这里其实每个参与者已经执行了事务操作) 3.各参与者节点响应协调者节点发起的询问。如果参与者节点的事务操作实际执行成功,则它返回一个”同意”消息;如果参与者节点的事务操作实际执行失败,则它返回一个”中止”消息。 #提交阶段 如果协调者收到了参与者的失败消息或者超时,直接给每个参与者发送回滚( Rollback )消息;否则,发送提交( Commit )消息;参与者根据协调者的指令执行提交或者回滚操作,释放所有事务处理过程中使用的锁资源。(注意:必须在最后阶段释放锁资源) 接下来分两种情况分别讨论提交阶段的过程。 当协调者节点从所有参与者节点获得的相应消息都为”同意”时:
1.协调者节点向所有参与者节点发出”正式提交( commit )”的请求。 2.参与者节点正式完成操作,并释放在整个事务期间内占用的资源。 3.参与者节点向协调者节点发送”完成”消息。 4.协调者节点受到所有参与者节点反馈的”完成”消息后,完成事务。 如果任一参与者节点在第一阶段返回的响应消息为”中止”,或者 协调者节点在第一阶段的询问超时之前无法获取所有参与者节点的响应消息时:
1.协调者节点向所有参与者节点发出”回滚操作( rollback )”的请求。 2.参与者节点利用之前写入的 Undo 信息执行回滚,并释放在整个事务期间内占用的资源。 3.参与者节点向协调者节点发送”回滚完成”消息。 4.协调者节点受到所有参与者节点反馈的”回滚完成”消息后,取消事务。 不管最后结果如何,第二阶段都会结束当前事务。 二阶段提交看起来确实能够提供原子性的操作,但是不幸的事,二阶段提交还是有几个缺点的: 1.同步阻塞问题:执行过程中,所有参与节点都是事务阻塞型的。当参与者占有公共资源时,其他第三方节点访问公共资源不得不处于阻塞状态。 2.单点故障:由于协调者的重要性,一旦协调者发生故障。参与者会一直阻塞下去。尤其在第二阶段,协调者发生故障,那么所有的参与者还都处于锁定事务资源的状态中,而无法继续完成事务操作。(如果是协调者挂掉,可以重新选举一个协调者,但是无法解决因为协调者宕机导
致的参与者处于阻塞状态的问题) 3.数据不一致:在二阶段提交的阶段二中,当协调者向参与者发送 commit 请求之后,发生了局部网络异常或者在发送 commit 请求过程中协调者发生了故障,这回导致只有一部分参与者接受到了 commit 请求。而在这部分参与者接到 commit 请求之后就会执行 commit 操
作。但是其他部分未接到 commit 请求的机器则无法执行事务提交。于是整个分布式系统便出现了数据部一致性的现象。 4.二阶段无法解决的问题:协调者再发出 commit 消息之后宕机,而唯一接收到这条消息的参与者同时也宕机了。那么即使协调者通过选举协议产生了新的协调者,这条事务的状态也是不确定的,没人知道事务是否被已经提交。 由于二阶段提交存在着诸如同步阻塞、单点问题、脑裂等缺陷,所以,研究者们在二阶段提交的基础上做了改进,提出了三阶段提交。 #3PC 三阶段提交(Three-phase commit),也叫三阶段提交协议(Three-phase commit protocol),是二阶段提交(2PC)的改进版本。
与两阶段提交不同的是,三阶段提交有两个改动点。 1.引入超时机制。同时在协调者和参与者中都引入超时机制。 2.在第一阶段和第二阶段中插入一个准备阶段。保证了在最后提交阶段之前各参与节点的状态是一致的。 也就是说,除了引入超时机制之外,3PC 把 2PC 的准备阶段再次一分为二,这样三阶段提交就有 CanCommit、PreCommit、DoCommit 三个阶段。 #CanCommit 阶段 3PC 的 CanCommit 阶段其实和 2PC 的准备阶段很像。协调者向参与者发送 commit 请求,参与者如果可以提交就返回 Yes 响应,否则返回 No 响应。 1.事务询问:协调者向参与者发送CanCommit请求。询问是否可以执行事务提交操作。然后开始等待参与者的响应。 2.响应反馈:参与者接到CanCommit请求之后,正常情况下,如果其自身认为可以顺利执行事务,则返回Yes响应,并进入预备状态。否则反馈No #PreCommit 阶段 协调者根据参与者的反应情况来决定是否可以记性事务的 PreCommit 操作。根据响应情况,有以下两种可能。 1. 假如协调者从所有的参与者获得的反馈都是 Yes 响应,那么就会执行事务的预执行。 发送预提交请求:协调者向参与者发送 PreCommit 请求,并进入 Prepared 阶段。 事务预提交:参与者接收到 PreCommit 请求后,会执行事务操作,并将 undo 和 redo 信息记录到事务日志中。 响应反馈:如果参与者成功的执行了事务操作,则返回 ACK 响应,同时开始等待最终指令。 2. 假如有任何一个参与者向协调者发送了 No 响应,或者等待超时之后,协调者都没有接到参与者的响应,那么就执行事务的中断。 发送中断请求:协调者向所有参与者发送 abort 请求。 中断事务:参与者收到来自协调者的 abort 请求之后(或超时之后,仍未收到协调者的请求),执行事务的中断。 #doCommit 阶段 该阶段进行真正的事务提交,也可以分为以下两种情况。 1. 执行提交 发送提交请求:协调接收到参与者发送的 ACK 响应,那么他将从预提交状态进入到提交状态。并向所有参与者发送 doCommit 请求。 事务提交:参与者接收到 doCommit 请求之后,执行正式的事务提交。并在完成事务提交之后释放所有事务资源。 响应反馈:事务提交完之后,向协调者发送 ACK 响应。 完成事务:协调者接收到所有参与者的 ACK 响应之后,完成事务。 2. 中断事务 协调者没有接收到参与者发送的 ACK 响应(可能是接受者发送的不是 ACK 响应,也可能响应超时),那么就会执行中断事务。 发送中断请求:协调者向所有参与者发送 abort 请求 事务回滚:参与者接收到 abort 请求之后,利用其在阶段二记录的 undo 信息来执行事务的回滚操作,并在完成回滚之后释放所有的事务资源。 反馈结果:参与者完成事务回滚之后,向协调者发送 ACK 消息 中断事务:协调者接收到参与者反馈的ACK消息之后,执行事务的中断。 在 doCommit 阶段,如果参与者无法及时接收到来自协调者的 doCommit 或者 abort 请求时,会在等待超时之后,会继续进行事务的提交。(其实这个应该是基于概率来决定的,当进入第三阶段时,说明参与者在第二阶段已经收到了 PreCommit 请求,那么协调者产
生 PreCommit 请求的前提条件是他在第二阶段开始之前,收到所有参与者的 CanCommit 响应都是 Yes。(一旦参与者收到了 PreCommit,意味他知道大家其实都同意修改了)所以,一句话概括就是,当进入第三阶段时,由于网络超时等原因,虽然参与者没有收到 commit 或
者 abort 响应,但是他有理由相信:成功提交的几率很大。) #2PC 与 3PC 的区别 相对于 2PC,3PC 主要解决的单点故障问题,并减少阻塞,因为一旦参与者无法及时收到来自协调者的信息之后,他会默认执行 commit。而不会一直持有事务资源并处于阻塞状态。但是这种机制也会导致数据一致性问题,因为,由于网络原因,协调者发送的 abort 响应没有及时被参
与者接收到,那么参与者在等待超时之后执行了 commit 操作。这样就和其他接到 abort 命令并执行回滚的参与者之间存在数据不一致的情况。
集群与负载均衡的算法与实现
#负载均衡
什么是负载均衡呢?用户输入的流量通过负载均衡器按照某种负载均衡算法把流量均匀的分散到后端的多个服务器上,接收到请求的服务器可以独立的响应请求,达到负载分担的目的。从应用场景上来说,常见的负载均衡模型有全局负载均衡和集群内负载均衡,从产品形态角度来说,
又可以分为硬件负载均衡和软件负载均衡。全局负载均衡一般通过DNS实现,通过将一个域名解析到不同VIP,来实现不同的region调度能力;硬件负载均衡器常见的有F5、A10、Array,它们的优缺点都比较明显,优点是功能强大,有专门的售后服务团队,性能比较好,缺点是缺少
定制的灵活性,维护成本较高;现在的互联网更多的思路是通过软件负载均衡来实现,这样可以满足各种定制化需求,常见的软件负载均衡有 LVS、Nginx、Haproxy。
阿里云高性能负载均衡使用 LVS 和 Tengine,我们在一个 region 区分不同的机房,每个机房都有 LVS 集群和 Tengine 集群,对于用户配置的四层监听,LVS 后面会直接挂载用户 ECS,七层用户监听 ECS 则挂载在 Tengine 上,四层监听的流量直接由 LVS 转发
到 ECS,而 7 层监听的流量会经过 LVS 到 Tenigine 再到用户 ECS。每一个 region 里都会有多个可用区,达到主备容灾目的,每一个集群里都有多台设备,第一是为了提升性能,第二也是基于容灾考虑。
图为高性能负载均衡控制管理概要图,SLB 产品也有 SDN 概念,转发和控制是分离的,用户所有配置通过控制台先到控制器,通过集中控制器转换将用户配置推送到不同设备上,每台设备上都有 Agent 接收控制器下发的需求,通过本地转换成 LVS 和 Tengine 能够识别的配
置,这个过程支持热配置,不影响用户转发,不需要 reload 才能使新配置生效。
#LVS
#LVS 支持的三种模式
早期 LVS 支持三种模式,DR 模式、TUN 模式和 NAT 模式。
#DR 模式
DR 模式经过 LVS 之后,LVS 会将 MAC 地址更改、封装 MAC 头,内层 IP 报文不动,报文经过 LVS 负载均衡查找到 RS 之后,将源 MAC 头改成自己的,目的 MAC 改成 RS 地址,MAC 寻址是在二层网络里,对网络部署有一定的限定,在大规模分布式集群部署里,这种模
式的灵活性没有办法满足需求;
#TUN 模式
TUN 模式走在 LVS 之后,LVS 会在原有报文基础上封装 IP 头,到了后端 RS 之后,RS 需要解开 IP 报文封装,才能拿到原始报文,不管是 DR 模式还是 TUN 模式,后端 RS 都可以看到真实客户源 IP,目的 IP 是自己的 VIP,VIP 在 RS 设备上需要配置,这样可以直
接绕过 LVS 返回给用户,TUN 模式问题在于需要在后端 ECS 上配置解封装模块,在 Linux 上已经支持这种模块,但是 Windows 上还没有提供支持,所以会对用户系统镜像选择有限定。
#NAT 模式
NAT 模式用户访问的是 VIP,LVS 查找完后会将目的 IP 做 DNAT 转换,选择出 RS 地址,因为客户端的 IP 没变,在回包的时候直接向公网真实客户端 IP 去路由,NAT 的约束是因为 LVS 做了 DNAT 转换,所以回包需要走 LVS,把报文头转换回去,由于 ECS 看到的是客
户端真实的源地址,我们需要在用户 ECS 上配置路由,将到 ECS 的默认路由指向 LVS 上,这对用户场景也做了限制。
#LVS 基于 NetFilter 框架实现
NetFilter 是 Linux 提供的网络开放平台,基于平台可以开发自己的业务功能模块,早期好多安全厂商都是基于 NetFilter 做一些业务模型实现,这种模型比较灵活,但通用模型里更多的是兼容性考虑,路径会非常长;而且通用模型中没办法发挥多核特性,目前 CPU 的发展更
多是向横向扩展,我们经常见到多路服务器,每路上有多少核,早期通用模型对多核支持并不是特别友善,在多核设计上有些欠缺,导致我们在通用模型上做一些应用开发时的扩展性是有限的,随着核的数量越来越多,性能不增反降。
#LVS 的改进
早期模式的各种限制制约了我们的发展,所以我们首先做了 FullNAT,相比原来的 NAT 方式,FullNAT 多了 SNAT 属性,将客户端的原 IP 地址作了转换;其次,我们在并行化上做了处理,充分利用多核实现性能线性提升;然后是快速路径,我们在做网络转发模型时很容易想
到设计快速路径和慢速路径,慢速路径更多是解决首包如何通过设备问题,可能需要查ACL或路由,需要判断许多和策略相关的东西,后面所有报文都可以通过快速路径转发出去;还有指令相关优化,利用因特尔特殊指令提升性能;另外针对多核架构,NUMA 多节点内存访问,通
过访问 Local 节点内存可能获得更好的延迟表现。
客户端进来 IP 首先访问 LVS 的 VIP,原 IP 是客户端的,目的 IP 是 LVS 的 VIP,经过 FullNAT 转换后,原 IP 变成 LVS 的 Local 地址,目的地址是 LVS 选择出来的 RS 地址,这样在 RS 回包时比较容易,只要路由可达,报文一定会交到 LVS 上,不需
要在 RS 上做特殊的配置。右面就是 DNAT + SNAT 转换,报文就可以通过 LVS 转发回客户端,这种方式主要带来应用场景部署灵活性选择。
通过并行化实现对 LVS 性能的改善,性能没有办法得到线性提升更多的是因为每条路径都需要访问全局资源,就会不可避免引入锁的开箱,另外,同一条链接上的报文可能分散在不同的核上,大家去访问全局资源时也会导致 cache 的丢失。所以我们通过 RSS 技术把同一
个五源组报文扔到同一个 CPU 上处理,保证入方向的所有相同连接上的报文都能交给相同 CPU 处理,每个核在转发出去时都用当前 CPU 上的 Local 地址,通过设置一些 fdir 规则,报文回来时后端 RS 访问的目的地址就是对应 CPU 上的 local 地址,可以交到指
定的 CPU 上去处理,这样一条连接上左右方向报文都可以交给同一个 CPU 处理,将流在不同的 CPU 隔离开;另外,我们把所有配置资源包括动态缓存资源在每个 CPU 上作了拷贝,将资源局部化,这使整个流从进入 LVS 到转发出去访问的资源都是固定在一个核上的本地资源,使性
能达到最大化,实现线性提升。
改进后的 LVS 表现如下:
出于对容灾和性能提升的考虑,我们做了集群化部署,每个 region 有不同机房,每个机房有多个调度单元,每个单元有多台 LVS 设备;
每台 LVS 经过优化后,都能达到更高性能,大容量,单台 LVS 可以达到 4000W PPS,600W CPS、单个 group 可以到达 1亿 并发;
支持 region、IDC、集群和应用级的高可用;
实现了防攻击功能,并在原版 LVS 上提供了更丰富的功能,可以基于各个维度做管理控制,精确的统计,流量的分析等。
#Tengine
Tengine 在应用过程中也遇到了各种问题,最严重的就是性能问题,我们发现随着 CPU 数量越来越多,QPS 值并没有线性提升;Nginx 本身是多 worker 模型,每个 worker 是单进程模式,多 worker 架构做 CPU 亲和,内部基于事件驱动的模型,其本身已经提供了很
高的性能,单核 Nginx 可以跑到 1W5~2W QPS。Nginx 往下第一层是 socket API,socket 往下有一层 VFS,再往下是 TCP、IP,socket 层比较薄,经过量化的分析和评估,性能开销最大的是 TCP 协议栈和 VFS 部分,因为同步开销大,我们发现横向扩展不行,对此,
我们做了一些优化。
七层反向代理的路径更长,处理更复杂,所以它的性能比 LVS 低很多,我们比较关注单机和集群的性能,集群性能可以靠堆设备去解决,单机如果不提升,成本会一直增加,从性能角度来看,有以下的优化思路和方向:
基于 Kernel 做开发,比如优化协议栈;
基于 AliSocket 的优化,AliSocket 是阿里研发的高性能 TCP 协议栈平台,底层是 DPDK,它将资源做了局部化处理,报文分发不同核处理,性能非常出色;
HTTPS 业务越来越多,流量逐步递增,我们采用硬件加速卡方式做一些加解密的性能提升,还有 HTTPS 的会话复用;
基于 Web 传输层的性能优化