当前位置: 首页 > 科技观察

微服务架构的四大金刚武器

时间:2023-03-16 16:54:58 科技观察

【编者按】互联网应用发展到今天,从单体应用架构到SOA,再到今天的微服务。随着微服务的不断升级和演进,服务之间的关系稳定性变得越来越重要。分布式系统复杂的主要原因是分布式系统需要考虑网络的延迟和不可靠性。微服务最重要的特性之一就是保证服务的幂等性和幂等性。稳定性很重要的前提,需要分布式锁来控制并发。同时,缓存、降级、限流是保护微服务系统稳定性的三大武器。随着业务的不断发展,按照业务领域划分的子系统越来越多。每个业务系统都需要缓存、限流、分布式锁、幂等工具组件。distributed-tools组件(尚未开源)官方包含了上述分布式系统所需的基本功能组件。distributed-tools组件提供了两个分别基于Tair和Redis的springbootstarter,使用起来非常简单。以使用redis为例,在application.properties中添加如下配置:redis.extend.hostName=127.0.0.1redis.extend.port=6379redis.extend.password=pwdcoderedis.extend.timeout=10000redis.idempotent.enabled=true在接下来的几页中,我们将重点介绍缓存、限流、分布式锁和幂等的使用。缓存的使用可以说是无处不在。从应用请求的访问路径来看,用户用户->浏览器缓存->反向代理缓存->WEB服务器缓存->应用缓存->数据库缓存等,几乎每一个环节都充满了缓存的使用,而对缓存最直白的解释就是“以空间换时间”的算法。缓存就是把一些数据暂时存放在某个地方,可能是内存,也可能是硬盘。总之,目的就是为了避免一些耗时的操作。我们常见的耗时操作,比如数据库查询,一些数据的计算结果,或者是为了减轻服务器的压力。其实压力的降低也是因为查询或者计算。虽然短、耗时,但是操作非常频繁,积累也很长,造成严重的排队等情况,服务器无法抗拒。distributed-tools组件提供了CacheEngine接口,基于Tair和Redis有不同的实现。具体的CacheEngine定义如下:publicStringget(Stringkey);/***获取指定key对应的对象,异常会返回null**@paramkey*@paramclazz*@return*/publicTget(Stringkey,Classclz);/***存储缓存数据,忽略过期时间**@paramkey*@paramvalue*@return*/publicbooleanput(Stringkey,Tvalue);/***存储缓存数据**@paramkey*@paramvalue*@paramexpiredTime*@paramunit*@return*/publicbooleanput(Stringkey,Tvalue,intexpiredTime,TimeUnitunit);/***根据key删除缓存数据**@paramkey*@return*/publicbooleaninvalid(Stringkey);get方法查询key,put存储缓存数据,invalid删除缓存数据。限流在分布式系统中,尤其是面对一些尖峰和瞬时高并发的场景,需要一些限流措施来保证系统的高可用。一般来说,限流的目的是通过限制并发访问/请求的速率,或者限制一个时间窗口内的请求速率来保护系统。一旦达到限制速率,可以拒绝服务(跳转到错误页面或告知资源不可用)、排队或等待(如秒杀、评论、下单)、降级(返回后台数据)或默认数据,如商品详情页默认有库存)。一些常见的限流算法包括固定窗口、滑动窗口、漏桶和令牌桶。distributed-tools组件目前只实现了基于计数器的固定窗口算法。具体用法如下:默认每次+1,非滑动窗口**@paramkey计数器自增key*@paramexpireTime过期时间*@paramunit时间单位*@return*/publiclongincrCount(Stringkey,intexpireTime,TimeUnitunit);/***指定过期时间自增Counter,单位时间内超过最大值rateThreshold则返回true,否则返回false**@paramkey限流key*@paramrateThreshold限流阈值*@paramexpireTime固定窗口时间*@paramunit时间单位*@return*/publicbooleanrateLimit(finalStringkey,finalintrateThreshold,intexpireTime,TimeUnitunit);基于CacheEngine的rateLimit方法可以实现限流,expireTime只能设置固定窗口时间,不能设置滑动窗口时间。另外,distributed-tools组件提供了模板RateLimitTemplate,可以简化限速的易用性,直接调用RateLimitTemplate的execute方法即可处理限速问题。/***@paramlimitKey限流KEY*@paramresultSupplier回调方式*@paramrateThreshold限流阈值*@paramlimitTime限流时间段*@paramblockDuration阻塞时间段*@paramunit时间单位*@paramerrCodeEnum指定限流错误码*@return*/publicTexecute(StringlimitKey,SupplierresultSupplier,longrateThreshold,longlimitTime,longblockDuration,TimeUnitunit,ErrCodeEnumerrCodeEnum){booleanblocked=tryAcquire(limitKey,rateThreshold,limitTime,blockDuration,unit);if(errCodeEnum!=null){AssertUtils.assertTrue(blocked,errCodeEnum);}else{AssertUtils.assertTrue(blocked,ExceptionEnumType.ACQUIRE_LOCK_FAIL);}returnresultSupplier.get();}另外,distributed-tools组件还提供了注解@RateLimit的用法,具体注解RateLimit定义如下:@Retention(RetentionPolicy.RUNTIME)@Target(ElementType.METHOD)@Documentedpublic@interfaceRateLimit{/***限制当前KEY*/StringlimitKey();/***允许访问次数,默认值为MAX_VALUE*/longlimitCount()defaultLong.MAX_VALUE;/***时间段*/longtimeRange();/***阻塞时间段*/longblockDuration();/***时间单位,默认为秒*/TimeUnittimeUnit()defaultTimeUnit.SECONDS;}基于注解的限流方式是使用代码如接下来:@RateLimit(limitKey="#key",limitCount=5,timeRange=2,blockDuration=3,timeUnit=TimeUnit.MINUTES)publicStringtestLimit2(Stringkey){.......returnkey;}任何方法添加以上注解具有一定的限流能力(具体方法需要在springaop指定的拦截范围内)。以上代码表示使用参数key作为限流key,每2分钟请求次数不超过5次。超过限制后,将被阻塞3分钟。分布式锁可以在单个Java进程中通过synchronized关键字和ReentrantLock可重入锁来控制多线程环境下对资源的并发访问。通常,本地锁定不能满足我们的需求。我们更多面对的场景是分布式系统中的跨进程锁,简称分布式锁。分布式锁的实现方式通常会将锁标志存储在内存中,但内存并不是某个进程分配的内存,而是Redis、Tair等公共内存。至于使用数据库、文件等,确保标签是互斥的。分布式锁比单机进程的锁复杂的主要原因是分布式系统需要考虑网络延迟和不可靠性。distributed-tools组件提供的分布式锁必须具备以下特点:互斥:与本地锁具有相同的互斥性,但分布式锁需要保证不同节点进程中不同线程的互斥性。可重入性:如果同一个节点上的同一个线程获得了锁,它也可以再次获得锁。锁超时:和本地锁一样,支持锁超时防止死锁,通过异步心跳恶魔线程刷新过期时间,防止特殊场景下的死锁(比如FGC死锁超时)。高性能和高可用性:加锁和解锁需要高性能,同时也需要高可用性,防止分布式锁失效,增加降级。支持阻塞和非阻塞:支持lock和trylock以及类似ReentrantLock的tryLock(longtimeOut)。公平锁和非公平锁(不支持):公平锁是按照申请锁的顺序获取的,非公平锁是无序的。目前distributed-tools组件提供的分布式锁不支持该功能。distributed-tools组件提供的分布式锁使用起来非常简单。它提供了分布式锁模板:DistributedLockTemplate,可以直接调用模板提供的静态方法(如下):/***分布式锁处理模板执行器**@paramlockKey分布式锁密钥*@paramresultSupplier分布式锁处理回调*@paramwaitTime锁等待时间*@paramunittimeunit*@paramerrCodeEnum指定一个特殊的错误代码isNotBlank(lockKey),ExceptionEnumType.PARAMETER_ILLEGALL);booleanlocked=false;Locklock=DistributedReentrantLock.newLock(lockKey);try{locked=waitTime>0?lock.tryLock(waitTime,unit):lock.tryLock();}catch(InterruptedExceptione){thrownewRuntimeException(String.format("lockerror,lockResource:%s",lockKey),e);}if(errCodeEnum!=null){AssertUtils.assertTrue(locked,errCodeEnum);}else{AssertUtils.assertTrue(locked,异常枚举类型.ACQUIRE_LOCK_FAIL);}try{returnresultSupplier.get();}finally{lock.unlock();}}幂等性在分布式系统设计中非常重要,在灵活的设计中非常重要,尤其是在复杂的微服务中,一个系统包含多个子系统服务,一个子系统一个服务经常调用另一个服务,一个服务通过RPC通信或者restful调用一个服务。分布式系统中的网络延迟或中断是不可避免的,并且通常会导致服务的调用层触发具有此属性的重试。接口的设计始终坚持这样一个理念:当出现异常并反复尝试调用接口时,总会给系统造成难以承受的损失,所以必须杜绝这种现象的发生。幂等性通常有两个维度:空间维度上的幂等性,即幂等对象的范围,无论是个人还是机构,无论是某笔交易还是某类交易。时间维度上的幂等性,即保证幂等性的时间是小时、天还是永久。真实系统中有很多操作,无论执行多少次,都应该具有相同的效果或返回相同的结果。以下应用场景也是常见的应用场景:当前端重复提交相同请求数据的请求时,后台需要返回与本次请求对应的相同结果。发起支付请求时,支付中心只从用户账户扣款一次,网络中断或系统异常时也只扣款一次。发送消息,相同内容的短信只发送给用户一次。创建一个业务订单,一次只能创建一个业务请求,如果创建多个重试请求,就会出现很大的问题。基于msgId的消息幂等处理。在正式使用distributed-tools组件提供的幂等性之前,我们先来看看distributed-tools幂等组件的设计。幂等密钥提取能力:获取唯一的幂等密钥。幂等密钥提取支持2个注解:IdempotentTxId、IdempotentTxIdGetter。将以上2个注解添加到任意方法中,即可提取出相关的幂等键。前提是需要添加Idempotent注解。关于需要幂等性的方法。如果单纯使用幂等模板进行业务处理,需要自己设置相关的幂等键,并保证其唯一性。分布式锁服务能力:提供全局加锁和解锁的能力distributed-tools幂等组件需要使用自身提供的分布式锁功能来保证其并发唯一性,distributed-tools提供的分布式锁可以为其提供可靠性和稳定性加锁和解锁能力。高性能的写入和查询能力:针对幂等结果查询和存储,分布式工具幂等组件提供基于Tair和redis的存储实现,通过Spring依赖注入IdempotentService支持自定义主备存储。推荐分布式工具幂等存储结果一级存储在TairMDB,二级存储ldb或tablestore,一级存储保证其高性能,二级存储保证其可靠性。二级存储并行查询会返回最快的查询幂等结果。二级存储并行异步写入,进一步提升性能。高可用的幂等写入和查询能力:幂等存储出现异常,不会影响正常的业务流程。添加容错分布式工具幂等组件以支持二级存储。为了保证其高可用,毕竟二级存储发生故障的概率值太低,业务也不会不可用。如果二级存储同时发生故障,业务上会进行一定的容错。对于不确定的异常会采用重试策略,并实现特定的幂等方法。主存和副存的写入和查询处理是隔离的,主存出现任何异常都不会影响整体的业务执行。了解了分布式工具组件的幂等性之后,我们来看看如何使用幂等组件。首先了解common-api提供的幂等注解。幂等注解的具体用法如下:幂等拦截器获取Etc.ID优先级:首先判断Idempotent的spelKey属性是否为空。如果不是,则根据spelKey定义的Spring表达式生成一个幂等ID。其次,判断参数中是否包含IdempotentTxId注??解。如果有IdempotentTxId,则直接获取参数值生成一个幂等ID。再次通过反射获取参数对象属性是否包含IdempotentTxId注??解。如果对象属性中包含IdempotentTxId注??解,则会获取参数对象属性生成一个幂等ID。以上三种情况最终都没有获取到幂等ID,需要进一步通过反射获取参数对象的Method是否定义了IdempotentTxIdGetter注解。如果包含此注解,则将通过反射生成幂等ID。代码使用示例:@Idempotent(spelKey="#request.requestId",firstLevelExpireDate=7,secondLevelExpireDate=30)publicvoidexecute(BizFlowRequestrequest){......................}如上代码所示,requestId作为幂等key从请求中获取,主存有效期为7天,副存有效期为30天。distributed-tools除了使用幂等注解,幂等组件还提供了一个通用的幂等模板IdempotentTemplate,使用幂等模板的前提必须设置为tair.idempotent.enabled=true或redis.idempotent.enabled=true,默认为false,同时需要指定幂等结果的一级存储,幂等结果的存储是可选配置。幂等模板IdempotentTemplate的具体使用方法如下:/***幂等模板处理器**@paramrequest幂等请求信息*@paramexecuteSupplier幂等处理回调函数*@paramresultPreprocessConsumer幂等结果回调函数可以对结果做一些预处理*@paramifResultNeedIdempotence除了异常之外还需要根据结果判断幂等性的场景可以提供该参数*@return*/publicReexecute(IdempotentRequest

request,SupplierexecuteSupplier,Consumer>resultPreprocessConsumer,PredicateifResultNeedIdempotence){.....}request:幂等参数IdempotentRequest组件,可以设置幂等参数和幂等唯一ID。executeSupplier:具体的幂等方法逻辑,比如支付、订单接口等,可以通过JDK8功能接口SupplierCallback进行处理。resultBiConsumer:幂等返回结果的处理。此参数可以为空。如果为空,则采用默认处理。根据幂等结果,如果成功,无法重试的异常错误码直接返回结果。如果失败,异常错误可以重试代码,将执行重试。如果参数值不为空,可以设置ResultStatus进行返回幂等结果的特殊逻辑处理(ResultStatus包含成功、失败可重试、失败不可重试三种状态)。