Java业务开发常见问题
Spring 框架:IoC 和 AOP 是扩展的核心
当 Bean 产生循环依赖时,比如 BeanA 的构造方法依赖 BeanB 作为成员需要注入,BeanB 也依赖 BeanA,你觉得会出现什么问题呢?又有哪些解决方式呢?
答:Bean 产生循环依赖,主要包括两种情况:一种是注入属性或字段涉及循环依赖,另一种是构造方法注入涉及循环依赖。接下来,我分别和你讲一讲。
第一种,注入属性或字段涉及循环依赖,比如 TestA 和 TestB 相互依赖:
1 |
|
针对这个问题,Spring 内部通过三个 Map 的方式解决了这个问题,不会出错。基本原理是,因为循环依赖,所以实例的初始化无法一次到位,需要分步进行:
创建 A(仅仅实例化,不注入依赖);
创建 B(仅仅实例化,不注入依赖);
为 B 注入 A(此时 B 已健全);
为 A 注入 B(此时 A 也健全)。
网上有很多相关的分析,我找了一篇比较详细的,可供你参考。
第二种,构造方法注入涉及循环依赖。遇到这种情况的话,程序无法启动,比如 TestC 和 TestD 的相互依赖:
1 |
|
这种循环依赖的主要解决方式,有 2 种:
改为属性或字段注入;
使用 @Lazy 延迟注入。比如如下代码:
1 |
|
其实,这种 @Lazy 方式注入的就不是实际的类型了,而是代理类,获取的时候通过代理去拿值(实例化)。所以,它可以解决循环依赖无法实例化的问题。
数据库索引:索引并不是万能药
索引除了可以用于加速搜索外,还可以在排序时发挥作用,你能通过 EXPLAIN 来证明吗?你知道,针对排序在什么情况下,索引会失效吗?
答:排序使用到索引,在执行计划中的体现就是 key 这一列。如果没有用到索引,会在 Extra 中看到 Using filesort,代表使用了内存或磁盘进行排序。而具体走内存还是磁盘,是由 sort_buffer_size 和排序数据大小决定的。
排序无法使用到索引的情况有:
对于使用联合索引进行排序的场景,多个字段排序 ASC 和 DESC 混用;
a+b 作为联合索引,按照 a 范围查询后按照 b 排序;
排序列涉及到的多个字段不属于同一个联合索引;
排序列使用了表达式。
为什么联合索引无法优化排序
- 联合索引
(a, b)
的设计是为了优化a
列的查询和a
列相同情况下的b
列查询。 - 当
a
列是范围查询时,b
列的顺序在索引中被打乱,因此无法直接利用索引来优化b
列的排序。
如果需要对 b
列进行排序,同时又有 a
列的范围查询,可以考虑以下优化方法:
方法 1:调整索引顺序
- 如果查询条件中
b
列的排序是必须的,可以尝试调整索引顺序为(b, a)
。 - 这样,MySQL 可以先按
b
列排序,然后再按a
列过滤。但这种方法可能不适用于所有场景,具体取决于查询条件。
方法 2:覆盖索引
- 如果查询只需要
a
和b
列,可以创建一个覆盖索引(a, b)
,并确保查询只选择a
和b
列。 - 这样,MySQL 可以直接从索引中获取数据,而不需要回表查询,从而提高性能。
方法 3:拆分查询
- 如果数据量较大,可以将查询拆分为两步:
- 先根据
a
列的范围条件查询出主键。 - 再根据主键查询数据,并对
b
列进行排序。
- 先根据
数据源头:任何客户端的东西都不可信任
问题 1:在讲述用户标识不能从客户端获取这个要点的时候,我提到开发同学可能会因为用户信息未打通而通过前端来传用户 ID。那我们有什么好办法,来打通不同的系统甚至不同网站的用户标识吗?
答:打通用户在不同系统之间的登录,大致有以下三种方案。
第一种,把用户身份放在统一的服务端,每一个系统都需要到这个服务端来做登录状态的确认,确认后在自己网站的 Cookie 中保存会话,这就是单点登录的做法。这种方案要求所有关联系统都对接一套中央认证服务器(中央保存用户会话),在未登录的时候跳转到中央认证服务器进行登录或登录状态确认。因此,这种方案适合一个公司内部的不同域名下的网站。
第二种,把用户身份信息直接放在 Token 中,在客户端任意传递,Token 由服务端进行校验(如果共享密钥话,甚至不需要同一个服务端进行校验),无需采用中央认证服务器,相对比较松耦合,典型的标准是 JWT。这种方案适合异构系统的跨系统用户认证打通,而且相比单点登录的方案,用户体验会更好一些。
第三种,如果需要打通不同公司系统的用户登录状态,那么一般都会采用 OAuth 2.0 的标准中的授权码模式,基本流程如下:
第三方网站客户端转到授权服务器,上送 ClientID、重定向地址 RedirectUri 等信息。
用户在授权服务器进行登录并且进行授权批准(授权批准这步可以配置为自动完成)。
授权完成后,重定向回到之前客户端提供的重定向地址,附上授权码。
第三方网站服务端通过授权码 +ClientID+ClientSecret 去授权服务器换取 Token。这里的 Token 包含访问 Token 和刷新 Token,访问 Token 过期后用刷新 Token 去获得新的访问 Token。
因为我们不会对外暴露 ClientSecret,也不会对外暴露访问 Token,同时使用授权码换取 Token 的过程是服务端进行的,客户端拿到的只是一次性的授权码,所以这种模式比较安全。
问题 2:还有一类和客户端数据相关的漏洞非常重要,那就是 URL 地址中的数据。在把匿名用户重定向到登录页面的时候,我们一般会带上 redirectUrl,这样用户登录后可以快速返回之前的页面。黑客可能会伪造一个活动链接,由真实的网站 + 钓鱼的 redirectUrl 构成,发邮件诱导用户进行登录。用户登录时访问的其实是真的网站,所以不容易察觉到 redirectUrl 是钓鱼网站,登录后却来到了钓鱼网站,用户可能会不知不觉就把重要信息泄露了。这种安全问题,我们叫做开放重定向问题。你觉得,从代码层面应该怎么预防开放重定向问题呢?
答:要从代码层面预防开放重定向问题,有以下三种做法可供参考:
第一种,固定重定向的目标 URL。
第二种,可采用编号方式指定重定向的目标 URL,也就是重定向的目标 URL 只能是在我们的白名单内的。
第三种,用合理充分的校验方式来校验跳转的目标地址,如果是非己方地址,就告知用户跳转有风险,小心钓鱼网站的威胁。
安全兜底:涉及钱时,必须考虑防刷、限量和防重
问题 1:防重、防刷都是事前手段,如果我们的系统正在被攻击或利用,你有什么办法及时发现问题吗?
答:对于及时发现系统正在被攻击或利用,监控是较好的手段,关键点在于报警阈值怎么设置。我觉得可以对比昨天同时、上周同时的量,发现差异达到一定百分比报警,而且报警需要有升级机制。此外,有的时候大盘很大的话,活动给整个大盘带来的变化不明显,如果进行整体监控可能出了问题也无法及时发现,因此可以考虑对于活动做独立的监控报警。
问题 2:任何三方资源的使用一般都会定期对账,如果在对账中发现我们系统记录的调用量低于对方系统记录的使用量,你觉得一般是什么问题引起的呢?
答:我之前遇到的情况是,在事务内调用外部接口,调用超时后本地事务回滚本地就没有留下数据。更合适的做法是:
请求发出之前先记录请求数据提交事务,记录状态为未知。
发布调用外部接口的请求,如果可以拿到明确的结果,则更新数据库中记录的状态为成功或失败。如果出现超时或未知异常,不能假设第三方接口调用失败,需要通过查询接口查询明确的结果。
写一个定时任务补偿数据库中所有未知状态的记录,从第三方接口同步结果。
值得注意的是,对账的时候一定要对两边,不管哪方数据缺失都可能是因为程序逻辑有 bug,需要重视。此外,任何涉及第三方系统的交互,都建议在数据库中保持明细的请求 / 响应报文,方便在出问题的时候定位 Bug 根因。
问题3:开放平台资源的使用需要考虑防刷,该怎么限制短信接口被盗刷?
第一种方式,只有固定的请求头才能发送验证码。
也就是说,我们通过请求头中网页或 App 客户端传给服务端的一些额外参数,来判断请求是不是 App 发起的。其实,这种方式“防君子不防小人”。
比如,判断是否存在浏览器或手机型号、设备分辨率请求头。对于那些使用爬虫来抓取短信接口地址的程序来说,往往只能抓取到 URL,而难以分析出请求发送短信还需要的额外请求头,可以看作第一道基本防御。
第二种方式,只有先到过注册页面才能发送验证码。
对于普通用户来说,不管是通过 App 注册还是 H5 页面注册,一定是先进入注册页面才能看到发送验证码按钮,再点击发送。我们可以在页面或界面打开时请求固定的前置接口,为这个设备开启允许发送验证码的窗口,之后的请求发送验证码才是有效请求。
这种方式可以防御直接绕开固定流程,通过接口直接调用的发送验证码请求,并不会干扰普通用户。
第三种方式,控制相同手机号的发送次数和发送频次。
除非是短信无法收到,否则用户不太会请求了验证码后不完成注册流程,再重新请求。因此,我们可以限制同一手机号每天的最大请求次数。验证码的到达需要时间,太短的发送间隔没有意义,所以我们还可以控制发送的最短间隔。比如,我们可以控制相同手机号一天只能发送 10 次验证码,最短发送间隔 1 分钟。
第四种方式,增加前置图形验证码。
短信轰炸平台一般会收集很多免费短信接口,一个接口只会给一个用户发一次短信,所以控制相同手机号发送次数和间隔的方式不够有效。这时,我们可以考虑对用户体验稍微有影响,但也是最有效的方式作为保底,即将弹出图形验证码作为前置。
除了图形验证码,我们还可以使用其他更友好的人机验证手段(比如滑动、点击验证码等),甚至是引入比较新潮的无感知验证码方案(比如,通过判断用户输入手机号的打字节奏,来判断是用户还是机器),来改善用户体验。
此外,我们也可以考虑在监测到异常的情况下再弹出人机检测。比如,短时间内大量相同远端 IP 发送验证码的时候,才会触发人机检测。
总之,我们要确保,只有正常用户经过正常的流程才能使用开放平台资源,并且资源的用量在业务需求合理范围内。此外,还需要考虑做好短信发送量的实时监控,遇到发送量激增要及时报警。
钱的进出一定要和订单挂钩并且实现幂等
涉及钱的进出,需要做好以下两点。
第一,任何资金操作都需要在平台侧生成业务属性的订单,可以是优惠券发放订单,可以是返现订单,也可以是借款订单,一定是先有订单再去做资金操作。同时,订单的产生需要有业务属性。业务属性是指,订单不是凭空产生的,否则就没有控制的意义。比如,返现发放订单必须关联到原先的商品订单产生;再比如,借款订单必须关联到同一个借款合同产生。
第二,一定要做好防重,也就是实现幂等处理,并且幂等处理必须是全链路的。这里的全链路是指,从前到后都需要有相同的业务订单号来贯穿,实现最终的支付防重。
对于支付操作,我们一定是调用三方支付公司的接口或银行接口进行处理的。一般而言,这些接口都会有商户订单号的概念,对于相同的商户订单号,无法进行重复的资金处理,所以三方公司的接口可以实现唯一订单号的幂等处理。
但是,业务系统在实现资金操作时容易犯的错是,没有自始至终地使用一个订单号作为商户订单号,透传给三方支付接口。出现这个问题的原因是,比较大的互联网公司一般会把支付独立一个部门。支付部门可能会针对支付做聚合操作,内部会维护一个支付订单号,然后使用支付订单号和三方支付接口交互。最终虽然商品订单是一个,但支付订单是多个,相同的商品订单因为产生多个支付订单导致多次支付。
如果说,支付出现了重复扣款,我们可以给用户进行退款操作,但给用户付款的操作一旦出现重复付款,就很难把钱追回来了,所以更要小心。
这,就是全链路的意义,从一开始就需要先有业务订单产生,然后使用相同的业务订单号一直贯穿到最后的资金通路,才能真正避免重复资金操作。
如何正确保存和传输敏感数据?
问题 1:虽然我们把用户名和密码脱敏加密保存在数据库中,但日志中可能还存在明文的敏感数据。你有什么思路在框架或中间件层面,对日志进行脱敏吗?
答:如果我们希望在日志的源头进行脱敏,那么可以在日志框架层面做。比如对于 logback 日志框架,我们可以自定义 MessageConverter,通过正则表达式匹配敏感信息脱敏。
需要注意的是,这种方式有两个缺点。
第一,正则表达式匹配敏感信息的格式不一定精确,会出现误杀漏杀的现象。一般来说,这个问题不会很严重。要实现精确脱敏的话,就只能提供各种脱敏工具类,然后让业务应用在日志中记录敏感信息的时候,先手动调用工具类进行脱敏。
第二,如果数据量比较大的话,脱敏操作可能会增加业务应用的 CPU 和内存使用,甚至会导致应用不堪负荷出现不可用。考虑到目前大部分公司都引入了 ELK 来集中收集日志,并且一般而言都不允许上服务器直接看文件日志,因此我们可以考虑在日志收集中间件中(比如 logstash)写过滤器进行脱敏。这样可以把脱敏的消耗转义到 ELK 体系中,不过这种方式同样有第一点提到的字段不精确匹配导致的漏杀误杀的缺点。
问题 2:你知道 HTTPS 双向认证的目的是什么吗?流程上又有什么区别呢?
答:单向认证一般用于 Web 网站,浏览器只需要验证服务端的身份。对于移动端 App,如果我们希望有更高的安全性,可以引入 HTTPS 双向认证,也就是除了客户端验证服务端身份之外,服务端也验证客户端的身份。
单向认证和双向认证的流程区别,主要包括以下三个方面。
第一,不仅仅服务端需要有 CA 证书,客户端也需要有 CA 证书。
第二,双向认证的流程中,客户端校验服务端 CA 证书之后,客户端会把自己的 CA 证书发给服务端,然后服务端需要校验客户端 CA 证书的真实性。
第三,客户端给服务端的消息会使用自己的私钥签名,服务端可以使用客户端 CA 证书中的公钥验签。
这里还想补充一点,对于移动应用程序考虑到更强的安全性,我们一般也会把服务端的公钥配置在客户端中,这种方式的叫做 SSL Pinning。也就是说由客户端直接校验服务端证书的合法性,而不是通过证书信任链来校验。采用 SSL Pinning,由于客户端绑定了服务端公钥,因此我们无法通过在移动设备上信用根证书实现抓包。不过这种方式的缺点是需要小心服务端 CA 证书过期后续证书注意不要修改公钥。
缓存设计:缓存可以锦上添花也可以落井下石
问题 1:在聊到缓存并发问题时,我们说到热点 Key 回源会对数据库产生的压力问题,如果 Key 特别热的话,可能缓存系统也无法承受,毕竟所有的访问都集中打到了一台缓存服务器。如果我们使用 Redis 来做缓存,那可以把一个热点 Key 的缓存查询压力,分散到多个 Redis 节点上吗?
答:Redis 4.0 以上如果开启了 LFU 算法作为 maxmemory-policy,那么可以使用–hotkeys 配合 redis-cli 命令行工具来探查热点 Key。此外,我们还可以通过 MONITOR 命令来收集 Redis 执行的所有命令,然后配合redis-faina 工具来分析热点 Key、热点前缀等信息。
对于重要节假日、线上促销活动、集中推送这些提前已知的事情,可以提前评估出可能的热 key 来。而对于突发事件,无法提前评估,可以通过 Spark,对应流任务进行实时分析,及时发现新发布的热点 key。而对于之前已发出的事情,逐步发酵成为热 key 的,则可以通过 Hadoop 对批处理任务离线计算,找出最近历史数据中的高频热 key。
找到热 key 后,就有很多解决办法了。首先可以将这些热 key 进行分散处理,比如一个热 key 名字叫 hotkey,可以被分散为 hotkey#1、hotkey#2、hotkey#3,……hotkey#n,这 n 个 key 分散存在多个缓存节点,然后 client 端请求时,随机访问其中某个后缀的 hotkey,这样就可以把热 key 的请求打散,避免一个缓存节点过载。
其次,也可以 key 的名字不变,对缓存提前进行多副本+多级结合的缓存架构设计。
再次,如果热 key 较多,还可以通过监控体系对缓存的 SLA 实时监控,通过快速扩容来减少热 key 的冲击。
最后,业务端还可以使用本地缓存,将这些热 key 记录在本地缓存,来减少对远程缓存的冲击。
当然,除了分散 Redis 压力之外,我们也可以考虑再做一层短时间的本地缓存,结合 Redis 的 Keyspace 通知功能,当 Redis 集群压力超过阈值时,熔断降级直接返回本地缓存或默认值。
问题 2:大 Key 也是数据缓存容易出现的一个问题。如果一个 Key 的 Value 特别大,那么可能会对 Redis 产生巨大的性能影响,因为 Redis 是单线程模型,对大 Key 进行查询或删除等操作,可能会引起 Redis 阻塞甚至是高可用切换。你知道怎么查询 Redis 中的大 Key,以及如何在设计上实现大 Key 的拆分吗?
答:Redis 的大 Key 可能会导致集群内存分布不均问题,并且大 Key 的操作可能也会产生阻塞。
关于查询 Redis 中的大 Key,我们可以使用 redis-cli –bigkeys
命令来实时探查大 Key。此外,我们还可以使用 redis-rdb-tools 工具来分析 Redis 的 RDB 快照,得到包含 Key 的字节数、元素个数、最大元素长度等信息的 CSV 文件。然后,我们可以把这个 CSV 文件导入 MySQL 中,写 SQL 去分析。
针对大 Key,我们可以考虑几方面的优化:
第一,是否有必要在 Redis 保存这么多数据。一般情况下,我们在缓存系统中保存面向呈现的数据,而不是原始数据;对于原始数据的计算,我们可以考虑其它文档型或搜索型的 NoSQL 数据库。
第二,考虑把具有二级结构的 Key(比如 List、Set、Hash)拆分成多个小 Key,来独立获取(或是用 MGET 获取)。将大 key 分拆为多个 key,尽量减少大 key 的存在。同时由于大 key 一旦穿透到 DB,加载耗时很大,所以可以对这些大 key 进行特殊照顾,比如设置较长的过期时间,比如缓存内部在淘汰 key 时,同等条件下,尽量不淘汰这些大 key。
第三,可以扩展新的数据结构,同时让 client 在这些大 key 写缓存之前,进行序列化构建,然后通过 restore 一次性写入。
此外值得一提的是,大 Key 的删除操作可能会产生较大性能问题。从 Redis 4.0 开始,我们可以使用 UNLINK 命令而不是 DEL 命令在后台删除大 Key;而对于 4.0 之前的版本,我们可以考虑使用游标删除大 Key 中的数据,而不是直接使用 DEL 命令,比如对于 Hash 使用 HSCAN+HDEL 结合管道功能来删除。
异步处理好用,但非常容易用错
在用户注册后发送消息到 MQ,然后会员服务监听消息进行异步处理的场景下,有些时候我们会发现,虽然用户服务先保存数据再发送 MQ,但会员服务收到消息后去查询数据库,却发现数据库中还没有新用户的信息。你觉得,这可能是什么问题呢,又该如何解决呢?
答:我先来分享下,我遇到这个问题的真实情况。
当时,我们是因为业务代码把保存数据和发 MQ 消息放在了一个事务中,收到消息的时候有可能事务还没有提交完成。为了解决这个问题,开发同学当时的处理方式是,收 MQ 消息的时候 Sleep 1 秒再去处理。这样虽然解决了问题,但却大大降低了消息处理的吞吐量。
更好的做法是先提交事务,完成后再发 MQ 消息。但是,这又引申出来一个问题:MQ 消息发送失败怎么办,如何确保发送消息和本地事务有整体事务性?
方案 1:本地消息表(Local Message Table)
这是一种经典的分布式事务解决方案,核心思想是通过本地事务保证消息的可靠性。
实现步骤:
- 在用户服务的数据库中创建一个本地消息表,用于存储待发送的 MQ 消息。
- 用户服务在保存用户数据的同时,将 MQ 消息写入本地消息表(同一个事务)。
- 事务提交后,通过一个后台任务(或定时任务)从本地消息表中读取消息,并发送到 MQ。
- 消息发送成功后,删除本地消息表中的记录。
优点:
- 保证了本地事务和消息发送的一致性。
- 即使消息发送失败,也可以通过后台任务重试。
缺点:
- 需要维护一个本地消息表,增加了数据库的复杂性。
- 需要实现后台任务来发送消息。
方案 2:事务消息(Transactional Outbox)
这是一种基于消息队列的事务性解决方案,适用于支持事务消息的 MQ(如 RocketMQ、Kafka)。
实现步骤:
- 用户服务在保存用户数据的同时,将 MQ 消息写入本地消息表(同一个事务)。
- 使用 MQ 的事务消息功能,将消息发送到 MQ。
- 如果消息发送成功,MQ 会通知用户服务删除本地消息表中的记录。
- 如果消息发送失败,MQ 会触发重试机制。
优点:
- 消息发送和本地事务具有强一致性。
- 不需要额外的后台任务。
缺点:
- 依赖 MQ 的事务消息功能,不是所有 MQ 都支持。
- 实现复杂度较高。
方案 3:消息队列的最终一致性
这是一种基于消息队列的最终一致性解决方案,适用于对一致性要求不是特别高的场景。
实现步骤:
- 用户服务在保存用户数据后,发送 MQ 消息。
- 如果消息发送失败,用户服务会记录日志,并通过定时任务重试发送消息。
- 会员服务监听到消息后,处理新用户的信息。如果查询不到新用户的信息,可以等待一段时间后重试。
优点:
- 实现简单,适用于大多数场景。
- 不需要依赖复杂的分布式事务机制。
缺点:
- 无法保证强一致性,只能保证最终一致性。
- 需要处理消息重复消费的问题(幂等性)。
方案 4:分布式事务框架(如 Seata)
如果业务对一致性要求非常高,可以使用分布式事务框架(如 Seata)来保证本地事务和消息发送的一致性。
实现步骤:
- 用户服务在保存用户数据后,发送 MQ 消息。
- Seata 会协调用户服务和 MQ 的事务,确保两者同时提交或回滚。
优点:
- 保证了强一致性。
- 适用于复杂的分布式事务场景。
缺点:
- 实现复杂度高,性能开销较大。
- 需要引入额外的分布式事务框架。
推荐方案
根据你的场景和需求,推荐以下方案:
- 如果对一致性要求较高,可以选择 本地消息表 或 事务消息。
- 如果对一致性要求较低,可以选择 消息队列的最终一致性,并通过重试机制和幂等性来保证数据的正确性。
数据服务系统架构
我们设计了一个包含多个数据库系统的、能应对各种高并发场景的一套数据服务的系统架构,其中包含了同步写服务、异步写服务和查询服务三部分,分别实现主数据库写入、辅助数据库写入和查询路由。
我们按照服务来依次分析下这个架构。
首先要明确的是,重要的业务主数据只能保存在 MySQL 这样的关系型数据库中,原因有三点:
RDBMS 经过了几十年的验证,已经非常成熟;
RDBMS 的用户数量众多,Bug 修复快、版本稳定、可靠性很高;
RDBMS 强调 ACID,能确保数据完整。
有两种类型的查询任务可以交给 MySQL 来做,性能会比较好,这也是 MySQL 擅长的地方:
按照主键 ID 的查询。直接查询聚簇索引,其性能会很高。但是单表数据量超过亿级后,性能也会衰退,而且单个数据库无法承受超大的查询并发,因此我们可以把数据表进行 Sharding 操作,均匀拆分到多个数据库实例中保存。我们把这套数据库集群称作 Sharding 集群。
按照各种条件进行范围查询,查出主键 ID。对二级索引进行查询得到主键,只需要查询一棵 B+ 树,效率同样很高。但索引的值不宜过大,比如对 varchar(1000) 进行索引不太合适,而索引外键(一般是 int 或 bigint 类型)性能就会比较好。因此,我们可以在 MySQL 中建立一张“索引表”,除了保存主键外,主要是保存各种关联表的外键,以及尽可能少的 varchar 类型的字段。这张索引表的大部分列都可以建上二级索引,用于进行简单搜索,搜索的结果是主键的列表,而不是完整的数据。由于索引表字段轻量并且数量不多(一般控制在 10 个以内),所以即便索引表没有进行 Sharding 拆分,问题也不会很大。
如图上蓝色线所示,写入两种 MySQL 数据表和发送 MQ 消息的这三步,我们用一个同步写服务完成了。我在“异步处理”中提到,所有异步流程都需要补偿,这里的异步流程同样需要。只不过为了简洁,我在这里省略了补偿流程。
然后,如图中绿色线所示,有一个异步写服务,监听 MQ 的消息,继续完成辅助数据的更新操作。这里我们选用了 ES 和 InfluxDB 这两种辅助数据库,因此整个异步写数据操作有三步:
MQ 消息不一定包含完整的数据,甚至可能只包含一个最新数据的主键 ID,我们需要根据 ID 从查询服务查询到完整的数据。
写入 InfluxDB 的数据一般可以按时间间隔进行简单聚合,定时写入 InfluxDB。因此,这里会进行简单的客户端聚合,然后写入 InfluxDB。
ES 不适合在各索引之间做连接(Join)操作,适合保存扁平化的数据。比如,我们可以把订单下的用户、商户、商品列表等信息,作为内嵌对象嵌入整个订单 JSON,然后把整个扁平化的 JSON 直接存入 ES。
对于数据写入操作,我们认为操作返回的时候同步数据一定是写入成功的,但是由于各种原因,异步数据写入无法确保立即成功,会有一定延迟,比如:
异步消息丢失的情况,需要补偿处理;
写入 ES 的索引操作本身就会比较慢;
写入 InfluxDB 的数据需要客户端定时聚合。
因此,对于查询服务,如图中红色线所示,我们需要根据一定的上下文条件(比如查询一致性要求、时效性要求、搜索的条件、需要返回的数据字段、搜索时间区间等)来把请求路由到合适的数据库,并且做一些聚合处理:
需要根据主键查询单条数据,可以从 MySQL Sharding 集群或 Redis 查询,如果对实时性要求不高也可以从 ES 查询。
按照多个条件搜索订单的场景,可以从 MySQL 索引表查询出主键列表,然后再根据主键从 MySQL Sharding 集群或 Redis 获取数据详情。
各种后台系统需要使用比较复杂的搜索条件,甚至全文搜索来查询订单数据,或是定时分析任务需要一次查询大量数据,这些场景对数据实时性要求都不高,可以到 ES 进行搜索。此外,MySQL 中的数据可以归档,我们可以在 ES 中保留更久的数据,而且查询历史数据一般并发不会很大,可以统一路由到 ES 查询。
监控系统或后台报表系统需要呈现业务监控图表或表格,可以把请求路由到 InfluxDB 查询。