如何将十几秒的查询请求优化成毫秒级?
这次项目针对的系统是一个电商系统。每个电商系统都有个商品详情页。一开始这个页面很简单,只包括商品的图片、介绍、规格、评价等。
刚开始,这个页面打开很快,系统运行平稳可靠。
后来,页面中加了商品推荐,即在商品详情页后面显示一些推荐商品的列表。
再后来,页面中加入了最近成交情况,即显示一下某人在什么时候下单了。
接着,页面中又加入了优惠活动,即显示这个商品都可以参加哪些优惠活动。
…
当时这个系统里面有5万多条商品数据,数据量并不大,但是每次用户浏览商品详情页时都需要几十条SQL语句,经常出现十几秒才能打开详情页的情况。
项目组开始考虑要怎么优化,重构数据库基本不可能,最好不要改动表结构。大家想到的方案也很通用,就是把大部分商品的详情数据缓存起来,少部分的数据通过异步加载。比如,最近的成交数据通过异步加载,即用户打开商品详情页以后,再在后台加载最近的成交数据,并显示给用户。
本地缓存优缺点:
优点:
读取速度快
本地缓存不需要远程网络请求去操作内存空间,没有额外的性能消耗,所以读取速度快
不能进行大数据量存储
本地缓存占用了应用进程的内存空间,比如java进程的jvm内存空间,故不能进行大数据量存储
缺点:
应用程序集群部署时,会存在数据更新问题(数据更新不一致)
本地缓存一般只能被同一个应用进程的程序访问,不能被其他应用程序进程访问。在单体应用集群部署时,如果数据库有数据需要更新,就要同步更新不同服务器节点上的本地缓存的数据来保证数据的一致性,但是这种操作的复杂度高,容易出错。可以基于redis的发布/订阅机制来实现各个部署节点的数据同步更新。
数据会随着应用程序的重启而丢失
因为本地缓存的数据是存储在应用进程的内存空间的,所以当应用进程重启时,本地缓存的数据会丢失。
优点:
支持大数据量存储
分布式缓存是独立部署的进程,拥有自身独自的内存空间,不需要占用应用程序进程的内存空间,并且还支持横向扩展的集群方式部署,所以可以进行大数据量存储。
数据不会随着应用程序重启而丢失
分布式缓存和本地缓存不同,拥有自身独立的内存空间,不会受到应用程序进程重启的影响,在应用程序重启时,分布式缓存的存储数据仍然存在。
数据集中存储,保证数据的一致性
当应用程序采用集群方式部署时,集群的每个部署节点都有一个统一的分布式缓存进行数据的读写操作,所以不会存在像本地缓存中数据更新问题,保证了不同服务器节点的 数据一致性。
数据读写分离,高性能,高可用
分布式缓存一般支持数据副本机制,实现读写分离,可以解决高并发场景中的数据读写性能问题。而且在多个缓存节点冗余存储数据,提高了缓存数据的可用性,避免某个缓存节点宕机导致数据不可用问题。
缺点:
数据跨网络传输,读写性能不如本地缓存
分布式缓存是一个独立的服务进程,并且和应用程序进程不在同一台机器上,所以数据的读写要通过远程网络请求,这样相对于本地缓存的数据读写,性能要低一些。
先将目前比较流行的缓存中间件Memcached、MongoDB、Redis进行简单对比,见表4-1。
使用MongoDB的公司最少,因为它只是一个数据库,由于它的读写速度与其他数据库相比更快,人们才把它当作类似缓存的存储。
所以接下来就是比较Redis和Memcached,并从中做出选择。
目前,Redis比Memcached更流行,这里总结一下原因,共3点:
数据结构
举个例子,在使用Memcached保存List缓存对象的过程中,如果往List中增加一条数据,则首先需要读取整个List,再反序列化塞入数据,接着再序列化存储回Memcached。而对于Redis而言,这仅仅是一个Redis请求,它会直接帮助塞入数据并存储,简单快捷。
持久化
对于Memcached来说,一旦系统宕机数据就会丢失。因为Memcached的设计初衷就是一个纯内存缓存。
通过Memcached的官方文档得知,1.5.18版本以后的Memcached支持Restartable Cache(可重启缓存),其实现原理是重启时CLI先发信号给守护进程,然后守护进程将内存持久化至一个文件中,系统重启时再从那个文件中恢复数据。不过,这个设计仅在正常重启情况下使用,意外情况还是不处理。
而Redis是有持久化功能的。
集群
这点尤为重要。Memcached的集群设计非常简单,客户端根据Hash值直接判断存取的Memcached节点。而Redis的集群因在高可用、主从、冗余、Failover等方面都有所考虑,所以集群设计相对复杂些,属于较常规的分布式高可用架构。
使用缓存的逻辑如下。
这种逻辑唯一麻烦的地方是,当用户发来大量的并发请求时,它们会发现缓存中没有数据,那么所有请求会同时挤在第2)步,此时如果这些请求全部从数据库读取数据,就会让数据库崩溃。
数据库的崩溃可以分为3种情况:
单一数据过期或者不存在,这种情况称为缓存击穿。
解决方案:使用互斥锁,异步定时更新
第一个线程如果发现Key不存在,就先给Key加锁,再从数据库读取数据保存到缓存中,最后释放锁。如果其他线程正在读取同一个Key值,那么必须等到锁释放后才行。关于锁的问题前面已经讲过,此处不再赘述。
或者
比如某一个热点数据的过期时间是1小时,那么每59分钟,通过定时任务去更新这个热点key,并重新设置其过期时间。
数据大面积过期或者Redis宕机,这种情况称为缓存雪崩。
解决方案:设置缓存的过期时间为随机分布或设置永不过期即可
一个恶意请求获取的Key不在数据库中,这种情况称为缓存穿透。
比如正常的商品ID是从100000到1000000(10万到100万之间的数值),那么恶意请求就可能会故意请求2000000以上的数据。这种情况如果不做处理,恶意请求每次进来时,肯定会发现缓存中没有值,那么每次都会查询数据库,虽然最终也没在数据库中找到商品,但是无疑给数据库增加了负担。这里给出两种解决办法。
①在业务逻辑中直接校验,在数据库不被访问的前提下过滤掉不存在的Key。(也可以布隆过滤器)
②针对恶意请求的Key存放一个空值在缓存中,防止恶意请求骚扰数据库。
缓存预热
上面这些逻辑都是在确保查询数据的请求已经过来后如何适当地处理,如果缓存数据找不到,再去数据库查询,最终是要占用服务器额外资源的。那么最理想的就是在用户请求过来之前把数据都缓存到Redis中。这就是缓存预热。
其具体做法就是在深夜无人访问或访问量小的时候,将预热的数据保存到缓存中,这样流量大的时候,用户查询就无须再从数据库读取数据了,将大大减小数据读取压力。
主要讲的的缓存 与 数据库 双写一致性问题。
更新缓存的步骤特别简单,共两步:更新数据库和更新缓存。但这简单的两步中需要考虑很多问题
其中,第1个问题就存在5种组合方案,下面逐一进行介绍(以上3个问题因为紧密关联,无法单独考虑,下面就一起说明)。
该方案存在的缺陷有两点:
场景举例
如果在线程A更新缓存与数据库的整个过程中,先把缓存及数据库都锁上,确保别的线程不能更新,是否可行?当然是可行的。但是其他线程能不能读取?
假设线程A更新数据库失败回滚缓存时,线程C也加入进来,它需要先读取缓存中的值,这时又返回什么值?
看到这个场景,是不是有点儿熟悉?不错,这就是典型的事务隔离级别场景。所以就不推荐这个组合,因为此处只是需要使用一下缓存,而这个组合就要考虑事务隔离级别的一些逻辑,成本太大。接着考虑别的组合。
这种方案的缺陷在于:
场景举例
同时有一个请求A进行更新操作,另一个请求B进行查询操作。那么会出现如下情形:
(1)请求A进行写操作,删除缓存
(2)请求B查询发现缓存不存在
(3)请求B去数据库查询得到旧值
(4)请求B将旧值写入缓存
(5)请求A将新值写入数据库
上述情况就会导致不一致的情形出现。而且,如果不采用给缓存设置过期时间策略,该数据永远都是脏数据。
为了解决一致性问题,可以让线程A给Key加锁,因为写操作特别耗时,这种处理方法会导致大量的读请求卡在锁中。
以上描述的是典型的高可用和一致性难以两全的问题,如果再加上分区容错就是CAP(一致性Consistency、可用性Availability、分区容错性Partition Tolerance)了,这里不展开讨论
这种方案的缺陷在于:
场景举例
假设第一步(更新数据库)成功,第二步(更新缓存)失败了怎么办?
因为缓存不是主流程,数据库才是,所以不会因为更新缓存失败而回滚第一步对数据库的更新。此时一般采取的做法是重试机制,但重试机制如果存在延时还是会出现数据库与缓存不一致的情况,不好处理。
假设两个线程A,B
(1)线程A更新了数据库
(2)线程B更新了数据库
(3)线程B更新了缓存
(4)线程A更新了缓存
这就出现请求A更新缓存应该比请求B更新缓存早才对,但是因为网络等原因,B却比A更早更新了缓存。这就导致了脏数据,因此不考虑。
假设两个线程同时更新同一个数据,线程A先完成第一步,线程B先完成第二步怎么办?
线程A把值更新成a,线程B把值更新成b,此时数据库中的最新值是b,因为线程A先完成了第一步,所以第二步谁先完成已经不重要了,因为都是直接删除缓存数据。这个问题解决了。
那么,它能解决组合3的第一个问题吗?假设第一步成功,第二步失败了怎么办?这种情况的出现概率与组合3相比明显低不少,因为删除比更新容易多了。虽然这个组合方案不完美,但出现一致性问题的概率较低。
除了组合3会碰到的问题,组合4还会碰到别的问题吗?
是的。假设线程A要更新数据,先完成第一步更新数据库,在线程A删除缓存之前,线程B要访问缓存,那么取得的就是旧数据。这是一个小小的缺陷。那么,以上问题有办法解决吗?
即延时双删
伪代码:
public void write(String key,Object data){
redis.delKey(key);
db.updateData(data);
Thread.sleep(1000);
redis.delKey(key);
}
采用这种同步淘汰策略,吞吐量降低怎么办?
ok,那就将第二次删除作为异步的,自己起一个线程,异步删除或者下图的方式
这个方案其实和先更新数据库,再删除缓存差不多,因为还是会出现类似的问题:假设线程A要更新数据库,先删除了缓存,这一瞬间线程C要读缓存,先把数据迁移到缓存;然后线程A完成了更新数据库的操作,这一瞬间线程B也要访问缓存,此时它访问到的就是线程C放到缓存里面的旧数据。
不过组合5出现类似问题的概率更低,因为要刚好有3个线程配合才会出现问题(比先更新数据库,再删除缓存的方案多了一个需要配合的线程)。
但是相比于组合4,组合5规避了第二步删除缓存失败的问题——组合5是先删除缓存,再更新数据库,假设它的第三步“再删除缓存”失败了,也没关系,因为缓存已经删除了。其实没有一个组合是完美的,它们都有读到脏数据(这里指旧数据)的可能性,只不过概率不同。根据以上分析,组合5相对来说是比较好的选择。
不过这个组合也有一些问题要考虑,具体如下。
设计高可用方案时,需要考虑5个要点。
如果对缓存高可用有需求,可以使用Redis的Cluster模式,以上5个要点它都会涉及。关于Cluster的配置方法,可以参考Redis官方文档或其他相关教程。
在查看缓存使用情况时,一般会监控缓存命中率、内存利用率、慢日志、延迟、客户端连接数等数据。当然,随着问题的深入还可增加其他的指标,这里就不详细说明了。
当时公司采用的是一套自研的管理工具,这套管理工具里包含了监控功能。目前也有很多开源的监控工具,如RedisLive、Redis-monitor。至于最终使用哪种监控工具,则需要根据实际情况而定。
以上方案可以顺利解决读数据请求压垮数据库的问题,目前互联网架构也基本是采取这个方案。
接下来说说不足吧。这个方案主要针对读数据请求量大的情况,或者读数据响应时间很长的情况,而不能应对写数据请求量大的场景。也就是说写请求多时,数据库还是会支撑不住
上述讨论了缓存的架构方案,它可以减少数据库读操作的压力,却也存在着不足,比如写操作并发量大时,这个方案不会奏效。那该怎么办呢?
写缓存的思路是后台服务接收到用户请求时,如果请求校验没问题,数据并不会直接落库,而是先存储在缓存层中,缓存层中写请求达到一定数量时再进行批量落库。这里所说的缓存层实际上指的就是写缓存。它的意义在于利用写缓存比数据库高几个量级的吞吐能力来承受洪峰流量,再匀速迁移数据到数据库。
假设高峰期1秒内有1.5万个预约数据的插入请求。这1.5万个请求如果直接到数据库,那么数据库肯定崩溃。所以把这1.5万个请求落到并发写性能很高的缓存层,然后以2000为单位从缓存层批量落到数据库。数据库如果用批量插入语句,TPS也是可以非常高的,可能达到上万,这样不仅能防止数据库崩溃,还能确保用户的请求得到满足。
如何以最小代价解决短期高频写请求
某公司策划了一场超低价预约大型线上活动,在某天9:00~9:15期间,用户可以前往详情页半价预约抢购一款热门商品。根据市场部门的策划方案,这次活动的运营目标是几十万左右的预约量。
为避免活动上线后出现问题,比如数据库被压垮、后台服务器支撑不住(这个倒是小问题,加几台服务器即可)等,项目组必须提前做好预案。这场活动中,领导要求在架构上不要做太大调整,毕竟是一个临时的活动。简单地说就是工期不能太长,修改影响范围不要太大。
项目组分析了一下可能的情况,其他都没问题,唯一没把握的就是数据库。
项目组通过如下逻辑做了一次简单的测算:
假设目标是15分钟完成100万的预约数据插入,并且不是在15分钟内平均插入的。按照以往的经验,有可能在1分钟内就完成90%的预约,也有可能在5分钟内完成80%的预约,这些难以预计。但是峰值流量预估值只能取高,不能取低。所以设计的目标是:用户1分钟内就完成90%的预约量,即90万预约。那么推算出目标的TPS(吞吐量)就是90万/60=1.5万。
原来预约就是个简单的功能,并没有做高并发设计。对它做了一次压力测试,结果最大的TPS是2200左右,与需求值差距较大。
项目组想过分表分库这个方案,不过代码改动的代价太大了,性价比不高。毕竟这次仅仅是临时性市场活动,而且活动运营目标是几十万的预约量,这点数据量采取分表分库的话,未免有些得不偿失。
项目最终采用的方案是不让预约的请求直接插入数据库,而是先存放到性能很高的缓冲地带,以此保证洪峰期间先冲击缓冲地带,之后再从缓冲地带异步、匀速地迁移数据到数据库中。
该方案在具体实施过程中要考虑6个问题。
对于同步,写请求提交数据后,当前写操作的线程会等到批量落库完成后才开始启动。这种设计的优点是用户预约成功后,可在“我的预约”页面立即看到预约数据;缺点是用户提交预约后,还需要等待一段时间才能返回结果,且这个时间不定,有可能需要等待一个完整的时间窗
同步会抛出的一些问题:
- 用户到底需要等待多久?用户不可能无限期等待下去,此时还需要设置一个时间窗,比如每隔100毫秒批量落库一次。
- 如果批量落库超时了怎么办?写请求不可能无限期等待,此时就需要给写请求线程的堵塞设置一个超时时间。
- 如果批量落库失败了怎么办?是否需要重试?多久重试一次?
- 如果写请求一直堵塞,直到重试成功再返回吗?那需要重试几次?这些逻辑其实与Spring Cloud组件、Hystrix请求合并功能(Hystrix 2018年已经停止更新)等类似。
对于异步,写请求提交数据后,会直接提示用户提交成功。这种设计的优点是用户能快速知道提交结果;缺点是用户提交完成后,如果查看“我的预约”页面,可能会出现没有数据的情况。
如果使用了异步,则上述的第2点和第4点基本不用考虑
异步有两种设计可以选择:
其实,第一种方案在实际应用中也经常遇到,不过项目中主要还是使用第二种方案。因为在第二种方案中,大部分情况下用户是感受不到延迟的,用户体验比较好,而如果选择第一种方案,用户还要去思考:这个延迟是什么意思?是不是失败了?这无形中就影响了用户体验。
两种方案:
那到底哪种触发方式好呢?当时项目采用的方案是同时使用这两种方式。具体实现逻辑如下。
通过以上操作,既避免了触发方案一数量不足、无法落库的情况,也避免了方案二因为瞬时流量大而使待插入数据堆积太多的情况。
缓存数据不仅可以存放在本地内存中,也可以存放在分布式缓存中(比如Redis),其中最简单的方式是存放在本地内存中。
但是,Hystrix的请求合并也是存放在本地内存中,为什么不直接使用Hystrix?这是因为写缓存与Hystrix的请求合并有些不一样,请求合并更多考虑的是读请求的情况,不用担心数据丢失,而写请求需要考虑容灾问题:如果服务器宕机,内存数据就会丢失,用户的预约数据也就没有了。
其实也可以考虑使用MQ来当缓存层,MQ的一个主要用途就是削峰,很适合这种场景。不过这个项目选择了Redis,因为服务本身已经依赖Redis了。另外,项目想要使用批量落库的功能,项目组知道如何一次性从Redis中取多个数据项,但是还没有试过批量消费MQ的消息。
这次并不需要迁移海量数据,因为每隔一秒或数据量凑满10条,数据就会自动迁移一次,所以一次批量插入操作就能轻松解决这个问题,只需要在并发性的设计方案中保证一次仅有一个线程批量落库即可。这个逻辑比较简单,就不赘述了
目前,Redis共支持两种备份方式
另外,Redis还有一个主从功能,这里就不展开了。如果公司已经存在一个统一管理的Redis集群方案,直接复用即可,至少运维有保障。而如果需要从0开始搭建,最简单的解决方案如下。
不过这个方案有个缺点,即一旦系统宕机,手动恢复时大家就会手忙脚乱,但数据很有保障。
这个项目经过两周左右就上线了,上线之后的某次活动中,后台日志和数据库监控一切正常。活动一共收到几十万的预约量,达到了市场预期的效果。
接下来再说说不足。写缓存这个解决方案可以缓解写数据请求量太大、压垮数据库的问题,但其不足还是比较明显的。
上个方案虽然可以减少数据库写操作的压力,但是也存在一些缺陷,比如需要长期高频插入数据时这种场景。
因业务快速发展,某天某公司的日活用户高达500万,基于当时的业务模式,业务侧要求根据用户的行为做埋点,旨在记录用户在特定页面的所有行为,以便开展数据分析,以及与第三方进行费用结算
当然,在数据埋点的过程中,业务侧还要求在后台能实时查询用户行为数据及统计报表。这里的“实时”并不是严格意义上的实时,对于特定时间内的延迟业务方还是能接受的,为确保描述的准确性,可以称之为准实时。
原始数据结构
通过以上数据结构,在后台查询原始数据时,业务侧不仅可以将城市(根据经纬度换算)、性别(需要从业务表中抽取)、年龄(需要从业务表中抽取)、目标类型、目标ID、事件动作等作为查询条件来实时查看用户行为数据,还可以从时间(天/周/月/年)、性别、年龄等维度实时查看每个目标ID的总点击数、平均点击次数、每个页面的转化率等作为统计报表数据(当然,关于统计的需求还很多,这里只是列举了一小部分)。
为了实现费用结算这个需求,需要收集的数据结构见表6-2(再次强调,该数据结构只是示例,并非真实的业务场景数据)。
根据以上业务场景,项目组提炼出了6点业务需求,并针对业务需求梳理了技术选型相关思路
目前关于快速保存埋点数据的技术主要分为Redis、Kafka、本地日志这3种,针对这里的业务场景,项目组最终选择了本地日志。
那么,为什么不使用Redis或Kafka呢?先来说说Redis的AOF机制,这在写缓存那一章也有讲过。
Redis的AOF机制会持久化保存Redis所有的操作记录,用于服务器宕机后的数据还原。那Redis什么时候将AOF落盘呢?
在Redis中存在一个AOF配置项appendfsync,如果appendfsync配置为everysec,则AOF每秒落盘一次,不过这种配置方式有可能会丢失一秒的数据;如果appendfsync配置成always,每次操作请求的记录都是落盘后再返回成功信息给客户端,不过使用这种配置方式系统运行会很慢。因为对埋点记录的请求要求响应快,所们该项目没有选择Redis。
接下来讨论一下Kafka的技术方案。
Kafka的冗余设计是每个分区都有多个副本,其中一个副本是Leader,其他副本都是Follower,Leader主要负责处理所有的读写请求,并同步数据给其他Follower。
那么Kafka什么时候将数据从Leader同步给Follower?Kafka的Producer配置中也有acks配置项,其值有3种。
通过以上分析可以发现,使用Redis与Kafka都会出现问题。
如果想保证数据的可靠性,必然需要牺牲系统性能,那有没有一个方案可以性能和可靠性兼得呢?有。项目组最终决定把埋点数据保存到本地日志中。
关于这个问题,最简单的方式是通过Logstash直接把日志文件中的数据迁移到Elasticsearch,但会有一个问题:业务侧要求存放Elasticsearch中的记录(包含城市、性别、年龄等原始数据,这些字段需要调用业务系统的数据进行抽取),而这些原始数据日志文件中并没有,所以中间需要调用业务系统来获取一些数据跟日志文件的数据合起来加工。基于这个原因,项目组并没有选择直接从Logstash到Elasticsearch。
如果坚持通过Logstash把日志文件的数据迁移到Elasticsearch,这里分享3种实现方式。
- 自定义filter:先在Logstash自定义的Filter(过滤器)里封装业务数据,再保存到Elasticsearch。因为Logstash自定义的Filter是使用Ruby语言编写的,也就是说需要使用其他语言编写业务逻辑,所以此次项目中Logstash自定义Filter的方案被排除了。
- 修改客户端的埋点逻辑:每次记录埋点的数据发送到服务端之前,先在客户端将业务的相关字段提取出来再上传到服务端。这个方法也直接被业务端否决了,理由是后期业务侧每更新一次后台查询条件,就需要重新发一次版,实在太麻烦了。
- 修改埋点服务端的逻辑:每次服务端在记录埋点的数据发送到日志文件之前,先从数据库获取业务字段组合埋点记录。这个方法也被服务端否决了,因为这种操作会直接影响每个请求的效率,间接影响用户体验。
另外,没有选择用Logstash直接保存到持久化层还有两点原因。
在此处的业务场景中,项目组最终决定引入一个计算框架,此时整个解决方案的架构如图6-2所示。
实际上,引入实时计算框架是为了在原始的埋点数据中填充业务数据,并统计埋点数据生成费用结算数据,最后分别保存到持久层中。
最后,关于Logstash还需要强调几点。
Logstash系统是通过Ruby语言编写的,资源消耗大,所以官方又推出了一个轻量化的Filebeat。系统可以使用Filebeat收集数据,再通过Logstash进行数据过滤。如果不想使用Logstash的强大过滤功能,可以直接使用Filebeat来收集日志数据发送给Kafka。
但问题又来了,Filebeat是使用轮询方式采集文件变动信息的,存在一定延时(有时候很大),不像Logstash那样可直接监听文件变动,所以该项目最终选择继续使用Logstash(资源消耗在可接受范围内)。
Kafka是LinkedIn推出的开源消息中间件,它天生是为收集日志而设计的,而且具备超高的吞吐量和数据量扩展性,被称作无限堆积。
因此它特别适合处理日志收集这种场景。
有这么一个疑问:
logstash 可以直接把本地日志推送到es里面,中间为什么还要加个 kafka 中间件?
Logstash 是一个数据处理管道,可用于收集、解析和转换来自不同来源的数据,然后再将其发送到 Elasticsearch 进行索引和分析。虽然 Logstash 可以直接将本地日志推送到 Elasticsearch,但使用 Kafka 这样的消息队列作为 Logstash 和 Elasticsearch 之间的缓冲区可以提供几个好处:
- 使用 Kafka 的主要好处之一是它可以充当 Logstash 和 Elasticsearch 之间的缓冲区,这有助于解耦两个系统并提供更好的容错能力。如果 Elasticsearch 出现故障或遇到问题,Kafka 可以继续缓冲传入的日志,直到 Elasticsearch 重新上线。这有助于防止数据丢失,并确保最终对所有日志进行索引和分析
- 使用 Kafka 的另一个好处是,它可以帮助在多个 Elasticsearch 节点之间分配索引日志的负载。通过使用 Kafka 作为缓冲区,Logstash 可以将日志发送到多个 Kafka 分区,然后这些分区可以被多个 Elasticsearch 节点并行使用。这有助于提高索引吞吐量并降低单个 Elasticsearch 节点过载的风险
- 使用 Kafka 可以为日志收集系统提供更好的可扩展性和灵活性。Kafka 可以充当从不同来源收集日志的中心枢纽,然后多个 Logstash 实例可以使用这些日志进行处理和索引。这有助于在多个节点之间分配日志处理的负载,并为系统提供更好的可伸缩性和容错能力
总的来说,虽然 Logstash 可以直接将本地日志推送到 Elasticsearch,但使用 Kafka 作为 Logstash 和 Elasticsearch 之间的缓冲区可以在容错、可扩展性和灵活性方面提供一些好处
为了把Kafka的数据迁移到持久层,需要使用一个分布式实时计算框架,原因有两点。
目前流行的分布式实时计算框架有3种:Storm、Spark Stream、Apache Flink。那么使用哪个更好呢?
这3种都可以选用,就看公司的具体情况了。比如公司已经使用了实时计算框架,就不再需要考虑这个问题;如果公司还没有使用,那就看个人喜好了。
笔者更喜欢用Apache Flink,不仅因为它性能强(阿里采用这项技术后,活动期间一秒内能够处理17亿条数据),还因为它的容错机制能保证每条数据仅仅处理一次,而且它有时间窗口处理功能。
关于流处理的容错机制、时间窗口这两个概念,具体展开说明一下。
在流处理这个过程中,往往会引发一系列的问题,比如一条消息的处理过程中,如果系统出现故障该怎么办?会重试吗?如果重试会不会出现重复处理?如果不重试,消息是否会丢失?能保证每条消息最多或最少处理几次?
在不同流处理框架中采取不同的容错机制,能够保证不一样的一致性。
1)At-Most-Once:至多一次,表示一条消息不管后续处理成功与否只会被消费处理一次,存在数据丢失的可能。
2)Exactly-Once:精确一次,表示一条消息从其消费到后续的处理成功只会发生一次。
3)At-Least-Once:至少一次,表示一条消息从消费到后续的处理成功可能会发生多次,存在重复消费的可能。
以上3种方式中,Exactly-Once无疑是最优的选择,因为在正常的业务场景中,一般只要求消息处理一次,而Apache Flink的容错机制就可以保证所有消息只处理一次(Exactly-Once)的一致性,还能保证系统的安全性,所以很多人最终都会使用它。
接下来说说Apache Flink的时间窗口计算功能
假定实时计算框架收到消息的时间是2秒后,有一条消息中的事件发生时间是6:30,因接收到消息后处理的时间延后了2秒,即变成了6:32,所以当计算6:01~6:30的数据和时,这条消息并不会计算在内,这就不符合实际的业务需求了。
在实际业务场景中,如果需要按照时间窗口统计数据,往往是根据消息的事件时间来计算。Apache Flink的特性恰恰是使用了基于消息的事件时间,而不是基于计算框架的处理时间,这也是它的另一个撒手锏。
对于秒杀架构设计而言,其难点在于僧多粥少,因此设计秒杀架构时,一般需要遵循商品不能超卖、下单成功的订单数据不能丢失、服务器和数据库不能崩溃、尽量别让机器人抢走商品这4个原则。
某一次公司策划了一场秒杀活动,该活动提供了100件特价商品(商品价格非常低),供用户于当年10月10日22点10分0秒开始秒杀。
当时平台已经积累了几千万的用户量,预计数十万的用户对这些特价商品感兴趣。根据经验,特价商品一般会在1~2秒内被一抢而光,剩余时间涌进来的流量用户只能看到秒杀结束界面,因此预测秒杀开启那一瞬间会出现一个流量峰值。
这也是一场临时性的活动,领导要求别加太多服务器,也别花太多时间重构架构,也就是说需要以最小的技术代价来应对这次秒杀活动。
其实秒杀架构的设计方案就是一个不断过滤请求的过程。从系统架构层面来说,秒杀系统的分层设计
思路如图7-1所示。
在图7-1中发现,秒杀系统的架构设计目标是尽量在上层处理用户请求,不让其往下层游动。那具体如何实现呢?
由于整个秒杀系统涉及多个用户操作步骤,所以解决如何将请求拦截在系统上游这个问题时,需要结合实际业务流程,将用户的每个操作步骤考虑在内。
这里通过一张图来描述秒杀系统的具体业务流程,如图7-2所示
接下来就按照秒杀系统的业务流程,来一步步讲解如何将请求拦截在系统上游
在以往的秒杀系统架构经历中,曾出现过这么一种状况:当时把系统的方方面面都考虑到了,但是活动一上线,第三方监控系统就显示异常,检查后发现所有服务器的性能指标都没问题,唯独出口带宽有问题,它被占满了。
所以在之后的项目中,静态资源尽量使用CDN(内容分发网络),如果涉及PC网站,还必须首先进行前后端分离
那如果是动态的请求该怎么办?有以下3种实现方式。
总体来说,对于浏览页面的用户行为,需要把用户请求尽量拦截在CDN、静态资源或负载均衡侧,如果确实做不到,也要拦截在缓存中。
用户进入下单页面时,主要有两个操作动作:进入下单页面、提交订单。下面讲解如何在这两个环节中将请求拦截在系统上游。
进入下单页面
为了防止别人通过爬虫抓取下单页面信息,从而给服务器增加压力,需要在下单页面做以下两层防护,从而防止恶意请求重复提交。
提交订单
秒杀系统架构方案的核心是订单提交,因为这个步骤的逻辑最复杂,而其他步骤仅涉及页面展示的逻辑,针对高并发问题使用缓存或者CDN进行处理难度不大。
因此,在订单提交环节,要想尽一切办法在系统各个分层中把一些不必要的请求过滤掉。
网关层面过滤请求
对系统而言,如果可以在网关层面拦截用户请求,那么这个方案的性价比就很高。要是能在这一层过滤95%以上的请求,整个系统也将很稳定。
那在网关层面如何实现请求过滤呢?可以做3种限制。
后台服务器过滤请求
请求进入后台服务器后,目标已经不是如何过滤请求了,而是如何保证特价商品不超卖,以及如何保证特价商品订单数据的准确性。
具体如何实现呢?主要考虑以下4点。
商品库存放入缓存Redis中:如果每个请求都前往数据库查询商品库存,数据库将无法承受,因此需要把商品库存放在缓存中,这样每次用户下单前,就先使用decr操作扣减库存,判断返回值。如果Redis的库存扣减后小于0,说明秒杀失败,将库存用incr操作恢复;如果Redis的库存扣减后不小于0,说明秒杀成功,开始创建订单。
把库存放入Redis时,下单的逻辑都是基于缓存的库存为第一现场。但是如果这时候有别的服务或者代码修改了数据库里面的库存,怎么办?这时的做法就是确保在秒杀期间不做上架或修改库存之类的业务操作,即不通过技术,而是通过业务流程来保证。
订单写入缓存中:在第5章介绍写缓存时提过一个方案,即订单数据先不放入数据库,而是放到缓存中,然后每隔一段时间(比如100毫秒)批量插入一批订单。用户下单后,首先进入一个等待页面,然后这个页面向后台定时轮询订单数据。轮询过程中,后台先在Redis中查询订单数据,查不到就说明数据已经落库,再去数据库查询订单数据,查到后直接返回给用户,用户收到消息通知后可以直接进入付款页面支付;在数据库查询订单数据时,查不到说明秒杀失败(理论上不会查不到,如果一直查不到就需要抛出异常并跟踪处理)。
订单批量落库:需要定期将订单批量落库,且在订单落库时扣减数据库中的库存。这个做法和第6章中的写缓存一样,这里不再重复。
Redis停止工作(挂掉)怎么办:虽然讲了这么多关于后台服务器的逻辑,在秒杀架构里面,最重要的反而是网关层的限流,它挡住了大部分的流量,进入后台服务器的流量并不多。不过仍然要考虑针对Redis停止工作的情况,分别处理前面的3种状况。
比如读Redis中的库存时,如果失败了,那就让它直接去数据库扣减库存,把那些incr和decr的逻辑放到数据库去;若是把订单写入缓存的时候失败了,那就直接将订单数据写入数据库中,然后就不需要处理后面批量落库的逻辑了。
以上就是订单提交操作的架构设计,不难看出它主要是在网关层和后台服务器进行相关设计。
在付款页面不需要再过滤用户请求了。在这个环节,除了保障数据的一致性外,还有一个要点:如果业务逻辑中出现了一个订单未及时付款而被取消的情况,记得把数据库及Redis的库存加回去。关于数据的一致性,后面的章节会专门展开,这里就不单独讨论了。
再来回顾一下秒杀系统的分层思路,这也是秒杀系统的整体服务器架构方案
为了保障秒杀系统的高可用性,在整体服务器架构中,需要保证图7-1中所有的层级都是高可用的。因此,静态资源服务器、网关、后台服务器均需要配置负载均衡,而缓存Redis和数据库均需要配置集群模式。
整体服务器架构中还有一个重要组成部分——MQ,因为这次的秒杀架构方案中不涉及它的设计逻辑,所以并未在上面的分层中提及它。不过,服务间触发通知时,就需要使用它了,因此也需要保证它是高可用的(这里要把主从、分片、Failover机制都考虑进去)。
表7-1中整理了一份秒杀系统设计Checklist,供大家参考。
这个场景中还有以下3个要点需要注意。
其实笔者之前也做过秒杀架构,但是那时候的逻辑是,必须保证前面100名的客户可以抢到商品,并没有做限流等措施,因此后台服务器的压力很大,经常出问题。
虽然要保证前100名客户的订单成功,但是前面100名不一定就是第一时间点击秒杀按钮的客户,有些人网速快,有些人网速慢。另外,普通客户肯定没有专门“薅羊毛”的人操作快。对于普通客户来说,随机决定比单纯比快要更有机会下单成功。
后来,基本上秒杀架构都会设计限流。有一些秒杀的代码是在前端的JS中随机抛弃掉一些请求,这其实也是某种意义上的限流,只不过不太合理,应尽量避免。