第一模块中,我们已经把数据持久化层相关的架构方案讲完了,05 讲我们正式进入第二个模块——缓存层场景实战的讲解。这一讲,我们主要围绕数据库读取操作频繁的问题深入探讨。
在我曾经负责的一个电商系统中,存放了 50000 多条商品数据,每次用户浏览商品详情页时,需要先从数据库中读取数据,再进行数据拼装和计算,耗费的时间有时长达 1 秒。
这就导致用户每次点击商品详情页时,页面打开速度慢,此时该如何减少数据库读操作压力呢?
之前因为项目时间紧张,为了赶进度,我们并没有从系统性能角度进行深入考量。而现在整个系统流量起来了,我们就不得不考虑这个问题了。
此时我们采取的方案也很通用,把所有的商品数据缓存起来就行。
关于缓存问题,最简单的实现方法是使用本地缓存。在 Google Guava 中有一个 cache 内存缓存模块,它把所有商品的 ID 与商品详情信息一对一缓存至 JVM 内存中,用户获取商品详情数据时,系统会根据商品 ID 直接从缓存中读取数据,大大提升了用户页面访问速度。
不过,通过简单换算后,我们发现这个方法明显不合理,先来举个例子。
1 条商品数据中,往往包含品牌、分类、参数、规格、服务、描述等字段,光存储这些商品数据就得占用 500K 左右内存,再将这些数据缓存到本地的话,差不多还需占用 500 K*50000=25G 内存。此时,假设商品服务有 30 个服务器节点,光缓存商品数据就需要额外准备 750G 内存空间,这种方法显然不可取。
为此,我们想到了另外一个解决办法——分布式缓存,先将所有的缓存数据集中存储在同一个地方,而并非保存到各个服务器节点中,然后所有的服务器节点从这个地方读取数据。
那么这个统一存储缓存的地方需要使用什么技术呢?这就涉及接下来我们要讲的缓存中间件的技术选型问题。
缓存中间件技术选型
我们先将市面上比较流行的缓存中间件(Memcached、MongoDB、Redis)进行简单对比,这样你就不必再深入进行选型调研了。
据我了解,以上三种技术中,目前市面上通用的缓存中间件技术是 Redis,使用 MongoDB 的公司最少,因为它只是一个数据库,由于它的读写速度与其他数据库相比较快,所以人们才把它当作类似缓存的存储。
在这里,我们总结了下 Redis 之所以比 Memcached 流行的三种原因。
(1)数据结构
举个例子,在使用 Memcached 保存 List 缓存对象的过程中,如果我们往 List 增加一条数据,首先需要读取整个 List ,再反序列化塞入数据,接着再序列化存储回 Memcached。而对于 Redis 而言,它仅仅是一个 Redis 请求,会直接帮我们塞入数据并存储,简单快捷。
(2)持久化
对于 Memcached 来说,一旦系统宕机数据就会丢失。通过 Memcached 的官方文档得知,1.5.18 以后 Memcached 支持 restartable cache,其实现原理是重启时 CLI 先发信号给守护进程,然后守护进程将内存持久化至一个文件中,系统重启时再从那个文件恢复数据。不过,这个设计仅在正常重启情况下使用,意外情况还是不处理。
(3)集群(这点尤为重要)
Memcached 的集群设计非常简单,客户端根据 Hash 值直接判断存取的 Memcached 节点。而 Redis 的集群因在高可用、主从、冗余、failover 等方面都有所考虑,所以集群设计相对复杂些,属于较常规的分布式高可用架构。
因此,经过一番“慎重”的思考,我们最终决定使用 Redis 作为缓存的中间件。
技术选型完,我们开始考虑缓存的一些具体问题,先从缓存何时存储数据入手。
使用缓存的逻辑是这样的:
先尝试从缓存中读取数据;
缓存中没有数据或者数据过期,再从数据库中读取数据保存到缓存中;
最终把缓存数据返回给调用方。
这种逻辑唯一麻烦的地方:当用户发来大量的并发请求,且所有请求同时挤在上面第 2 步,此时如果这些请求全部从数据库读取数据,会直接挤爆数据库。
上面所说的挤爆可以分为三种情况,我们单独展开说明一下。
1. 单一数据过期或者不存在,这种情况称为缓存击穿。
此时解决方案:第一个线程如果发现 key 不存在,先给 key 加锁,再从数据库读取数据保存到缓存中,最后释放锁。如果其他线程正在读取同一个 key 值,它必须等到锁释放后才行。(关于锁的问题 01 讲中我们已经讲过,就不重复展开了。)
2. 数据大面积过期或者 Redis 宕机,这种情况称为缓存雪崩。
此时,我们设置缓存的过期时间随机分布或永不过期即可。
3. 一个恶意请求获取的 key 不在数据库中,这种情况称为缓存穿透。
这种情况如果不作处理,恶意请求每次都会查询数据库,无疑给数据库增加了压力。
这里我分享 2 种解决办法:① 在业务逻辑上直接校验,在数据库不被访问的前提下过滤掉不存在的 key;② 将恶意请求的 key 存放一个空值在缓存中,防止恶意请求骚扰数据库。
最后,关于缓存预热我想说明下:在深夜无人或访问量小的时候,我们可以考虑将预热的热数据保存到缓存中,这样流量大的时候,用户查询无须再从数据库读取数据,大大减少了数据读压力。
关于缓存何时存数据的问题我们就讨论完了,接下来开始讨论更新缓存的问题,这部分内容因涉及双写(缓存+数据库),所以学起来估计会有点难度,不过我相信你们可以的。
更新缓存的步骤特别简单,总共就两步:更新数据库和更新缓存。但就这么简单的两步,我们需要考虑好几个问题。
先更新数据库还是先更新缓存?更新缓存时先删除还是直接更新?
假设第一步成功了,第二步失败了怎么办?
假设 2 个线程同时更新同一个数据,A 线程先完成第一步,B 线程先完成第二步,此时该怎么办?
其中,第一个问题就存在 4 种组合问题,我们先针对第 1 种组合问题给出对应的解决方案。(以上几个问题因为紧密关联,没法单独考虑,下面我们就一起说明。)
对于这个组合,会遇到这种情况:假设第 2 步数据库更新失败了,要求回滚缓存的更新,这时该怎么办呢?我们知道 Redis 不支持事务回滚,除非我们采用手工回滚的方式,先保存原有数据,然后再将缓存更新回原来的数据,这种解决方案就有点尴尬了。
这里我简单举个例子,比如:
原来缓存中的值是 a,两个线程同时更新库存;
线程 A 将缓存中的值更新成 b,且保存了原来的值 a,然后更新数据库;
线程 B 将缓存中的值更新成 c,且保存了原来的值 b,然后更新数据库;
线程 A 更新数据库时失败了,它必须回滚了,那现在缓存中的值更新回什么呢?
要不这样吧,我们在 A 线程更新缓存与数据库的整个过程中,先把缓存及数据库都锁上,确保别人不能更新,这种方法可不可行呢?当然是可行的,但是别人能不能读呢?
假设 A 更新数据库失败回滚缓存时,线程 C 也来参一腿,它需要先读取缓存中的值,这时又返回什么值呢?
看到这个场景,你是不是有点儿印象?不错,这就是典型的事务隔离级别场景。我们只是使用一下缓存而已,你让我自己实现事务隔离级别,这个要求会不会有点高?我们还是考虑别的吧。
使用这种方案,就算我们更新数据库失败了也不需要回滚缓存。这种做法虽然巧妙规避了失败回滚的问题,却引来了 2 个更大的问题。
假设 A 线程先删除缓存,再更新数据库。在 A 线程完成更新数据库之前,后执行的 B 线程反而超前完成了操作,读取 key 发现没数据后,将数据库中的旧值存放到了缓存中。A 线程在 B 线程都完成后再更新数据库,这样就会出现缓存(旧值)与数据库的值(新值)不一致的问题。
为了解决一致性问题,我们可以让 A 线程给 key 加锁,因为写操作特别耗时,这种处理方法会导致大量的读请求卡在锁中。
以上描述的是典型的高可用和一致性难以两全的问题,要再加上分区容错就是 CAP 了,这里我们就不展开讨论了,继续讨论另外两种组合吧。
对于组合三,我们同样需要考虑 2 个问题。
假设第一步成功,第二步失败了怎么办?因为缓存不是主流程,数据库才是,所以我们不会因为更新缓存失败而回滚第一步对数据库的更新。此时,我们一般采取的做法是做重试机制,但重试机制如果存在延时还是会出现数据库与缓存不一致的情况,非常不好处理啊。
假设 2 个线程同时更新同一个数据,A 线程先完成了第一步,B 线程先完成了第二步怎么办?
假设 2 个线程同时更新同一个数据,A 线程先完成了第一步,B 线程先完成了第二步怎么办?这个你先好好想一下。我们接着推演整个过程:A 线程把值更新成 a,B 线程把值更新成 b,此时数据库中的最新值是 b,因为 A 线程先完成了第一步,后完成第二步,所以缓存中的最新值是 a,数据库与缓存的值还是不一致,还是不好处理啊。
因此,我不建议采用以上这个方案。
针对组合四,我们看看到底会存在哪些问题。
假设第一步成功了,第二步失败了怎么办?这种情况的出现概率与上个组合相比明显少不少,因为删除比更新容易多了。此时虽然它不完美,但出现一致性的问题概率少。
假设 2 个线程同时更新同一个数据,A 线程先完成第一步,B 线程先完成第二步怎么办?这块你也先好好想一下。我们接着推演整个过程:A 线程把值更新成 a,B 线程把值更新成 b,此时数据库中的最新值是 b,因为 A 线程先完成第一步,至于第二步谁先完成已经无所谓了,反正是直接删除缓存数据。
看到这,我们发现组合四完美地解决了以上难题,所以我建议更新缓存时,先更新数据库再删除缓存。
不过,这个解决方案也会引发另外 3个问题。
删除缓存数据后变相出现缓存击穿,此时该怎么办?此问题在前面我们已经给出了方案。
删除缓存失败如何重试?你可以参考之前的查询分离使用重试的方案解决。
删除缓存失败,重试成功前出现脏数据。这个需要与业务商量,毕竟这种情况还是少见,我们可以根据实际业务情况判断是否需要解决这个瑕疵。毕竟任何一个方案都不是完美的,但如果剩下 1% 的问题需要我们花好几倍的代价去解决,从技术上来讲得不偿失,这就要求架构师去说服业务方。毕竟作为一名好的架构师,需要具备极强的说服力。(佩服作者的脸皮。)
前面我们花了太长的篇幅讨论更新缓存的逻辑,接下来我们好好讨论缓存的高可用设计。
关于缓存高可用设计的问题,其实我们可以单独开一个课时来讲,但是考虑到 Redis 的用法介绍偏理论,而我们的课程偏实战,所以本专栏我们不详细展开了。
因我属于实用派的,我还是想跟大家分享一些个人的方法论。设计高可用方案时,我们需要考虑 5 个要点。
负载均衡: 是否可以通过加节点的方式水平分担读请求压力。
分片: 是否可以通过划分到不同的节点的方式水平分担写压力。
数据冗余: 一个节点的数据如果挂掉了,其他节点的数据是否可以直接备份挂掉节点的职责。
Fail-over: 任何节点挂掉后,集群的职责是否可以重新分配,以此保障集群正常工作。
一致性保证: 在数据冗余、failover、分片机制的数据转移过程中,如果某个地方出幺蛾子,能否保证所有的节点数据或节点与数据库之间数据的一致性。(依靠 Redis 本身是不行的。)
如果对缓存高可用有需求,我们可以使用 Redis 的 Cluster 模式,关于我前面提的那些点它都涉及。至于 Cluster 怎么配置,我们就不详细展开了,Redis 官方文档或网上都有很多教程,你可以自己查找相关资料去学习。
缓存上线以后,我们还需要定时查看缓存使用情况,再判断业务逻辑是否需要优化,也就是所谓的缓存的监控。
在查看缓存使用情况时,一般我们会监控缓存命中率、内存利用率、慢日志、延迟、客户端连接数等数据。当然,随着问题的深入我们还增加了其他的指标,这里就不详细说明了。
至于最终使用哪种监控工具,需要根据你们的实际情况而定。当初我们公司采用的是一套自研的管理工具,这套管理工具里包含了监控功能。现如今,市面上也有很多开源的监控工具,比如 RedisLive、Redis-monitor,我就不单独展开了。
以上方案可以顺利解决读数据请求压垮数据库的问题,目前互联网架构也基本是采取这个方案。但这个方案还在一个不足,无法解决写数据请求量大的问题,也就是说写请求多时,数据库还是会扛不住。针对这个问题,06 讲中我们会给出对应解决方案。
各位亲爱的同学,今天的课程就讲到这里。05 讲中的解决方案肯定还有一些遗漏的问题没有考虑到,如果你有更好的解决方案,欢迎在评论区留言与我互动哦。
另外,喜欢本专栏的同学欢迎将本文分享给更多的好友看到哦。
同学们,05 讲中我们详细讨论了缓存的架构方案,它可以减少数据库读操作压力,却也存在着不足。比如写操作并发量大时,这个方案并不奏效,那该怎么办呢?我们先来看一个具体的业务场景。
曾经,我所在的公司策划过一场超低价预约大型线上活动,在某天 9:00-9:15 期间,用户可以前往预约详情页半价预约抢购一款热门商品。根据市场部门的策划方案,这次活动运营目标是几十万左右的预约量。
面对如此大的预约量,如何防止涌进来的请求压垮数据库?
之前,我们给公司的系统做过一次压测:并发量保持在 8000 左右时,系统响应速度最高,并发量数据要是再上升,系统响应速度就会急剧变慢。如果几十万的用户同时在那个期间预约商品,可以预见高峰期(特别是 9 点那一瞬间)的并发量肯定超过 10000,到时我们的数据库肯定会因为承受不住而宕机。
为避免活动上线后出现此问题,我们必须提前做好预备方案。不过,在这场活动中,领导要求我们在架构上不要做太大调整。(说白了就是工期不能太长,也不能购买太多服务器。)
为此,我们最终采用的方案是不让预约的请求直接插入数据库,而是先存放到性能很高的缓冲地带,以此保证洪峰期间先冲击缓冲地带,之后再从缓冲地带异步匀速搬运数据到数据库中。
因使用的方案比较简单,所以这个方案设计不到 2 周就上线了,且活动期间用户体验全程没有卡顿,领导非常满意,夸赞我们技术棒棒哒。
你肯定会有疑问,这个问题分表分库不就能解决吗?是倒是可以,不过代价太大且性价比不高。毕竟这次仅仅是一个临时性市场活动,且前面也说了,这次活动运营目标是几十万的预约量,这点数据量采取分表分库的话,未免有点得不偿失。
其实,以上介绍的解决方案就是写缓存,这也是我们接下来要讲解的重点内容。
什么是写缓存呢?写缓存的思路是后台服务接收到用户请求时,如果请求校验没问题,数据并不会直接落库,而是先存储在缓冲层中,待缓冲层中写请求达到一定数量再进行批量落库。这里所说的缓冲地带,实际上指的就是写缓存。它的意义在于利用写缓存比数据库高几个量级的吞吐能力来承受洪峰流量,再平速搬运数据到数据库。
为了让你更容易理解这个实现过程,我画了一张写缓存架构设计示意图进行说明,如下图所示:
写缓存架构设计示意图
从以上设计方案中,不难看出写缓存可以大幅减少数据库写操作的频率,从而减少数据库压力。
上面这张图看起来很简单,但该方案在具体实施过程中,往往需要考虑六大问题。
在回答这个问题前,我们先来说说同步与异步之间的区别。
1.同步与异步的区别
比如同步,写请求提交数据后,写操作的线程会等到批量落库完成后才开始启动。这种设计的好处是用户预约成功后,可在我的预约页面立即看到预约数据。坏处是用户提交预约后,还需要在界面上等待一段时间才能返回结果,且这个时间不定,有可能需要等待一个完整的时间窗。
比如异步,写请求提交数据后,会直接提示用户提交成功。这种设计的好处是用户能快速知道提交结果。坏处是用户提交完后,如果手痒前往我的预约页面查看,可能会出现没有数据的情况,这时用户就会蒙圈儿。
那我们到底应该使用哪种设计模式呢?先别急,我们再来讨论下这两种设计模式的复杂度。
2.同步与异步的复杂度
同步的实现原理是写请求提交数据时,写请求的线程被堵塞住或者等待,待批量落库完成后再发送信号给写请求的线程,这个线程获得落库完成的信号后,最后返回预约成功给用户。
不过,这个过程会引出一系列的问题,比如:
用户到底需要等待多久?用户不可能无限期等待下去,此时我们还需要设置一个时间窗,比如每隔 100ms 批量落库 1 次;
如果批量落库超时了怎么办?写请求也不可能无限期等待,此时就需要给写请求的线程的堵塞设置一个超时时间;
如果批量落库失败了怎么办?是否需要重试?多久重试一次?
如果写请求一直堵塞在那直到重试成功再返回?那需要重试几次?这些逻辑其实与 Spring Cloud组件、Hystrix 请求合并功能(Hystrix 2018 年已经停止更新)等类似。
如果使用异步的话,上面的第二点、第四点基本不用考虑,从复杂度的角度上来说,异步会比同步简单很多,因此后面我们直接选用异步的方式,预约数据保存到缓冲层即可返回结果。
关于异步的用户体验设计,共有 2 种设计方案可供业务方选择。
在我的预约页面给用户提供一个提示:您的预约订单可能会有一定时间延迟。
用户预约成功后,直接进入预约完成详情页,此页面会定时发请求查询后台批量落库的状态,如果落库成功,则弹出成功提示,并跳转至下一个页面。
其实,第 1种方案在市面上也经常遇到,不过我们后面主要还是选择的第 2 种方案。因为在第 2 种方案中,用户也感受不到到延迟。
关于批量落库触发逻辑,目前市面上共分为 2 种触发方式。
1. 写请求满足特定次数后就落库1次,比如 10个请求落1次。
按照次数批量落库的优点是访问数据库的次数变为 1/N,从数据库压力上来说会小很多。不过也存在不足,如果访问数据库的次数未凑齐 N 次,用户的预约就一直无法落库。
2. 每隔一个时间窗口落库1次,比如每隔 1s 落库1 次。
按照时间窗口落库的优点是能保证用户等待的时间不会太久,其缺点是如果某个瞬时流量太大,在那个时间窗口落库的数据就会很多,多到在 1 次数据库访问中没法完成所有数据的插入(比如 1s 内堆积了 5 千条数据),它们只好通过分批次实现插入,这不就变回第 1 种逻辑了吗?
那到底哪种触发方式好呢?我之前的方案中是这两种方式同时使用,具体实现逻辑如下:
每收集 1 次写请求,插入预约的数据到缓存中,再判断缓存中预约的总数是否达到一定数量,达到后直接触发批量落库;
开 1 个定时器,每隔 1s 触发 1 次批量落库。
为方便你理解,这里我也画了一张架构示意图进行说明,如下图所示:
批量落库触发架构示意图
通过以上操作,我们既避免了触发方案 1 提到的数量不足无法落库的情况,也避免了方案2因为瞬时流量大,待插入数据堆积太多的情况。
缓冲数据不仅可以存放在本地内存中,也可以存放在分布式缓存中(比如 Redis),其中最简单的方式是存放本地内存中。
你可能想问,Hystrix的请求合并好像也是存放本地内存中?嗯,确实是,不过写缓存与 Hystrix 的请求合并有点不一样,请求合并更多考虑的是读请求的情况,不用担心数据丢失,而写请求需要考虑容灾问题。如果服务器出现宕机,内存数据就会丢失,用户的预约数据也就没了,业务人员肯定会疯掉。
因此,我们就使用分布式缓存了。因 05 讲中我们对分布式缓存的技术选型做了简单介绍,所以我们这次直接选择 Redis 了,这里我用一张图进行简单说明。
接下来,我们需要考虑批量落库的设计了,批量落库主要是把 Redis 中的预约数据移动到数据库中。那么问题又来了,当新的数据一直增加,批量落库可能会出现多个线程同时处理的情况,此时就要考虑并发性了。
实际上,缓冲层并发操作逻辑与冷热分离搬运冷数据的逻辑很相似,但这次我们讲个不一样的。
如果你对下面英文感兴趣,可以先看下 MySQL 官方文档中关于 Concurrent Inserts 的描述:
TheMyISAMstorage engine supports concurrent inserts to reduce contention between readers and writers for a given table: If aMyISAMtable has no holes in the data file (deleted rows in the middle), anINSERTstatement can be executed to add rows to the end of the table at the same time thatSELECTstatements are reading rows from the table.
If there are multipleINSERTstatements, they are queued and performed in sequence, concurrently with theSELECTstatements. The results of a concurrentINSERTmay not be visible immediately.
我翻译下红色部分的内容:如果多个 insert 语句同时执行,它们会按根据排队情况按顺序执行,也可以与 select 语句并发执行。
这里,我们再结合上面的场景具体说明下缓冲层并发操作时需要注意什么。
与冷热分离不一样的地方在于,这次我们并不需要搬运海量数据,因为每隔 1 秒或数据量凑满 10 条,数据就会自动搬运一次,所以 1 次 batch insert 操作就能轻松搞定这个问题,我们只需要在并发性的设计方案中保证一次仅有一个线程批量落库就行。这个逻辑比较简单,我们就不赘述了。
在考虑落库失败这个问题之前,我们先来看下批量落库的实现逻辑。
首先,当前线程从缓存中获取所有数据,因为每 10 条执行 1 次落库操作,不需要担心缓存数据量过多,也不用考虑将获得的数据分批次操作了;
其次,当前线程批量保存数据库;
最后,当前线程从缓存中删除对应数据。(注意:不能直接清空缓存的数据,因为新的预约数据可能插入到缓存中了。)
那在批量落库的过程中,如果这个操作失败了怎么办?我们自有妙招。
我们已经知道了批量落库每一步可能失败的处理的解决办法,接下来就是如何确保数据不丢失。
在上面的业务场景里,我们是先把用户提交的数据保存到缓存中,因此必须保证缓存中的数据不丢失,这就要求我们实现 Redis的数据备份。
现如今,Redis 共支持 2 种备份方式,我们一起来看下。
另外,Redis 还有一个主从的功能,这里我们就不深度展开了。如果你们公司已经存在一个统一管理的Redis 集群方案,你直接复用公司的就行,最起码运维有保障。
而如果你需要从 0 开始搭建,我认为最简单的解决方案如下:
先使用简单的主从模式;
然后在Slave Redis 里使用快照(30 秒 1 次)+AOF(1秒1次)的配置;
如果 master 宕机了,千万别直接启动,先把 slave 升级为 master;
这时代码里已经有预案了,写缓存如果失败直接落库。
不过这个方案有个缺点,一旦系统宕机,手动恢复时大家会手忙脚乱,但数据很有保障。
写缓存这个解决方案可以缓解写数据请求量太大压垮数据库的问题,但还是存在不足。
不足一: 此方案缓解的只是短时(活动期间)数据库压力的问题,当写数据量依旧非常大时,这个方案还是无法解决。
不足二: 此方案适合每个写操作都是独立的情况,如果写操作之间存在竞争资源,比如商品库存,这个方案就无法覆盖。
在后面 07 讲和 08 讲中,我们会专门讲解这两大不足点对应的解决方案,一定要记得继续学习哦。各位亲爱的同学,今天的课程就讲到这里了。
06 讲中的解决方案肯定还存在遗漏的地方没有考虑,如果你有更好的方案,欢迎在评论区留言,对本专栏感兴趣的也欢迎你分享给更多的好友看到哦。
06 讲我们详细讨论了写缓存的架构解决方案,它虽然可以减少数据库写操作的压力,但也存在不足。比如需要长期高频插入数据时,这个解决方案就无法满足,07 讲我们就围绕这个问题逐步提出解决方案。在架构方案层层展开的过程中,你会发现不断有新的问题需要讨论。
因业务快速发展,某天我们公司的日活用户高达 500 万,基于现有业务模式,业务侧要求我们根据用户的行为做埋点,旨在记录用户在特定页面的所有行为、开展数据分析与第三方进行费用结算。(为什么需要费用结算,这里就不展开了。)
当然,在数据埋点的过程中,业务侧还要求在后台能实时查询用户行为数据及统计报表。(这里虽说是实时,其实特定时间内的延迟业务方还是能接受的,为确保描述准确性,我们把它称之为准实时吧。)
为了让你更加容易理解后续方案的设计思路,我把真实业务场景中的数据结构进行了相关简化(真实的业务场景数据结构更加复杂)。首先,我们需要收集的原始数据结构如下表所示:
通过以上数据结构,在后台查询原始数据时,业务侧不仅可以以城市(根据经纬度换算)、性别(需要从业务表中抽取)、年龄(需要从业务表抽取)、目标类型、目标 ID、事件动作等作为查询条件实时查看用户行为数据,还可以以时间(天/周/月/年)、性别、年龄等维度实时查看每个目标 ID 的总点击数、平均点击次数、每个页面的转化率等统计报表数据。(当然,关于统计的需求还很多,这里我们只是列举了一小部分)。
为了实现费用结算这个需求,我们需要收集的数据结构如下表所示(再次强调,该数据结构只是示例,并非真实的业务场景):
希望以上数据结构可以帮助我们快速理解业务场景。为了快速实现整个解决方案,接下来我们探讨下技术选型的初步思路。
根据以上业务场景,我们提炼出了 6 点业务需求,并针对业务需求梳理了技术选型相关思路。
原始数据海量: 对于这点,我们初步考虑使用 HBase 进行持久化。
对于埋点记录的请求响应要快: 埋点记录服务会把原始埋点记录存放在一个缓冲的地方,以此保证响应快速。关于这点有好几个缓存方案,一会儿我们展开讨论。
可通过后台查询原始数据: 如果使用 HBase 直接作为查询引擎,查询速度太慢了,所以我们还需要使用 ES 来保存查询页面上作为查询条件的字段和活动 id。
各种统计报表的需求: 关于数据可视化工具也有很多选择,比如 Kibana、Grafana 等,考虑使用过程的灵活性,我们最终选择自己设计功能。
能根据埋点日志生成费用结算数据: 我们将费用结算数据保存在 MySQL 中。
需要一个框架将缓存中的数据进行处理,并保存到 ES、HBase 和 MySQL 中。 因为业务有准实时查询的需求,所以我们需要使用实时处理工具。目前,市面上流行的实时处理工具主要分为 Storm、Spark Streaming、Apache Flink 这三种,一会儿我们也会展开说明。
为了帮助你快速理解这部分知识,我简单画了一张架构图来说明,如下图所示:
仔细观察这张架构图,你会发现图上还有 2 个地方打了问号,这是为什么呢?这就涉及我们接下来需要讨论的 4 个问题。
市面上关于快速保存埋点数据的技术主要分为 Redis、Kafka、本地日志这三种,在上面的业务场景中,我们最终选择了本地日志。
说到这,你可能想问:Redis 跟 Kafka 到底哪里不好,为什么你没使用呢?我们先来说说 Redis 的 AOF 机制,这点在 06 讲我们也有说过。
Redis 的 AOF 机制会持久化保存 Redis 所有的操作记录,用于服务器宕机后数据还原。那 Redis 什么时候将 AOF 落盘呢?
在 Redis 中存在一个 AOF 配置:appendfsync,如果 appendfsync 配置成 everysec,AOF 每秒落盘一次,不过这种配置方式有可能会丢失 1 秒的数据。如果 appendfsync 配置成 always,每次操作请求的记录都落盘后再返回成功信息给客户端,不过这种配置方式系统性能就会很慢。因为对于埋点记录的请求要求响应快,所以我们没有选择 Redis。
接下来我们讨论下Kafka 的技术方案。
Kafka 的冗余设计是每个分区都有多个副本,其中一个副本是 Leader,其他副本都是 Follower,Leader 主要负责处理所有的读写请求,并同步数据给其他 Follower。
那么 Kafka 什么时候将数据从 Leader 同步给 Follower 呢 ?Kafka 的 producer configs 中也有个 acks 配置,它的配置方式分为三种。
acks=0:不等 Leader 将数据落到日志,Kafka 直接返回完成信号给客户端。这种方式虽然响应快,但数据持久化没有保障,数据如果没有落到本地日志,系统就会出现宕机,导致数据丢失。
acks=1:等 Leader 将数据落到本地日志,但是不等 Follower 同步数据,Kafka 就直接返回完成信号给客户端。
acks=all:等 Leader 将数据落到日志,且等 min.insync.replicas 个 Follower 都同步数据后,Kafka 再返回完成信号给客户端。这种配置方式虽然数据有保证,但响应慢。
通过以上内容的讲解,我们发现使用 Redis 与 Kafka 都会出现问题。
如果我们想保证数据的可靠性,必然需要牺牲系统性能,那有没有一个方案可以性能+可靠性同时兼得呢?有的,所以我们最终决定把埋点数据保存到本地日志中。
关于这个问题,最简单的方式是通过 Logstash 直接把日志文件中的数据搬运到 ES,但是问题来了,业务侧要求存放 ES 中的记录包含城市、性别、年龄等原始数据(这些字段需要调用业务系统的数据进行抽取),而这些原始数据日志文件中并没有,所以我们并没有选择 Logstash。
如果你坚持通过 Logstash 把日志文件的数据搬运到 ES,我分享 3 种实现方式。
自定义 filter: 先在 Logstash 自定义的 filter 里封装业务数据,再保存到 ES。因 Logstash 自定义的 filter 是使用 Ruby 语言编写的,也就是说我们需要使用其他语言编写业务逻辑,因此 Logstash 自定义 filter 的方案被我们 pass 了。
修改客户端的埋点逻辑: 每次记录埋点的数据发送到服务端之前,我们先在客户端将业务的相关字段提取出来再上传到服务端。这个方法也直接被业务端 pass 了,理由是后期业务侧每更新一次后台查询条件,我们就需要重新发一次版,实在太麻烦了。
修改埋点服务端的逻辑: 每次服务端在记录埋点的数据发送到日志文件之前,我们先从数据库获取业务字段组合埋点记录。这个方法也被服务端 pass 了,因为这种操作会直接影响每个请求的效率,间接影响用户体验。
另外,我们没选择 Logstash 还有 2 点原因。
日志文件中的数据需要同时输出 ES 和 Hbase 两个输出源,因 Logstash 的多输出源基于同一个 pipeline,如果 1 个输出源出错了,另 1 个输出源也会出错,两者之间会互相影响。
MySQL 中需要生成费用结算数据,而费用结算数据需要通过分析埋点的数据动态来计算,显然 Logstash 并不适合这样的业务场景,因为 filter 可以改变每条数据某些字段的值。
在上面的业务场景中,我们最终决定引入了一个计算框架了,此时整个解决方案的架构图如下:
这个方案中就是先通过 Logstash 把日志文件搬运到 MQ 中,再通过实时计算框架处理 MQ 中的数据,最后保存处理转换出来的数据到持久层中。
实际上,引入实时计算框架是为了在原始的埋点数据中填充业务数据,并统计埋点数据生成费用结算数据,最后分别保存到持久层中。
最后,关于 Logstash 的注意点,我们需要重点强调下。
Logstash 系统是通过 Ruby 语言编写的,资源消耗大,所以官方又推出了一个轻量的 Filebeat。我们可以使用 Filebeat 收集数据,再通过 Logstash 进行数据过滤。如果你不想使用 Logstash 的强大过滤功能,你可以直接使用 Filebeat 收集日志数据发送给 Kafka。
但问题又来了,Filebeat 是使用轮询的方式采集文件变动,存在一定(有时候很大)延时,不像 Logstash 可直接监听文件变动,所以最终我们选择继续使用 Logstash。(因为我们扛得住资源的消耗。)
讨论到这,上图中绿色部分的内容已确认,接下来我们开始讨论橙色部分的内容:Kafka、处理框架。
Kafka 是 LinkedIn 推出的开源消息中间件,它天生是为收集日志而设计,且它具备超高的吞吐量和数据量的扩展性,号称无限堆积。
根据LinkedIn官方说法,他们使用 3 台便宜的机器部署 Kafka,就能每秒写入 2 百万条记录,官方博客截图如下图所示:
(来源:LinkedIn官方博客)
看到这,你肯定会好奇为什么它的吞吐量这么高?这里我们就有必要了解 Kafka 的存储结构了,我们先看一张架构示意图,如下图所示:
图片来源 :Kafka 官方文档:http://kafka.apache.org/documentation/#log
Kafka 的存储结构中每个 Topic 分区相当于 1 个巨型文件,而每个巨型文件又是由多个 segment 小文件组成。其中,Producer 负责对该巨型文件进行“顺序写”,Consumer 负责对该文件进行“顺序读”。
这里,我们可以把 Kafka 的存储架构简单理解为 Kafka 写数据通过追加数据到文件尾实现顺序写,读取数据时直接从文件中读,好处是读操作不会阻塞写操作,这也是吞吐量大的原因。
另外,理论上只要磁盘空间足够,Kafka 可以实现消息无限堆积,因此它特别适合处理日志收集这种场景,可见我们选择使用 Kafka 是有一定理论依据的哦。
为了把 Kafka 的数据搬运到持久层,我们需要使用一个分布式实时计算框架,原因有 2 点。
数据量特别大,为此我们需要使用一个处理框架将上亿的埋点数据每天进行快速分析和处理(且必须使用多个节点并发处理才来得及),再存放到 ES、HBase 和 MySQL 中,即大数据计算,因此它有分布式计算的诉求。
业务要求实时查询统计报表数据,因此我们需要一个实时计算框架处理埋点数据。
目前,市面上流行的分布式实时计算框架有 3 种:Storm、Spark Stream、Apache Flink,到底使用哪个好呢?
我认为都可以,这就看公司的具体情况了,比如公司已经使用实时计算框架了,你就不需要再考虑这个问题了,如果公司还没有使用,那就看个人喜好了。
我个人偏好 Apache Flink,不仅因为它性能强(听说阿里双 11 使用它后,1 秒内处理了 17 亿条数据),还因为它的容错机制能保证每条数据仅仅处理 1 次,且它有时间窗口处理功能。
关于流处理、容错机制、时间窗口这三个概念,我们具体展开说明一下。
在流处理这个过程中,往往会引发一系列的问题,比如一条消息处理过程中,如果系统出现故障该怎么办?你会重试吗?如果重试会不会出现重复处理?如果不重试,消息是否会丢失?你能保证每条消息最多或最少处理几次?
在不同流处理框架中采取不同的容错机制,它们也就保证了不一样的一致性。
At-Most-Once : 至多一次,表示一条消息不管后续处理成功与否只会被消费处理一次,存在数据丢失可能。
Exactly-Once : 精确一次,表示一条消息从其消费到后续的处理成功,只会发生一次。
At-Least-Once : 至少一次,表示一条消息从消费到后续的处理成功,可能会发生多次,存在重复消费的可能。
以上三种方式中,Exactly-Once 无疑是最优的选择,因为在正常的业务场景中,一般只要求消息处理一次。而 Apache Flink 的容错机制就可以保证所有消息只处理 1 次(Exactly-Once)的一致性,还能保证系统安全性能,所以很多人最终都使用它。
接下来,我们来说说 Apache Flink 的时间窗口计算功能,以下是 Apache Flink 的一个代码示例,它把每个小时里发生事件的用户聚合在一个列表中。
final StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
env.setStreamTimeCharacteristic(TimeCharacteristic.ProcessingTime);
// alternatively:
// env.setStreamTimeCharacteristic(TimeCharacteristic.IngestionTime);
// env.setStreamTimeCharacteristic(TimeCharacteristic.EventTime);
DataStream stream = env.addSource(new FlinkKafkaConsumer09(topic, schema, props));
stream
.keyBy( (event) -> event.getUser() )
.timeWindow(Time.hours(1))
.reduce( (a, b) -> a.add(b) )
.addSink(...);
我们知道,日志中事件发生的时间有可能与计算框架处理消息的时间不一致。
假定实时计算框架收到消息的时间是 2 秒后,比如有一条消息,这个事件发生的时间是 6:30,因你接收到消息后处理的时间延后了 2 秒,即变成了 6:32,因此当你计算 6:01-6:30 的数据和,这条消息并不会计算在 6:01-6:30 范围内,这就不符合实际的业务需求了。
在实际业务场景中,如果需要按照时间窗口统计数据,我们往往是根据消息的事件时间来计算。而 Apache Flink 的特性恰恰是基于消息的事件时间,而不是基于计算框架的处理时间,这也是它的另一个撒手锏。
为了方便你理解,我也画了一张图来说明,此时整个架构设计方案如下图所示:
07 讲中,我们并没有讲解一些特别深入的架构设计上的注意点,主要是阐述技术选型背后的思考过程,希望对你的架构思维的提升有所帮助。
07 讲中探讨的解决方案肯定还存在遗漏的问题没有考虑到,如果你有更好的方案,欢迎在评论区留言,对本专栏感兴趣的同学欢迎分享给更多的好友看到哦。
08 讲我们开始讲解讲秒杀架构,秒杀架构是一个综合性非常强的问题,并且在面试时经常会被问到,所以我强烈建议你多关注一下。