美团点评智能支付核心交易系统的可用性实践
背景
每个系统都有它最核心的指标。比如在收单领域:进件系统第一重要的是保证入件的准确性,第二重要的是保证上单效率。清结算系统第一重要的是保证准确打款,第二重要的是保证及时打款。我们负责的系统是美团点评智能支付的核心链路,承担着智能支付100%的流量,内部习惯称为核心交易。因为涉及美团点评所有线下交易商家、用户之间的资金流转,对于核心交易来说:第一重要的是稳定性,第二重要的还是稳定性。
稳定重要性
问题引发
作为一个平台部门,我们的目标分为三个阶段:第一阶段是迅速支持业务发展;第二阶段是把握住一个明确的发展方向;第三阶段是在观察市场趋势的基础上,引领一个大方向。
虽然目标宏伟,但现实情况是,从2017年初的每天数十万订单,到年底时,日订单量已经激增至700万,系统面临着前所未有的挑战。支付渠道在不断增加,交易链路在延长,系统复杂性也在不断提升。从最初的POS机到后来的二维码支付产品,如小白盒、小黑盒、秒付等,产品的多样化,使得系统的定位也在不断调整。而系统对于变化的适应速度,就像是与兔子赛跑的乌龟。
由于业务的高速增长,即使系统没有进行任何更新升级,也可能会突然出现事故。事故发生的频率越来越高,而系统自身的升级也常常面临诸多困难。基础设施升级、上下游升级,往往会引发“蝴蝶效应”,系统可能会在毫无预兆的情况下受到影响。
问题分析
核心交易的稳定性问题实质上是如何实现系统的高可用性。
可用性指标
业界高可用的标准是按照系统宕机时间来衡量的:
可用性标准
因为业界的标准是后验的指标,考虑到对于平时工作的指导意义,我们通常采用服务治理平台OCTO来统计可用性。计算方法是:
美团可用性计算
可用性分解
业界系统可靠性还有两个比较常用的关键指标:
-
平均无故障时间(Mean Time Between Failures,简称MTBF):即系统平均能够正常运行多长时间,才发生一次故障。
-
平均修复时间(Mean Time To Repair,简称MTTR):即系统由故障状态转为工作状态时修理时间的平均值。
对于核心交易来说,可用性最好是无故障。然而,在出现故障时,判断其影响的因素除了时间,还包括影响的范围。将核心交易的可用性问题分解则为:
可用性分解
问题解决
1. 发生频率要低之别人死我们不死
1.1 消除依赖、弱化依赖和控制依赖
用STAR法则举一个场景:
情境(situation)
我们要设计一个系统A,其功能是:通过我们美团点评的POS机,使用系统A连接银行进行支付,同时我们会提供一些优惠活动,如满减、积分等。
任务(task)
分析一下对于系统A的显性需求和隐性需求:
-
接收上游传递的参数,参数中包含商家信息、用户信息、设备信息、优惠信息。
-
生成单号,将交易的订单信息落库。
-
敏感信息要加密。
-
要调用下游银行的接口。
-
要支持退款。
-
要把订单信息同步给积分核销等部门。
-
要能给商家一个查看订单的界面。
-
要能给商家进行收款的结算。
基于以上需求,分析一下怎样才能让里面的最核心链路“使用POS机付款”稳定。
行动(action)
分析一下:需求1到4是支付必需的链路,可以将其集成在一个子系统内,称之为收款子系统。需求5到8各自独立,每个都可以作为一个子系统来开发,具体情况取决于开发人员数量、维护成本等因素。
需要注意的是,需求5到8与收款子系统之间没有功能上的依赖,只有数据上的依赖,即它们都需要依赖生成的订单数据。
收款子系统是整个系统的核心,对稳定性有极高的要求。其他子系统出现问题,不能影响到收款子系统。
因此,我们需要在收款子系统和其他子系统之间建立一个解耦,统一管理向其他系统提供的数据。这里称之为“订阅转发子系统”,只要确保这个系统不会影响收款子系统的稳定性即可。
粗略架构图如下:
架构图
结果(result)
从上述架构图可以看出,收款子系统和退款子系统、结算子系统、信息同步子系统、查看订单子系统之间没有直接依赖关系,达到了消除依赖的目的。收款子系统不需要依赖数据订阅转发子系统,而是数据订阅转发子系统需要依赖收款子系统的数据。我们通过控制依赖,使数据订阅转发子系统从收款子系统拉取数据,而不是收款子系统向数据订阅转发子系统推送数据。这样,即使数据订阅转发子系统出现问题,收款子系统也不会受到影响。
再说数据订阅转发子系统拉取数据的方式。例如,数据存储在MySQL数据库中,通过同步Binlog来获取数据。如果采用消息队列进行数据传输,就会对消息队列中间件产生依赖。如果我们设计一个灾备方案:当消息队列出现问题,直接通过RPC调用传输数据。对于这个消息队列,就实现了降低依赖的效果。
1.2 事务中不包含外部调用
外部调用包括对其他系统的调用和基础组件的调用。外部调用的特点是返回时间不可预测,如果将其包含在事务中,会导致事务变得庞大。过大的数据库事务会占用所有数据库连接,导致与该数据库相关的所有服务陷入等待状态,进而使得连接池满负荷,多个服务崩溃。如果处理不好这个问题,危险程度极高,达到五颗星。下面的图显示出外部调用时间的不可控:
大事务问题
解决方法:
-
检查各个系统的代码,看看事务中是否包含RPC调用、HTTP调用、消息队列操作、缓存操作、循环查询等耗时操作。这些操作应该放在事务之外,理想情况下,事务内只处理数据库操作。
-
对大事务添加监控报警。大事务发生时,会收到邮件和短信提醒。针对数据库事务,一般分为1s以上、500ms以上、100ms以上三种级别的事务报警。
-
建议不要使用XML配置事务,而应采用注解方式。原因在于,XML配置事务的可读性不强,切面配置通常过于泛滥,容易导致事务过大,而且对于嵌套情况的规则处理不好。
大事务排除措施
1.3 设置合理的超时和重试
对外部系统和基础组件如缓存、消息队列等的依赖关系。设想一下,如果这些被依赖的对象突然出现问题,我们系统的响应时间将由内部耗时、依赖方超时时间以及重试次数决定。如果超时时间过长或重试次数过多,系统可能会长时间无法返回,导致连接池被耗尽,甚至导致系统崩溃;反之,如果超时时间过短,会导致499错误增加,从而降低系统的可用性。
举个例子:
依赖例子
以服务A为例,它依赖于两个服务的数据来完成某项操作。在正常情况下,没有问题。但是,如果服务B在您不知情的情况下,响应时间变长甚至停止服务,而客户端的超时时间设置过长,那么完成此次请求的响应时间就会变长。在这种情况下,如果发生意外,后果将会非常严重。
Java的Servlet容器,无论是Tomcat还是Jetty都是多线程模型,都用Worker线程来处理请求。这个可配置有上限,当你的请求打满Worker线程的最大值之后,剩余请求会被放到等待队列。等待队列也有上限,一旦等待队列都满了,那这台Web Server就会拒绝服务,对应到Nginx上返回就是502。如果你的服务是QPS较高的服务,那么在这种情况下,你的服务也会受到影响。如果上游服务没有合理设置超时时间,故障会继续向上扩散。这种故障逐级放大的过程,就是所谓的服务雪崩效应。
解决方法:
-
首先要调研被依赖服务自己调用下游的超时时间是多少。调用方的超时时间要大于被依赖方调用下游的时间。
-
统计这个接口99%的响应时间是多少,设置的超时时间在这个基础上加50%。如果接口依赖第三方,而第三方的波动比较大,也可以按照95%的响应时间。
-
如果系统服务的重要性较高,可以按照默认的重试次数,通常是重试三次。否则,可以考虑不进行重试。
1.4 解决慢查询
慢查询会降低应用的响应性能和并发性能。在业务量增加的情况下造成数据库所在的服务器CPU利用率急剧攀升,严重的会导致数据库不响应,只能重启解决。
慢查询,参考:https://tech.meituan.com/2014/06/30/mysql-index.html
慢查询
解决方法:
-
将查询分为实时查询、近实时查询和离线查询。实时查询可以直接穿透数据库,其他的查询则不走数据库,可以使用Elasticsearch来实现一个查询中心,处理近实时查询和离线查询。
-
读写分离。写走主库,读走从库。
-
索引优化。过多的索引会影响数据库的写入性能,而索引不足则会导致查询速度变慢。DBA建议一个数据表的索引数不超过4个。
-
不允许出现大表。MySQL数据库的一张数据表当数据量达到千万级,效率开始急剧下降。
1.5 熔断
在依赖的服务不可用时,服务调用方应通过一些技术手段,提供有损服务,以确保业务的柔性可用性。如果系统没有熔断机制,由于代码逻辑问题上线引发故障、网络问题、调用超时、业务促销导致调用量激增、服务容量不足等原因,服务调用链路上的一个下游服务出现故障,就可能导致接入层的其他业务无法使用。下图是对无熔断影响的鱼骨图分析:
无熔断
解决方法:
-
自动熔断:可以使用Netflix的Hystrix或者美团点评自己研发的Rhino来做快速失败。
-
手动熔断:确认下游支付通道抖动或不可用,可以手动关闭通道。
2. 发生频率要低之自己不作死
自己不作死要做到两点:第一自己不作,第二自己不死。
2.1 不作
关于不作,我总结了以下7点:
-
不当小白鼠:只用成熟的技术,确保系统的稳定不受技术问题的影响。
-
职责单一化:防止职责的交织削弱或影响其完成主要职责的能力。
-
流程规范化:降低人为因素带来的影响。
-
过程自动化:让系统更高效、更安全的运营。
-
容量有冗余:为应对竞争对手系统崩溃导致用户转向我们、大型促销活动等突发情况,以及防灾考虑,系统容量至少要有2倍以上的冗余。
-
持续的重构:持续重构是保证代码长期稳定运行,避免一动就出问题的有效方法。
-
漏洞及时补:美团点评有安全漏洞运维机制,提醒督促各个部门修复安全漏洞。
安全漏洞
2.2 不死
关于不死,地球上有五大不死神兽:能在恶劣环境下停止新陈代谢的“水熊虫”;可以返老还童的“灯塔水母”;在硬壳里休养生息的“蛤蜊”;水、陆、寄生样样都成的“涡虫”;有隐生能力的“轮虫”。它们的共通特征用在系统设计领域上就是自身容错能力强。这里“容错”的概念是:使系统具有容忍故障的能力,即在产生故障的情况下,仍有能力将指定的过程继续完成。容错即是Fault Tolerance,确切地说是容故障(Fault),而并非容错误(Error)。
容错
3. 发生频率要低之不被别人搞死
3.1 限流
在开放的网络环境中,外部系统经常会遇到各种有意或无意的恶意攻击,例如DDoS攻击和用户重复尝试。尽管我们的团队成员都是精英,但我们仍然需要采取措施来保护系统,以免受到上游疏忽的影响。毕竟,没有人能够保证其他同事不会编写一个无限次重试的代码,如果下游返回不符合预期。这些内部和外部的巨量调用,如果不加以保护,往往会扩散到后台服务,最终可能引起后台基础服务宕机。下图是对无限流影响的问题树分析:
无限流
解决方法:
-
通过对服务端的业务性能进行压测,可以分析出一个相对合理的最大QPS值。
-
流量控制中用的比较多的三个算法是令牌桶、漏桶、计数器。可以使用Guava的RateLimiter来实现。其中SmoothBurstry是基于令牌桶算法的,SmoothWarmingUp是基于漏桶算法的。
-
核心交易这边采用美团服务治理平台OCTO做thrift截流。该平台支持接口粒度配额、单机/集群配额、指定消费者配额、测试模式工作以及及时的报警通知。在测试模式下,系统只会报警并不会真正进行限流。关闭测试模式后,超过限流阈值的请求将触发异常处理。限流策略可以随时关闭。
-
可以使用Netflix的Hystrix或者美团点评自己研发的Rhino来做特殊的针对性限流。
4. 故障范围要小之隔离
隔离是指将系统或资源分割开,这样在系统出现问题时,可以限制故障的扩散和影响范围。
服务器物理隔离原则
-
① 内外有别:内部系统与对外开放平台应有所区别对待。
-
② 内部隔离:从上游到下游按通道从物理服务器上进行隔离,并将低流量服务合并。
-
③ 外部隔离:按渠道隔离,渠道之间互不影响。
线程池资源隔离
-
Hystrix通过命令模式,将每个类型的业务请求封装成对应的命令请求。每个命令请求对应一个线程池,创建好的线程池是被放入到ConcurrentHashMap中。
注意:尽管线程池提供了线程隔离,客户端底层代码也必须要有超时设置,不能无限制地阻塞导致线程池一直饱和。
信号量资源隔离
-
开发者可以使用Hystrix限制系统对某一个依赖的最高并发数,这基本上就是一个限流策略。每次调用依赖时都会检查是否达到了信号量的限制值,如果达到限制值,则拒绝请求。
总的来说,隔离技术是一种非常重要的技术,它可以帮助我们设计出更稳定、更健壮的系统。无论是在服务器的设计,还是在线程池和信号量的管理中,都需要运用到隔离技术。通过合理的隔离,我们可以有效地防止故障的扩散,提高系统的稳定性和可靠性。
5. 故障恢复要快之快速发现
发现分为事前发现、事中发现和事后发现。事前发现的主要手段是压测和故障演练;事中发现的主要手段是监控报警;事后发现的主要手段是数据分析。
5.1 全链路线上压测
你的系统是否适合全链路线上压测呢?一般来说,全链路压测适用于以下场景:
-
① 针对链路长、环节多、服务依赖错综复杂的系统,全链路线上压测可以更快更准确的定位问题。
-
② 有完备的监控报警,出现问题可以随时终止操作。
-
③ 有明显的业务峰值和低谷。低谷期就算出现问题对用户影响也比较小。
全链路线上压测的目的主要有:
-
① 了解整个系统的处理能力
-
② 排查性能瓶颈
-
③ 验证限流、降级、熔断、报警等机制是否符合预期并分析数据反过来调整这些阈值等信息
-
④ 发布的版本在业务高峰的时候是否符合预期
-
⑤ 验证系统的依赖是否符合预期
全链路压测的简单实现:
-
① 采集线上日志数据来做流量回放,为了和实际数据进行流量隔离,需要对部分字段进行偏移处理。
-
② 数据着色处理。可以用中间件来获取和传递流量标签。
-
③ 可以使用影子数据表来隔离流量,但需要注意磁盘空间,建议如果磁盘剩余空间不足70%时采用其他方式隔离流量。
-
④ 外部调用可能需要Mock。实现上可以采用一个Mock服务随机产生和线上外部调用返回时间分布的时延。
压测工具上,核心交易这边使用美团点评开发的pTest。
压测工具对比
故障的快速发现是保证系统稳定运行的重要环节。只有快速发现故障,才能快速定位问题,从而快速解决问题,减少故障对系统的影响。全链路线上压力测试是故障快速发现的重要手段之一。它通过模拟用户的操作行为,对系统进行压力测试,从而发现系统可能存在的问题。全链路线上压力测试不仅可以发现系统的问题,还可以帮助我们了解系统的性能,排查性能瓶颈,验证系统的各种机制是否正常工作。因此,全链路线上压力测试是系统维护的重要工具,对于保证系统的稳定运行起着至关重要的作用。
6. 故障恢复要快之快速定位
定位需要靠谱的数据。所谓靠谱就是和要发现的问题紧密相关的,无关的数据会造成视觉盲点,影响定位。所以对于日志,要制定一个简明日志规范。另外系统监控、业务监控、组件监控、实时分析诊断工具也是定位的有效抓手。
简明日志规范
7. 故障恢复要快之快速解决
要解决,提前是发现和定位。解决的速度取决于我们所采用的自动化程度,是完全自动化、半自动化还是手工操作。核心交易的目标是构建一个高可用的系统。我们的宗旨是:“避免重复造轮子,充分利用现有资源。”为此,我们打造了一个集成平台,其主要任务是:“专注于核心交易的高可用性,实现更好、更快、更高效的解决方案。”
在美团点评内部,有许多用于发现、定位和处理问题的系统和平台。然而,如果每次都需要打开不同的链接或登录不同的系统,势必会影响到解决问题的速度。因此,我们需要将这些系统和平台集成起来,实现问题的一站式解决。希望达到的效果举例如下:
解决问题
工具介绍
Hystrix
Hystrix通过实现断路器模式来监控故障。当断路器检测到调用接口出现长时间等待时,它会采用快速失败策略,向上返回一个错误响应,从而达到防止阻塞的目的。在这里,我们将重点介绍Hystrix的线程池资源隔离和信号量资源隔离。
线程池资源隔离
线程池资源隔离
优点
-
使用线程可以完全隔离第三方代码,请求线程可以快速放回。
-
当一个失败的依赖再次变成可用时,线程池将清理,并立即恢复可用,而不是一个长时间的恢复。
-
可以完全模拟异步调用,方便异步编程。
缺点
-
线程池的主要缺点是它增加了CPU,因为每个命令的执行涉及到排队(默认使用SynchronousQueue避免排队),调度和上下文切换。
-
对使用ThreadLocal等依赖线程状态的代码增加复杂性,需要手动传递和清理线程状态(Netflix公司内部认为线程隔离开销足够小,不会造成重大的成本或性能的影响)。
信号量资源隔离
开发者可以使用Hystrix限制系统对某一个依赖的最高并发数。这个基本上就是一个限流策略,每次调用依赖时都会检查一下是否到达信号量的限制值,如达到,则拒绝。
信号量资源隔离
优点
-
不新起线程执行命令,减少上下文切换。
缺点
-
无法配置断路,每次都一定会去尝试获取信号量。
比较一下线程池资源隔离和信号量资源隔离
-
线程隔离是和主线程无关的其他线程来运行的;而信号量隔离是和主线程在同一个线程上做的操作。
-
信号量隔离也可以用于限制并发访问,防止阻塞扩散,与线程隔离的最大不同在于执行依赖代码的线程依然是请求线程。
-
线程池隔离适用于第三方应用或者接口、并发量大的隔离;信号量隔离适用于内部应用或者中间件;并发需求不是很大的场景。
隔离对比
Hystrix的线程池资源隔离和信号量资源隔离都是用于限制对共享资源的并发访问,防止故障扩散。线程池资源隔离适用于外部应用或接口、并发量大的场景,而信号量资源隔离适用于内部应用或中间件,并发需求不是很大的场景。在实际应用中,我们可以根据具体需求选择合适的资源隔离策略。
Rhino
Rhino是美团点评基础架构团队研发并维护的一个稳定性保障组件,提供故障模拟、降级演练、服务熔断、服务限流等功能。和Hystrix对比:
-
内部通过CAT(美团点评开源的监控系统,参见之前的博客“深度剖析开源分布式监控CAT (https://tech.meituan.com/CAT_in_Depth_Java_Application_Monitoring.html)”)进行了一系列埋点,方便进行服务异常报警。
-
接入配置中心,能提供动态参数修改,比如强制熔断、修改失败率等。
每秒上万次,秒杀如何下单处理?
问题场景
传统的优化手段,如数据库的分布式改造和缓存的使用,主要针对的是读请求。
在高并发场景下,尤其是秒杀抢购等极端情况下,系统需要处理大量的写请求。
在秒杀抢购等极端情况下,如何处理每秒上万次的下单请求。
1 秒钟之内,有 1 万个数据库连接同时达到,系统的数据库濒临崩溃,寻找能够应对如此高并发的写请求方案迫在眉睫。
而写请求的处理更为复杂,因为它涉及到数据库的并发控制、事务管理等一系列问题。这时我们想到了消息队列。
消息队列的概念和作用
消息队列(Message Queuing)是一种在多个任务之间传递消息的机制。
它允许应用程序通过网络发送和接收消息,而无需知道消息的发送者和接收者。
消息队列在系统中起到了缓冲和传递的作用,使得系统各个组件之间可以解耦合,提高系统的灵活性和可扩展性。
消息队列的应用
在面对高并发写请求时,消息队列可以发挥以下作用:
-
削峰填谷:消息队列可以作为一个缓冲区,将大量的写请求暂存起来,避免请求直接涌向数据库。这样,就可以平衡系统的负载,避免数据库因并发请求过多而崩溃。
-
异步处理:通过消息队列,我们可以将一些次要的业务流程异步处理,提高系统性能。例如,在秒杀场景中,我们可以将生成订单、扣减库存等主要流程与发送优惠券、增加积分等次要流程分开处理,从而提高整体的处理效率。
-
解耦合:消息队列可以使系统各个组件之间的耦合度降低。例如,在秒杀场景中,我们可以将业务逻辑与数据统计逻辑解耦,使得数据统计不会影响到业务逻辑的执行。
秒杀场景的削峰填谷
在后台启动若干个异步处理程序,消费消息队列中的消息,异步处理程序去执行校验库存、数据库落库、扣积分、扣优惠券等逻辑。
因为只有有限个队列处理线程在执行,所以落入后端数据库上的并发请求是有限的。
而请求是可以在消息队列中被短暂地堆积,如果后端的消息积压太严重,就进行预警和扩容。
如何做消息队列的扩容,请参见:
阿里面试:如何保证RocketMQ消息有序?如何解决RocketMQ消息积压?
秒杀场景的松耦合
除了削峰填谷以外,消息队列的另一个作用是松耦合。
秒杀的链路设计的操作比较多,包括积分、优惠券、库存等等。
一个思路是:可以使用 RPC 的方式来同步地调用,但是这样调用会有两个问题:
-
整体系统的耦合性比较强,当RPC的接口发生故障时,会影响到秒杀系统的可用性。
-
当数据系统需要新的字段,就要变更接口的参数,那么秒杀系统也要随着一起变更。
这时,可以考虑使用消息队列降低其他系统和秒杀系统的耦合度。
秒杀系统产生一条购买数据后,我们可以先把全部数据发送给消息队列,然后其他服务再订阅这个消息队列的话题,这样它们就可以接收到数据,然后再做过滤和处理了。
秒杀系统在这样解耦合之后,数据系统的故障就不会影响到秒杀系统了,同时,当数据系统需要新的字段时,只需要解析消息队列中的消息,拿到需要的数据就好了。
消息队列的实践
在实际应用中,我们需要注意以下几点:
-
消息队列的配置和管理:我们需要合理配置消息队列的大小和容量,以满足系统的需求。同时,我们需要对消息队列进行监控和管理,确保其正常运行。
-
消息的丢失和重复处理:在消息队列中,消息可能会因为各种原因丢失或重复。我们需要设计相应的处理策略,以确保消息的可靠传递。
-
消息队列的扩展性:随着系统规模的扩大,消息队列可能需要支持更多的消费者和生产者。我们需要设计灵活的消息队列架构,以满足系统的扩展性需求。
消息队列MQ的选型
MQ的常见产品
-
ActiveMQ:java语言实现,万级数据吞吐量,处理速度ms级,主从架构,成熟度高
-
RabbitMQ :erlang语言实现,万级数据吞吐量,处理速度us级,主从架构
-
RocketMQ :java语言实现,十万级数据吞吐量,处理速度ms级,分布式架构,功能强,扩展性强
-
kafka :scala语言实现,十万级数据吞吐量,处理速度ms级,分布式架构,功能较少,应用于大数据较多
TPS达到5600万的RocketMQ
RocketMQ是阿里开源的一款非常优秀中间件产品,最早就是kafka的Java版本,脱胎于阿里的另一款队列技术MetaQ,后捐赠给Apache基金会作为一款孵化技术,仅仅经历了一年多的时间就成为Apache基金会的顶级项目。
RocketMQ已经在阿里内部被广泛的应用,并且经受住了多次双十一的这种极致场景的压力(2017年的双十一,RocketMQ流转的消息量达到了万亿级,峰值TPS达到5600万)
总结
消息队列在高并发系统设计中起到了关键的作用。它可以帮助我们处理高并发的写请求,提高系统的性能和稳定性。
然而,使用消息队列也需要我们关注一些实践问题,如消息的丢失和重复处理,以及消息队列的扩展性等。
亿级用户,如何做微服务底层架构?
一、爱奇艺微服务改造背景
爱奇艺技术产品团队为亿万用户提供卓越的视频服务,为了顺应业务的迅猛发展和创新需求,并支持巨量的用户请求,众多团队都自发对其业务系统进行了微服务架构的升级。
在微服务化的过程中,各业务团队根据自身需要选择了不同的开源框架,如Apache Dubbo/Spring Cloud等,此外也存在一些自研性质的框架;此外,为了满足微服务应用的监控需求,一些团队还独立运维了监控系统等基础设施。
随着实践的深入,一些问题逐渐开始暴露,这其中包括:
-
部分基础设施出现重复建设,导致资源浪费且稳定性难以保证;
-
由于技术架构和SDK的不统一,最佳实践在团队间难以迅速推广;
-
技术架构的不统一使得东西向流量中大量引入了业务自行维护的网关,导致链路加长,影响排障效率和响应延时。
为解决上述问题,爱奇艺中间件团队充分了解了业务在微服务实践中的需求和问题,并推出了爱奇艺的微服务标准架构。
在构建标准架构的过程中,我们主要遵循了以下原则:
1、架构统一:在同一个技术领域中,往往有多种技术实现,但过多的技术框架会导致维护成本过高,缺乏专人支持等问题。在微服务标准架构的选型过程中,我们综合了各开源项目的实际情况和业界的主流技术方案,将各个领域中的技术选型进行了统一,每个领域的技术选型原则上均不超过1种;
通过统一技术选型,我们可以降低技术债务,提高开发人员的技术熟练度,从而更高效地推动业务发展。
2、可扩展:微服务标准架构中有不少开发SDK的选型,为了满足各个开发团队不同的业务需求,需要保证各个SDK的可扩展性,如果开源版本无法满足内部需求的,爱奇艺中间件团队都会维护统一化的内部定制版本;
通过提供可扩展的SDK,我们可以更好地支持开发团队的创新和业务拓展。同时,统一的内部定制版本可以确保各个团队在开发过程中遵循相同的技术规范,提高系统的稳定性和可维护性。
3、高可用:构建标准架构的目标之一是将各团队负责的基础设施(例如注册中心、监控系统等)逐步整合到内部公共平台。对相关平台的技术架构审查和可用性维护是我们的重要任务之一。此外,我们还建立了服务成熟度体系SMMI,定期对核心系统及基础服务进行成熟度评估;
通过整合基础设施并建立服务成熟度体系,我们可以更好地监督和管理系统的健康状况,及时发现并解决潜在问题。
4、技术演进:开源软件有其生命周期,需要充分考虑各软件的社区维护状况。例如,在熔断技术选型上,我们选择在标准架构中推广Sentinel,而非已停止维护的Hystrix。此外,标准架构并非固定不变的体系,对于新技术的引入,我们提供了标准化的流程,确保技术体系能够持续迭代;
通过关注开源软件的社区维护状况并引入新技术,我们可以确保标准架构始终保持与时代同步。同时,标准化的技术引入流程有助于规范开发过程,提高系统的可维护性和稳定性。
5、内部开源:在标准架构建设过程中,爱奇艺内部实行内部开源协作模式。除了基础服务部门外,还鼓励业务团队参与这些基础服务的维护工作,共同打造既满足业务需求,又具有一定业界领先水平的微服务技术体系,进一步推动相关标准架构的推广和完善。
通过鼓励业务团队参与基础服务的维护工作,我们可以更好地整合内部资源,提高开发效率。同时,这种协作模式有助于培养技术人才,提升团队的技术实力。
在微服务架构的实践过程中,团队们面临了诸多挑战,包括基础设施的重复建设、技术架构的不统一、链路加长等问题。为了解决这些问题,爱奇艺中间件团队推出了微服务标准架构,并在构建过程中遵循了架构统一、可扩展、高可用、技术演进和内部开源等原则。
通过这一系列举措,爱奇艺旨在打造一个既符合业务需求,又具有业界领先度的微服务技术体系,以推动业务的快速发展。
二、爱奇艺微服务标准架构
下图展示了爱奇艺微服务标准架构的全貌:
标准架构主要包括如下主要内容:
1、统一的微服务开发SDK:核心开发框架Dubbo/Spring Cloud;熔断限流框架Sentinel等;
2、统一的微服务基础设施,包括:
-
注册中心:Nacos/consul;
-
服务网关:基于kong进行二次开发的网关平台,提供鉴权、限流等功能;
-
配置中心:基于携程Apollo进行二次开发的平台
-
指标监控:prometheus集群托管服务;
-
链路监控:全链路平台(基于skywalking进行定制开发);
-
混沌工程:在ChaosBlade基础上进行二次研发,提供各类故障演练功能。
3、统一的微服务平台:QDAS(QIYI Distributed Application Service,爱奇艺分布式应用服务),提供微服务应用生命周期管理、服务治理、服务市场等功能。
总的来说,标准架构为微服务的开发、部署和管理提供了一整套完整的解决方案,涵盖了开发框架、基础设施和服务平台等各个方面,旨在提高开发效率,降低运维成本,保证微服务架构的高可用性和高稳定性。
通过统一的微服务开发SDK,可以简化开发流程,提高开发效率;统一的微服务基础设施,可以降低运维成本,提高系统稳定性;统一的微服务平台,可以提供全方位的服务治理和应用管理功能,使得微服务的开发、部署和管理变得更加便捷和高效。
三、标准架构生态建设
接下来,我们将从微服务SDK、注册中心、监控体系、熔断限流、API网关、微服务平台等几个微服务标准架构的关键点进行详细的介绍。
开源SDK定制
根据各个业务团队的需求,爱奇艺中间件团队主要对Dubbo SDK做了以下几方面的扩展:
-
基础设施的适配:包括注册中心、监控系统、内部的容器平台等等;
-
可用性增强:包括非健康实例隔离、以及区域就近路由的机制;
-
安全性增强:支持了服务间调用的认证机制;
-
序列化:增添了对protobuf序列化方式的支持。
我们还优化了SDK的性能,提高了服务的并发处理能力,并且增强了日志记录功能,以便于快速定位问题。
同时,通过引入负载均衡策略,我们确保了服务的稳定性和可靠性。
这些扩展不仅提升了SDK的易用性和安全性,而且使得爱奇艺的微服务架构更加健壮和高效。
注册中心演进
在微服务架构中,注册中心是至关重要的基础设置之一。
在爱奇艺内部,注册中心的选型并不一致,之前在线上运行的注册中心包括ZooKeeper、eureka、consul等。
然而,ZooKeeper、eureka等并不是目前业界最佳的微服务注册中心选型。
以Zookeeper为例,其主要缺陷包括:
-
无法进行横向扩展;
-
作为一个一致性的系统,在网络分区会产生不可用。
在对业界的各种方案进行调研后,我们决定采用Nacos作为我们下一代的微服务注册中心。
如下图所示,选择Nacos的主要原因包括:
-
高性能,能够进行横向扩展;
-
适用于传统服务架构,同时也适用于云原生环境,包括支持与Istio控制面对接;
-
提供了Nacos-Sync组件,可以与其他注册中心进行数据同步,使得注册中心的迁移变得简单。
Nacos不仅具有高性能和可扩展性,还提供了丰富的功能,如动态配置管理、服务发现和健康监测等。这使得Nacos成为爱奇艺微服务架构的理想选择。
通过使用Nacos,我们能够更好地管理和服务实例,提高系统的可用性和稳定性。同时,Nacos的灵活性和可扩展性也为我们的微服务架构提供了更大的发展空间。
Nacos高可用部署
在部署Nacos服务时,我们充分考虑了服务部署架构方面的高可用性。
目前我们的Nacos服务是一个大集群,实例分布在多个不同的可用区中,在每个可用区内部,我们会申请不同的VIP,最终的内网域名是绑定在这些VIP上。
另外其底层所使用的MySQL也采用了多机房部署。这样的架构可以避免单个Nacos实例或者单机房故障造成整个Nacos服务的不可用。
以下是一些可能的故障场景的模拟:
-
单个Nacos实例故障:利用Load Balancer集群提供的健康检查能力自动从VIP中摘除;
-
某个VIP集群故障:利用客户端重试机制解决;
-
单个AZ故障:利用客户端重试机制解决;
-
MySQL集群故障:MySQL与注册发现过程无关,不受影响;
-
整个Nacos服务故障:客户端兜底机制,如服务实例缓存等。
通过引入负载均衡器和客户端重试机制,确保了在单个实例或集群出现故障时,Nacos服务仍然能够正常运行。
同时,对MySQL数据库实施了多机房部署,进一步提高了整个Nacos服务的可靠性。
这些措施有效地保障了Nacos服务的连续性和稳定性。
注册中心平滑迁移方案
接下来将简单介绍一下如何使用Nacos-Sync进行注册中心的平滑迁移。
-
首先要部署一个Nacos-Sync服务,从旧的注册中心向Nacos同步数据。Nacos-Sync支持集群化部署,部署多个实例时,其向新注册中心的写入时幂等的,并且它原生支持Dubbo的注册数据格式;
-
检查数据无误后,首先升级Consumer端,改为从Nacos注册中心进行发现。这时的服务发现的数据均是由Nacos-Sync从旧的注册中心同步过来的;
-
再升级Provider端,改为向Nacos进行服务注册;
-
下线Nacos-Sync服务及旧的注册中心,整个迁移流程就结束了。
以上方案的主要优点包括:
-
服务提供方和消费方的升级完全独立,可以自行进行;
-
迁移涉及的应用仅需升级一次。
通过使用Nacos-Sync进行注册中心迁移,能够确保整个迁移过程的平滑性和稳定性。Nacos-Sync不仅支持集群化部署,还具备数据同步的幂等性,从而降低了迁移过程中出现数据不一致的风险。此外,Nacos-Sync原生支持Dubbo的注册数据格式,使得迁移过程更加便捷。在迁移过程中,服务提供方和消费方的升级相互独立,可以根据实际情况灵活进行。而且,迁移涉及的应用只需升级一次,大大降低了迁移工作的复杂度和风险。
监控体系建设
服务监控是所有业务团队都极为关注的主题。完整的微服务监控体系一般需要有以下3个方面组成:
-
指标监控:涵盖QPS/响应延时/错误率等黄金指标、业务的自定义指标、JAVA应用的JVM指标,以及基础环境相关指标,如CPU、内存利用率等;
-
日志监控:例如错误日志数量;可以利用AI技术对日志模式进行统计分析等;
-
链路监控:由于微服务调用关系的复杂性,调用链追踪显得尤为重要,它可以帮助业务人员更好地分析应用间的依赖关系,并能够监控各个调用关系上的关键指标。
构建一个完善的监控体系对于保障微服务架构的稳定运行至关重要。通过指标监控,我们可以实时了解服务的运行状态,发现并解决问题。
日志监控则可以帮助我们分析错误产生的原因,为问题的定位和解决提供有力支持。而链路监控则有助于我们了解各个服务之间的调用关系,分析应用间的依赖,从而更好地监控整个微服务架构的运行状况。
在监控体系的建设过程中,我们不仅需要关注监控的全面性和准确性,还需要关注监控数据的实时性和可分析性。通过引入先进的监控技术和工具,如AI技术、大数据分析等,我们可以提高监控数据的实时性和可分析性,从而更好地支持微服务架构的运行和维护。
在未来的工作中,我们将继续优化和完善监控体系,以提高微服务架构的运行效率和稳定性。
指标监控
在指标监控方面,我们基于Prometheus构建了一套相对完善的监控和告警标准化方案。
在这个过程中,我们需要解决以下几个问题:
-
首先是指标计算问题。为了减少侵入性,我们在SkyWalking agent的基础上进行了二次开发,能够自动拦截Spring MVC、Dubbo等主流框架的调用,统计调用次数、处理耗时和是否出错等数据;
-
其次是指标采集的问题,Prometheus是采用拉模式采集指标的,对于微服务场景一般是利用Prometheus的服务发现机制。Prometheus默认集成了consul、k8s等服务发现方式,不过并未对Nacos注册中心直接提供支持,我们在开源的Nacos adapter的基础上进行了改造,使得Prometheus能够从Nacos中发现要采集的应用实例信息。
指标查看主要采用基于SkyWalking UI和Grafana开发的界面。我们提供了一套通用的配置模板,业务可以根据需要自行扩展。
在告警方面,我们将告警策略设置在Prometheus中,具体的告警由Alert-Manager通过Adapter发送给内部的监控告警平台。
监控dashboard查看、告警策略设置、订阅的入口统一设置在我们内部的全链路监控平台上,用户可以在该平台上查看进行相应的操作。
下图展示了服务监控界面:
通过基于Prometheus的指标监控方案,可以实时了解微服务的运行状态,为业务提供可靠的数据支持。
在解决指标计算和采集问题的过程中,对SkyWalking agent和Nacos adapter进行了二次开发,提高了监控数据的准确性和实时性。
同时,通过统一的监控平台,用户可以方便地查看监控数据、设置告警策略和订阅告警通知。
链路追踪
链路追踪的基本原理与Google关于Dapper的论文相同,应用程序通过植入agent产生调用链数据,通过日志收集或网络直接上报的方式统一汇总至Kafka,然后通过我们的实时分析程序进行分析。
分析结果大致可分为三类:原始的调用链数据我们会使用ES+HBase进行存储,调用关系上的实时监控数据我们采用时序数据库Druid进行存储,拓扑关系则采用图数据存储。
链路追踪主要提供了一下功能:
-
调用依赖关系分析:提供了服务间依赖和接口间依赖的多个层次粒度,支持MySQL、Redis等各类中间件,为开发人员提供各种上下游依赖的直观展示;
-
服务间调用关系指标:提供QPS/响应延时错误率等核心指标监控,且能在一个调用关系上同时提供客户端及服务端两个视角的监控值,便于进行问题定位;
-
程序异常分析:在调用链数据中心记录异常类型及堆栈信息并进行分析,支持展示某个应用的程序异常种类及每分钟发生次数等;
-
日志关联:将调用链与业务日志进行关联查询,便于获取程序运行的详细信息。
链路追踪在微服务架构中起着至关重要的作用,它可以帮助我们更好地了解服务之间的调用关系,分析依赖关系,从而为问题的定位和解决提供有力支持。通过实时分析程序对调用链数据进行处理和分析,我们可以实时掌握微服务的运行状况,发现并解决问题。
熔断限流方案
由于微服务架构的特点,上下游依赖和网络通信都比较多,这些因素都会对应用本身产生一定的风险,比如上游系统的突发流量或者热点参数;下游系统服务不可用、延时增大、错误率升高等等。
如果缺少对自身系统的保护,有可能产生雪崩的效应。
为了应对这些场景,我们主要引入了Sentinel框架进行解决。
Sentinel的核心原理是用户可以定义各类资源(资源可以是本地的一个接口,或者远程的某个依赖),并在资源上设置各种规则(比如限流规则),在访问某个资源时,Sentinel组件会检查这些规则是否满足,在不满足的情况下会抛出特定的异常。用户可以通过捕捉这些异常实现快速失败或者降级等业务逻辑。
Sentinel还提供了一个控制台,可以用来管理规则的参数设置以及查看实时监控等。
为了适应爱奇艺各个业务团队的需求,我们对sentinel框架做了一定的扩展,下面的例子即是我们实现的复杂参数限流功能。
Sentinel框架本身就自带热点参数限流的功能,不过仅支持一些简单类型的参数(如String、int等)。
在有些情况下,限流的场景可能比较复杂,比如下图中,可能要根据第一个参数的id属性进行限流,这种场景原生的sentinel并未提供支持。针对这种情况,我们提供了一个抽象接口,允许用户通过自己的实现从参数中提取出需要限流的资源。
为了实现规则参数的动态下发,我们将sentinel与内部的配置中心进行了适配。在sentinel dashboard上进行的参数改动,最后都会保存至配置中心,业务系统通过引入配置中心的SDK,即可实现在不重启应用的前提下进行参数的动态调整。
在QDAS管理平台上,我们还利用K8s技术提供了Sentinel Dashboard的托管功能,节省了各业务团队在这方面的部署和维护成本。
通过引入Sentinel框架,可以有效地应对微服务架构中可能出现的各种风险,保障系统的稳定运行。通过定义资源和设置规则,我们可以实现对访问量的控制,防止系统过载。同时,通过捕捉异常和降级处理,我们可以确保在系统压力过大时,仍能提供基本的服务。
API网关
爱奇艺 API 网关底层基于开源项目 Kong 实现,旨在为开发者提供稳定、便捷、高性能、可扩展的服务入口功能,一站式管理API 配置和生命周期,对微服务治理具有重要意义。
在 API 网关控制流架构设计中,微服务平台 API 网关模块通过内部系统集成及服务化实现,为开发者提供全部所需入口配置及管理功能,且无需代码侵入、工单申请等人工干涉,实现API 创建即可用。API 网关支持认证、限流、访问控制等通用功能。
结构如下图所示:
API 网关在微服务架构中发挥着重要作用,它为开发者提供了一个统一的接口管理平台,使得开发者能够更方便地管理和维护 API。通过集成开源项目 Kong,我们实现了 API 网关的高性能、可扩展性和稳定性,为爱奇艺微服务平台提供了强大的支持。
QDAS
在完善的微服务架构中,微服务治理平台至关重要。
QDAS 是一个以应用为核心的一站式平台,通过功能插件的方式,为微服务应用的开发、部署、运维等各个环节提供全生命周期的支持,同时兼容 Dubbo/Spring Cloud 传统微服务框架和 Istio 服务网格架构。
QDAS平台主要支持的功能包括:
1、应用基本信息维护;
2、传统微服务治理;
-
实例列表及与Nacos注册中心集成的实例上下线管理;
-
Grafana核心指标监控大盘;
-
Sentinel dashboard托管;
-
基于Sentinel的接口鉴权和流量配额管理(开发中)。
3、应用生命周期管理
支持在各类平台(容器/虚机)上的应用部署和版本升级功能;
4、服务市场
接口契约管理:包括基于Swagger UI的接口描述查看等。
QDAS 平台在微服务架构中发挥着重要作用,它为开发者提供了一个统一的应用管理和治理平台,使得开发者能够更方便地管理和维护微服务应用。
通过集成各类功能插件,实现了 QDAS 平台的高性能、可扩展性和稳定性,为爱奇艺微服务平台提供了强大的支持。
混沌工程
Netflix最早系统化地提出了混沌工程的概念,旨在尽早识别风险,针对性地加强薄弱环节。
我们同样重视故障演练,通过内部工具和外部开源项目,逐步发展出自己的故障注入平台——小鹿乱撞。
利用该平台,用户可以制定演练方案进行演练,以检验服务的健壮性。
目前,小鹿乱撞平台已支持对服务器、容器(Docker)、数据库、中间件、网络设备、K8s 集群等进行故障注入,并在演练过程中实时展示相关监控、日志和报警等信息,演练结束后自动生成演练报告。
另外,借助平台定时演练的能力,用户可以方便的实现周期性演练的效果。
混沌工程在提高系统稳定性和可靠性方面具有重要意义。
通过故障演练和注入,我们可以主动发现系统的潜在风险,并有针对性地进行优化和改进。
借助小鹿乱撞平台,我们实现了故障演练的自动化、智能化和周期化,为爱奇艺微服务平台的稳定运行提供了有力保障。
四、未来规划
对于微服务标准架构的未来规划,大概分为以下几方面的工作:
-
微服务技术趋势:云原生和服务网格(Service Mesh)已经成为微服务技术发展的趋势。我们将重点关注如何引入服务网格技术,并为各业务提供顺畅的过渡解决方案;
-
服务治理:我们将继续扩展 QDAS 平台的功能,力求打造一个既适用于服务网格又适用于传统微服务的统一控制面;
-
开发者支持:未来计划推出项目脚手架和在线调试等服务,以便开发人员能更便捷地进行项目开发和线上问题排查。
在微服务架构的未来发展中,我们将密切关注技术趋势,不断优化和改进现有的技术体系。通过引入服务网格技术,我们将进一步提升系统的稳定性和可扩展性,为业务的发展提供有力支持。
同时,我们还将持续完善 QDAS 平台,使其成为一个全面、高效的服务治理工具,以满足各种微服务场景的需求。
此外,通过推出项目脚手架和在线调试等服务,我们将为开发人员创造一个更加友好、便捷的开发环境,提高开发效率。
闲鱼如何实现上亿级数据的精准计数?
关系型数据库在执行计数任务时,其执行效率会随着数据量级的增长而降低;当尤其是在处理亿级数据时,计数的效率低下到让人难以接受。
在闲鱼团队的关系系统中,为了应对这一问题,我们采取了一种策略,以实现对亿级数据的高效计数,且确保计数操作的速度达到毫秒级。
挑战
闲鱼现有的业务场景中,用户收藏宝贝、关注他人的数据量,已经达到了亿级规模。
使用传统的关系型数据库如MySQL进行有条件计数查询时,效率低下,特别是在处理大规模数据时,无法满足线上业务的高效响应需求。
如上图,在亿级别数据量级的关系型数据库存储中按分表key执行count操作,即使是在响应时间这一单一指标上,也难以达到线上业务的高标准,更不用说频繁执行这些低性能查询对数据库性能的影响了。
业内针对海量数据的计数场景,通常采用的解决方案有计数器和定时离线计算两种。
两种方式各有优劣:
计数方案 | 优势 | 劣势 |
---|---|---|
计数器(redis等内存kv存储) | 实时的计数结果 | 一旦出错难以恢复 |
定时离线计算 | 数据出错可以重复执行计算任务进行恢复 | 数据时效性差 |
本文提出了一种基于离线批处理+在线增量统计的设计方案,将复杂耗时的数据库计数操作,替换为多次KV存储的读取和对接操作,在线上业务场景中实现了QPS峰值时响应保持在毫秒级别(10ms以内),成功率也始终接近100%的效果,从而有效地支持了业务的发展。
闲鱼团队为了克服关系型数据库在处理大规模数据计数时的性能瓶颈,采纳了一种创新的解决方案。
该方案通过结合离线批处理和在线实时统计的技术,显著提高了数据计数操作的效率,确保了在高流量环境下,计数操作能够迅速且准确完成,极大地提升了用户体验,并保证了业务流程的高效稳定运行。
方案
本文提出的计数方案,简而言之,就是定时的将数据全量同步到离线库中进行批处理,实时在线上对增量数据保持统计,最后将这两部分的结果结合起来,以获得精准计数值。
离线批处理
闲鱼目前存储关系数据的方式如下(省略与本文无关字段):
source | 源(如:用户id) |
---|---|
target | 目标(如:收藏的宝贝id) |
status | 关系状态(0-正常,1-删除) |
gmt_modified | 关系最新修改时间 |
gmt_create | 数据创建时间 |
如果要统计某个用户的关系数量,例如用户A收藏的宝贝数量,只需要获取到source为A且status为0的数据的总条数即可。
利用数据的离线处理能力,我们首先考虑的是按时间点对数据进行分割,即:某个时间点(如每天凌晨0点)之前的数据同步到离线存储进行计算,之后再与今天的增量数据进行合并,从而得到最终结果,如下图:
下文中将指定的切割时间点称为“预期快照读时间”。
在执行上图中的离线数据同步任务时,闲鱼团队采用的阿里云ODPS离线同步任务,采用扫表的方式完成数据离线。
在单表的场景下,借助于mysql的快照读机制可以准确的获取到某个时间点的快照,从而实现数据的离线、在线精准切割。
离线批处理的困境
在实际场景中,海量数据通常必须采用分库分表的方式进行存储。
而分库分表后,为了降低对线上业务的影响,离线数据同步任务往往是分批执行,这样就无法确保所有表的快照读操作时间完全一致,如下图:
多个分表的快照读时间不一致的情况下,快照读的实际执行时间必然会和预期快照读时间之间存在偏差,如下图:
假设在预期快照读时间和快照读实际执行时间的间隔内产生了一条数据变化(在上图中,此变化具体是在凌晨30分时,该关系的状态从0变为了1),那么实际执行的快照读将无法读取到预期快照读时间的快照,而是会读取到最新的数据状态。
换句话说,当预期快照时间≠数据实际读取时间时,在预期快照读时间到快照读实际执行时间之间产生的数据变化将会污染统计结果。
但是,由于数据量级过大,必须采用分库分表的存储、分批执行离线任务,这就使得按照固定时间点对数据进行分割的方法变得不可行。
解决方案
为了规避这个问题,我们不得不放弃固定时间点的数据切割方式,转而采用一种新的策略,将同步任务开始时间作为数据快照读时间,并依据此时间对数据进行切割。
使用这样的同步方式,获取到的离线统计结果将如下图所示:
对不同source值计算后得到计数结果的同时,还可以得到该关系的最新更改时间,且不同关系的最新修改时间各不相同。本文将离线统计得到的包含该关系在某个时刻的最新计数值(所有关系中最新一条的修改时间+修改后的合计值),记做offlineTotal。
与一般离线任务产出的结果不同的是,offlineTotal中额外包含了该关系的最新一条修改时间,下文中的在线增量数据统计方案,将基于此时间来完成数据的对接与合并。
在线实时增量统计
结合离线数据计数的结果,在线增量数据需要包含如下信息:
-
可以与离线计算结果匹配上的时间;
-
在该时间点之后,这个关系计数值的变化情况。
在闲鱼的实现中,我们采用KV存储的方式,来记录关系的变化情况。
具体记录的值为:每一个source每一天的总增量dailyIncrTotal,以及每一次发生关系更新的那一时刻的增量modifiedTimeIncr,如下表:
key | offlineTotal | dailylncrTotal | modifiedTimelncr |
---|---|---|---|
10002 | 2018-9-12 01:15:00|5 | 2018-9-12|2 | 2018-9-12 01:15:00|1 |
统计计数值时,首先使用离线计数值offlineTotal加上当天的总增量dailyIncrTotal得到一个合计值,如下图:
可以看到,由于离线任务统计的数据并不是严格按照时间点进行切割(通常离线任务会在每天凌晨0点至1点之间执行),离线计数值和当日增量之间可能存在数据重叠。
在这种情况下,再根据离线计数值中的最新修改时间,获取在该时刻的增量,从合计值中去掉这部分数据即可:
整理这段计算逻辑,可以得到如下公式:
currentTotal = offlineTotal + ∑dailyIncrTotal - modifiedTimelncr
注:ΣdailyIncrTotal表示从离线记录中得到的最新修改时间的日期一直到计数时的日期。
至此,一次完整的计数请求,被替换为2+N次KV存储的查询(N为离线计算结果距当前时间的日期差,通常不会超过一天)。
这种方案不仅具有与实时计数器相当的响应速度,而且在遇到异常情况时,可以利用离线批处理的能力,重复运行离线任务,以滚动的方式修正数据。
总结
本文介绍了一种在亿数据量级场景下实现快速精准计数的方案,采用离线批处理来减少线上压力、提高计算效率,同时使用KV存储实时记录增量数据快照,实现了计数结果毫秒级响应,且可依赖离线数据订正。
从不同的角度思考问题,有时候在面对看似耗时且难以优化的场景时,转换思考方式可能会带来意想不到的解决方案。
SaaS多租户,如何设计?
一、SaaS多租户简介
多租户技术是一种软件架构技术,它是在探讨与实现如何于多用户的环境下共用相同的系统或程序组件,并且仍可确保各用户间数据的隔离性。它是为共用的数据中心内如何以单一系统架构与服务提供多数客户端相同甚至可定制化的服务,并且仍可保障客户的数据隔离。简单来说是一个单独的实例可以为多个组织服务。
多租户是SaaS(Software-as-a-Service)下的一个概念,意思为软件即服务,即通过网络提供软件服务。SaaS平台供应商将应用软件统一部署在自己的服务器上,客户可以根据工作的实际需求,通过互联网向厂商租用所需的应用软件服务,按定购的服务多少和时间长短向厂商支付费用,并通过互联网获得SaaS平台供应商提供的服务。
SaaS服务尤其利于一些中小企业,以低成本实现自己的软件需求。
什么是SaaS多租户技术
-
多租户技术或称多重租赁技术,是一种软件架构技术,是实现如何在多用户环境下(此处的多用户一般是面向企业)共用相同的系统或程序组件,并且确保各用户间数据隔离性。
-
在一台服务器上运行单个应用实例,它为多个租户(客户)提供服务。从定义中我们可以理解:多租户是一种架构,目的是为了让多用户环境下使用同一套程序,且保证用户间数据隔离。多租户的重点就是同程序下实现多用户数据的隔离。
1.1、什么是 SaaS多租户
SaaS,是Software-as-a-Service的缩写名称,意思为软件即服务,即通过网络提供软件服务。
SaaS平台供应商将应用软件统一部署在自己的服务器上,客户可以根据工作实际需求,通过互联网向厂商定购所需的应用软件服务,按定购的服务多少和时间长短向厂商支付费用,并通过互联网获得Saas平台供应商提供的服务。
SaaS服务通常基于一套标准软件系统为成百上千的不同客户(又称为租户)提供服务。这要求SaaS服务能够支持不同租户之间数据和配置的隔离,从而保证每个租户数据的安全与隐私,以及用户对诸如界面、业务逻辑、数据结构等的个性化需求。由于SaaS同时支持多个租户,每个租户又有很多用户,这对支撑软件的基础设施平台的性能、稳定性和扩展性提出很大挑战。
多租户是SaaS领域的特有产物,探究何为多租户需回归到对SaaS的理解上。
SaaS服务是指部署在云上的,客户可以按需购买,并通过网络请求就能获取到的服务;也就是说,在这样的场景下,会有N个客户同时使用同一套SaaS服务。
那么对SaaS服务供应商来说,构建SaaS体系需要完成两部分工作:上层服务+底层多租户系统。
上层服务是供应商对外售卖的软件服务,其可以为客户创造价值、为公司带来营收;而底层多租户系统则是SaaS模式实现的具体方式,公司在对外售卖SaaS服务时,需要考虑如何实现客户之间的数据隔离、服务的权限控制、计费管理等;因此需要引入多租户概念来解决上述问题。
通过多租户系统,公司可以更好的管理客户和上层服务,客户也可以更好的使用软件服务。这也就是多租户系统存在的意义了。
1.2、SaaS多租户的优势
-
开发和运维成本低
-
按需付费,节约成本
-
即租即用,软件版本更新快
-
故障排查更及时
-
大数据和AI的能力支持更强大
1.3、多租户模型
如图所示,涉及主要模型有以下几类:
(1)租户:指一个企业客户或是个人客户,租户之间数据与行为隔离,上下级租户间通过授权实现数据共享。每个租户只能操作归属或授权给该租户的数据;
(2)组织:如果租户是一个企业客户,通常就会拥有自己的组织架构;
(3)用户:租户下的具体使用者,拥有用户名、密码、邮箱等账号信息的自然人;
(4)角色:用户操作权限的集合;
(5)员工:组织内的某位员工;
(6)解决方案:为了解决客户的某类型业务问题,SaaS供应商一般都将产品和服务组合在一起,为客户提供整体的打包方案;
(7)产品能力:能够帮助客户实现场景解决方案闭环的能力;
(8)资源域:用来运行1个或多个产品应用的一套云资源环境;
(9)云资源:SaaS产品一般都部署在各种云平台上,例如阿里云、腾讯云、华为云等。对这些云平台提供的计算、存储、网络、容器等资源,抽象为云资源。
二、SaaS多租户的数据隔离设计方案
多租户对于用户来说,最主要的一点就在于数据隔离。
绝对不能出现:一个用户登了A用户单位的号,但是看到了B用户单位的数据。因此,多租户的数据库设计方案和代码实现就相当有必要考虑了。
目前开发者们普遍接受的SaaS多租户设计方案,常见的大概就3种:即为每个租户提供独立的数据库、独立的表空间、按字段区分租户,每种方案都有其各自的适用情况。
-
一个租户独立一个数据库
一个租户独立使用一个数据库,那就意味着我们的SaaS系统需要连接多个数据库,这种实现方案其实就和分库分表架构设计是一样的,好处就是数据隔离级别高、安全性好,毕竟一个租户单用一个数据库,但是物理硬件成本,维护成本也变高了。
-
独立的表空间
这种方案的实现方式,就是所有租户共用一个数据库系统,但是每个租户在数据库系统中拥有一个独立的表空间。
-
按租户id字段隔离租户
这种方案是多租户方案中最简单的数据隔离方法,即在每张表中都添加一个用于区分租户的字段(如tenant_id或org_id啥的)来标识每条数据属于哪个租户,当进行查询的时候每条语句都要添加该字段作为过滤条件,其特点是所有租户的数据全都存放在同一个表中,数据的隔离性是最低的,完全是通过字段来区分的,很容易把数据搞串或者误操作。
2.1、三种数据隔离架构设计的对比如下
隔离方案 | 成本 | 支持租户数量 | 优点 | 缺点 |
---|---|---|---|---|
独立数据库系统 | 高 | 少 | 数据隔离级别高,安全性,可以针对单个租户开发个性化需求 | 数据库独立安装,物理成本和维护成本都比较高 |
独立的表空间 | 中 | 较多 | 提供了一定程度的逻辑数据隔离,一个数据库系统可支持多个租户 | 数据库管理比较困难,表繁多,同时数据修复稍复杂 |
按租户id字段区分 | 低 | 多 | 维护和购置成本最低,每个数据库能够支持的租户数量最多 | 隔离级别最低,安全性也最低 |
大部分公司都是采用第三种多租户设计方案:按租户id字段隔离租户 架构设计实现多租户数据隔离的。
因为这种方案服务器成本最低,但是提高了开发成本。
2.2、MyBatis-Plus多租户插件优雅实现数据隔离
该系统只有一个数据库,所有租户共用数据表。
在每一个数据表中增加一列租户ID,用以区分租户的数据。
增删查改时,一定要带上租户ID,否则就会操作到其他租户的数据。因此,这里的设计一定要重点考虑。
我们要保证的就是一定不要忘记带上租户ID。一个很好的方案就是通过AOP的方案,隐式的为我们的每一个SQL带上这个租户ID。
推荐使用MyBatisPlus来操作数据库的。它提供了插件的机制,我们可以通过拦截它提供的四大组件的某些对象,某些方法,来操作SQL,动态的为我们的SQL拼接上租户ID字段。
当然,MyBatis-Plus高版本提供了更加方便的拦截器,并且已经将多租户插件放入JAR包,我们只需稍加实现,并将该插件加入到MyBatis的拦截器链中,就可以不用再显式的拼接租户ID字段了,降低了出错的概率。
三、MyBatisPlus实现多租户功能
如果希望以最少的服务器为最多的租户提供服务,并且租户接受以牺牲隔离级别换取降低成本。可以采用方案三,即共享数据库,共享数据架构,因为这种方案服务器成本最低,但是提高了开发成本。
所以MybatisPlus就提供了一种多租户的解决方案,实现方式是基于多租户插件TenantLineInnerInterceptor进行实现的。
在 MyBatis Plus 中,采用“共享数据库,共享数据架构”方式实现多租户。
MybatisPlus提供了租户处理器( TenantId 行级 ),租户之间共享数据库,共享数据架构,通过表字段(租户ID)进行数据逻辑隔离。
该种实现方式,需要我们在要实现多租户的表中添加 tenant_id(租户ID)字段,每次在对数据库操作时都需要在 where 后面添加租户判断条件“tenant_id=用户的租户ID”。
然而,使用了 MyBatis Plus 后,我们就不需要每次都手动在 wehre 后面添加 tenant_id 条件。
注意事项:
多租户 != 权限过滤,不要乱用,租户之间是完全隔离的!!!
启用多租户后所有执行的method的sql都会进行处理.
自写的sql请按规范书写(sql涉及到多个表的每个表都要给别名,特别是 inner join 的要写标准的 inner join)
<!-- Mybatis-Plus 增强CRUD -->
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
<version>3.5.1</version>
</dependency>
<!-- Mybatis-Plus 扩展插件 -->
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-extension</artifactId>
<version>3.5.1</version>
</dependency>
TenantLineInnerInterceptor是MybatisPlus中提供的多租户插件,其使用方法大致分为下面4步:
3.1、表及实体类添加租户ID
应用添加维护一张tenant(租户表),记录租户的信息,每一个租户,有一个租户ID。
然后,在需要进行隔离的数据表上新增租户id,例如,现在有数据库表(user)如下:
租户ID一般用tenant_id
字段名 | 字段类型 | 描述 |
---|---|---|
id | Long | 主键 |
tenantId | Long | 租户编码 |
other | varchar(256) | 其他属性 |
将tenantId用来隔离租户与租户之间的数据,如果要查询当前服务商的用户,SQL大致如下:
SELECT * FROM table t WHERE t.tenantId = 1;
3.2、application文件中添加多租户配置和新增配置属性类
(1) 设置环境变量,配置拦截规则
-
tenant.enable
: 可以设置是否开启多租户, -
tenant.ignoreTables
:需要进行租户id过滤的表名集合。 -
tenant.filterTables
:对多租户的表设置白名单忽略多租户拦截等。例如sys_user表结构中,没有tenant_id多租户字段,那么多租户拦截器不拦截该表。
#多租户配置
tenant:
enable: true
column: tenant_id
filterTables:
ignoreTables:
- sys_app
- sys_config
- sys_dict_data
- sys_dict_type
- sys_logininfor
- sys_menu
- sys_notice
- sys_oper_log
- sys_role
- sys_role_menu
- sys_user
- sys_user_role
ignoreLoginNames:
(2) 多租户配置属性类
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
import java.util.List;
/**
* 多租户配置属性类
*
* @author hege
* @Date 2023-08-25
*
*/
@Data
@ConfigurationProperties(prefix = "tenant")
public class TenantProperties {
/**
* 是否开启多租户
*/
private Boolean enable = true;
/**
* 租户id字段名
*/
private String column = "tenant_id";
/**
* 需要进行租户id过滤的表名集合
*/
private List<String> filterTables;
/**
* 需要忽略的多租户的表,此配置优先filterTables,若此配置为空,则启用filterTables
*/
private List<String> ignoreTables;
/**
* 需要排除租户过滤的登录用户名
*/
private List<String> ignoreLoginNames;
}
3.3、编写多租户处理器实现TenantLineHandler接口
在 MyBatis Plus 中,提供了 TenantLineInnerInterceptor 插件和 TenantLineHandler 接口。
其中:
-
TenantLineInnerInterceptor 插件用来自动向每个 SQL 的 where 后面添加判断条件“tenant_id=用户的租户ID”。
-
而 TenantLineHandler 接口用来给 TenantLineInnerInterceptor 插件提供租户ID、租户字段名。
TenantLineHandler 接口定义如下:
public interface TenantHandler {
/**
* 获取租户 ID 值表达式,支持多个 ID 条件查询
* 支持自定义表达式,比如:tenant_id in (1,2) @since 2019-8-2
* @param where 参数 true 表示为 where 条件 false 表示为 insert 或者 select 条件
* @return 租户 ID 值表达式
*/
Expression getTenantId(boolean where);
/**
* 获取租户字段名
* @return 租户字段名
*/
String getTenantIdColumn();
/**
* 根据表名判断是否进行过滤
* @param tableName 表名
* @return 是否进行过滤, true:表示忽略,false:需要解析多租户字段
*/
boolean doTableFilter(String tableName);
}
实现TenantHandler接口并实现它的方法,下面是一个例子:
import com.baomidou.mybatisplus.extension.plugins.handler.TenantLineHandler;
import net.sf.jsqlparser.expression.Expression;
import net.sf.jsqlparser.expression.LongValue;
import net.sf.jsqlparser.expression.NullValue;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContext;
import org.springframework.security.core.context.SecurityContextHolder;
import java.util.List;
/**
* 多租户处理器实现TenantLineHandler接口
*
* @author hege
* @Date 2023-08-25
*/
public class MultiTenantHandler implements TenantLineHandler {
private final TenantProperties properties;
public MultiTenantHandler(TenantProperties properties) {
this.properties = properties;
}
/**
* 获取租户ID值表达式,只支持单个ID值 (实际应该从用户信息中获取)
*
* @return 租户ID值表达式
*/
@Override
public Expression getTenantId() {
//实际应该从用户信息中获取
if(SecurityUtils.getTenantLoginUser()!=null)
{
//SecurityUtils 从ThreadLocal里面的安全上下文 中获取 用户所归属的单位id(租户id)
Long tenantId = SecurityUtils.getLoginUser().getUser().getRootPartyId();
if(tenantId!=null)
{
return new LongValue(tenantId);
}
}
return new LongValue(0);
}
/**
* 获取租户字段名,默认字段名叫: tenant_id
*
* @return 租户字段名
*/
@Override
public String getTenantIdColumn() {
//通过配置获取
return properties.getColumn();
}
/**
* 根据表名判断是否忽略拼接多租户条件
*
* 默认都要进行解析并拼接多租户条件
*
* @param tableName 表名
* @return 是否忽略, true:表示忽略,false:需要解析并拼接多租户条件
*/
@Override
public boolean ignoreTable(String tableName) {
//忽略指定用户对租户的数据过滤
List<String> ignoreLoginNames=properties.getIgnoreLoginNames();
//SecurityUtils 从ThreadLocal里面的安全上下文 中获取 用户名称
String loginName=SecurityUtils.getTenantUsername();
if(null!=ignoreLoginNames && ignoreLoginNames.contains(loginName)){
return true;
}
//忽略指定表对租户数据的过滤
List<String> ignoreTables = properties.getIgnoreTables();
if (null != ignoreTables && ignoreTables.contains(tableName)) {
return true;
}
return false;
}
}
SecurityUtils 从ThreadLocal里面的安全上下文 中获取 用户名称, 用户所归属的单位id(租户id)
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContext;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import java.util.List;
import java.util.stream.Collectors;
/**
* 安全服务工具类
*
* @author hege
*/
public class SecurityUtils {
/**
* 获取多租户用户
**/
public static LoginUser getTenantLoginUser() {
try {
LoginUser loginUser = null;
// 获取安全上下文对象,就是那个保存在ThreadLocal里面的安全上下文对象,总是不为null(如果不存在,则创建一个authentication属性为null的empty安全上下文对象)
SecurityContext securityContext = SecurityContextHolder.getContext();
// 获取当前认证了的 principal(当事人) 或者 request token (令牌); 如果没有认证,会是 null,该例子是认证之后的情况
Authentication authentication = securityContext.getAuthentication();
if(authentication!=null)
{
if(authentication.getPrincipal()!=null)
{
if (authentication.getPrincipal() instanceof LoginUser) {
loginUser = (LoginUser) authentication.getPrincipal();
}
}
}
return loginUser;
} catch (Exception e) {
e.printStackTrace();
throw new ServiceException("获取用户信息异常", HttpStatus.UNAUTHORIZED);
}
}
}
3.4、MybatisPlus配置类启用多租户拦截插件
前面讲到,在 MyBatis Plus 中,提供了 TenantLineInnerInterceptor 插件和 TenantLineHandler 接口。
其中,TenantLineInnerInterceptor 插件用来自动向每个 SQL 的 where 后面添加判断条件“tenant_id=用户的租户ID”。
TenantLineInnerInterceptor 插件 调用 TenantLineHandler 接口用来给 提供租户ID、租户字段名。
使用 @Configuration 和 @Bean 注解配置 MyBatis Plus 的多租户插件,
iimport com.baomidou.mybatisplus.annotation.DbType;
import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor;
import com.baomidou.mybatisplus.extension.plugins.inner.BlockAttackInnerInterceptor;
import com.baomidou.mybatisplus.extension.plugins.inner.OptimisticLockerInnerInterceptor;
import com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor;
import com.baomidou.mybatisplus.extension.plugins.inner.TenantLineInnerInterceptor;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.transaction.annotation.EnableTransactionManagement;
/**
* Mybatis Plus 配置
*
* @author hege
*/
@EnableTransactionManagement(proxyTargetClass = true)
@Configuration
@EnableConfigurationProperties(TenantProperties.class)
public class MybatisPlusConfig {
/**
* 如果用了分页插件注意先 add TenantLineInnerInterceptor 再 add PaginationInnerInterceptor
*
* @param tenantProperties
* @return
*/
@Bean
public MybatisPlusInterceptor mybatisPlusInterceptor(TenantProperties tenantProperties) {
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
if (Boolean.TRUE.equals(tenantProperties.getEnable())) {
// 启用多租户插件拦截
interceptor.addInnerInterceptor(new TenantLineInnerInterceptor(new MultiTenantHandler(tenantProperties)));
}
// 分页插件
interceptor.addInnerInterceptor(paginationInnerInterceptor());
// 乐观锁插件
interceptor.addInnerInterceptor(optimisticLockerInnerInterceptor());
// 阻断插件
interceptor.addInnerInterceptor(blockAttackInnerInterceptor());
return interceptor;
}
}
配置好之后,不管是查询、新增、修改删除方法,MP都会自动加上租户ID的标识,测试如下:
@Test
public void select(){
List<User> users = userMapper.selectList(Wrappers.<User>lambdaQuery().eq(User::getAge, 18));
users.forEach(System.out::println);
}
运行sql实例:
DEBUG==> Preparing: SELECT id, login_name, name, password,
email, salt, sex, age, phone, user_type, status,
organization_id, create_time, update_time, version,
tenant_id FROM sys_user
WHERE sys_user.tenant_id = '001' AND is_delete = '0' AND age = ?
验证结果:
针对MybatisPlus提供的API、自定义Mapper中的statement均可正常拦截,会在SQL执行增删改查的时候自动加上tenant_id。
3.5、特定SQL语句忽略拦截
如果在程序中,有部分SQL不需要加上租户ID的表示,需要过滤特定的sql,或者对于一些超级管理员使用的接口,希望跨租户查询、免数据鉴权时,无需多租户拦截。
怎么办?
可以通过下面几种方式实现忽略拦截:
-
方法1:使用MybatisPlus框架自带的@InterceptorIgnore注解,以用在Mapper类上,也可以用在方法上
-
方法2:添加超级用户账号白名单,在自定义的Handler里进行逻辑判断,跳过拦截
-
方法3:添加数据表白名单,在自定义的Handler里进行逻辑判断,跳过拦截
使用MybatisPlus框架自带的@InterceptorIgnore注解,以用在Mapper类上,也可以用在方法上, 下面是一个例子:
/**
* 使用@InterceptorIgnore注解,忽略多租户拦截 <br/>
* 注解@InterceptorIgnore可以用在Mapper类上,也可以用在方法上
*
* @param id
* @return
*/
@InterceptorIgnore(tenantLine = "true")
UserOrgVO myFindByIdNoTenant(@Param(value = "id") Long id);
参考文献
https://mp.weixin.qq.com/s/TR75wnxsXgFZ2ot1dOvX2w
https://mp.weixin.qq.com/s/CVTuEINWHCLue1oB7Yr3ng
https://mp.weixin.qq.com/s/Nl5Oll9GcF6JB8JvIb2YqA
https://zhuanlan.zhihu.com/p/420696556
https://blog.csdn.net/CSDN2497242041/article/details/132525117