数据库表解耦
Hi,我是阿昌
,今天学习记录的是关于数据库表解耦
的内容。
微服务拆分之初,需要搭建好的两个基础设施,一个是基于开关的反向代理
,另一个是数据同步机制
。
有了这两个设施做保障,接下来就可以大刀阔斧地一一拆解了。除此之外,如何用 API 来取代代码依赖。
可能已经发现了,这个实践与其说是解决了对于单体代码的依赖,不如说是解决了对于单体数据库的依赖。诚然,对于保险系统以及大多数这种数据密集型系统来说,所有的操作最终都将体现到数据库上。在这类系统的改造过程中,最不好解决的问题莫过于数据库表的解耦了。
这种面向数据库编程在 21 世纪初的国内是十分流行的,像 Delphi、VB 等都提供了很方便的数据库连接组件,而 PowerBuilder 更是推出了可以直连数据库的 DataWindow,将数据的访问和展示耦合在了一起。
在这样的大背景下,将所有逻辑都放在客户端组件的 Smart UI 模式,以及将业务逻辑和数据访问逻辑混杂的“事务脚本模式”为什么如此流行,也就不难理解了。
然而随着业务的发展,这种模式的弊病也越来越明显,很多团队都意识到了这个问题并着手解决。但在遗留系统中,这样的代码还是随处可见。
在微服务拆分的过程中,如何界定数据库表的所有权,进而拆分给不同的服务,就成了最为棘手的工作。
一、用 API 调用取代连表查询
用 PolicyDao 和 UnderwriteApplicationDao 去查询保单和核保申请数据,再组合成我们需要的核保申请数据并返回。
然而,在大多数遗留系统中,模块对于数据的所有权边界往往是很模糊的,也就是说,在任何模块中都可以随意访问本属于其他模块的数据。
所以,对于这样的代码,真实情况往往是这样:使用一个连表查询,一次性查出所有想要的数据。
// 核保服务中的代码 - UnderwriteApplicationService.java
public UnderwriteApplicationDto getUnderwriteApplication(long policyId) {
UnderwriteApplicationDao underwriteApplicationDao = new UnderwriteApplicationDao();
UnderwriteApplicationModel underwriteApplicationModel = underwriteApplicationDao.getUnderwriteApplication(policyId);
return getUnderwriteApplicationDto(underwriteApplicationModel);
}
// 核保服务中的代码 - UnderwriteApplicationDao.java
public UnderwriteApplicationModel getUnderwriteApplication(long policyId) {
String sql = "SELECT * FROM TBL_UNDERWRITE_APPLICATION UC, TBL_POLICY P WHERE UC.POLICY_ID = P.ID AND P.ID = :policyId";
return executeSqlAndConvertToUnderwriteApplicationModel(sql, policyId);
}
注意,忽略了具体的执行 SQL 语句的框架,它可能直接使用了 JDBC,也可能使用了 MyBatis,这并不是重点。
重点是如何拆分这样一个 SQL 语句。在这条 SQL 语句中,TBL_UNDERWRITE_APPLICATION 是指核保申请所对应的表,显然它应该属于核保数据库,而 TBL_POLICY 是保单所对应的表,应该留在单体库中。
怎么拆分呢?其实要拆分这样的连表查询并不难,方案和处理代码依赖问题采用的方法类似,也是通过 API 的方式
来替换。
具体来说仍然是三步走,即调自己、调服务、组数据。最终的代码也几乎与解决代码依赖的方案一样,这里就不重复说了。
用 API 调用取代连表查询和上节课的用 API 调用取代代码依赖,都用到了两种前面学到的模式,分别是改造老城区的变更数据所有权模式,和建设新城区中的数据库包装服务模式。
在核保服务和单体服务的分权之争中,开发人员作为裁判做了这样一件事儿:把保单数据的访问功能从核保服务那里剥离出来,核保服务不再拥有这部分数据的所有权,而是转给单体服务。
当然了,在现阶段拆分出来的数据,会暂且放在统一的数据 API 中,至于到底划到单体中哪个模块,不是现阶段应该关注的。这里为了变更数据所有权,我们采用把数据库封装为 API 的方式。
获取保单数据的 API 仅仅是个提供数据库数据的接口,不包含任何业务逻辑。到这里,简单的数据解耦我们就能轻松应对了。
遗留系统的代码千奇百怪,各种数据库表的连表查询更是百怪千奇,接下来来迎战更高难的复杂查询。
二、为复杂查询建立单独的数据库
下面这张图是从一个真实的遗留系统中提取出来的 SQL 语句,对具体的内容做了模糊处理,但重要的是了解它的长度和复杂度。
这种复杂查询在遗留系统中屡见不鲜,甚至在有的项目中遇到过需要十几张 A4 纸才能完整打印的 SQL 语句。
如果非要硬来,对上面这种复杂 SQL 也用 API 调用来替换,那成本和风险都是相当高的,而且有些甚至是做不到的。
比如有些查询会把分页逻辑也写在 SQL 语句里,但如果 WHERE 条件中包含单体服务中的表,把这个依赖转换成 API 查询,然后在内存中再去过滤,分页就会乱掉。
那么对于类似这样的复杂查询应该如何处理呢?可以针对这样的查询,提取一个单独的查询数据库出来。
新建一个独立的数据库,把核保和单体数据库中的数据都同步到这个数据库中,核保服务可以直接连接到该库中进行查询操作。
由于该库包含了查询所需的全部表,因此 SQL 语句甚至不需要做任何修改,只需要区分查询和修改数据库的连接地址就可以了。
这样在一定程度上还实现了读写分离。在未来剥离单体服务中对于核保表的依赖时,也可以让单体服务访问查询数据库。以后再从单体中继续剥离其他服务时,也同样可以复用这个库,可谓一举多得。
多个微服务共享一个数据库,这不是典型的反模式吗?注意这里共享的并不是业务数据,而是查询数据,是只读的。它并不需要独立演进。
比如如果单体库独立进行了修改,并且不需要让核保服务知道,查询库就不需要跟着修改,反之亦然。
如果单体库的修改需要通知核保服务做出相应的修改,可能就需要同时修改查询库,并且重新部署,这也是很正常的情况,因为反正都需要重新部署核保服务。
最重要的是,通过新的、单独的查询数据库,极大地降低了认知负载,再也不需要去修改复杂的 SQL 语句了,这其实就是报表数据库模式。
三、冗余数据
除了单独的查询数据库,还有一种方式是将复杂查询所用到的数据,都冗余到核保库中。
这样,复杂 SQL 仍然不需要做修改,改造的成本也非常低。
都有哪些数据需要冗余到核保服务中呢?
表面上看,是核保服务中,所有查询 SQL 中所涉及到的表和字段的并集。
不过实际上,应该先把这些表和字段分类,对不同的类别采取不同的处理策略。
1、快照数据的冗余
第一种类型是快照数据
。快照数据是指那些只关心数据当时的状态,而不关心数据后续变化的场景。
可以把这类数据存储(或缓存)在消费端,一方面需要这些数据的时候就不需要远程获取了,提升了性能,另一方面当远程服务宕机的时候,也不至于影响消费端服务。关于这类数据,我们最常见的就是微信头像更新。在点击好友头像的时候会更新一下头像,平时看到的都是他以前用的头像,这个头像就是本地存储的一个快照。
平时聊天和视频,头像是否是最新的我们并不关心,等到点击头像的时候再更新,也不会觉得有啥问题。相反,如果一定要保持头像的实时更新,对每一个手机客户端来说,都是非常严重的资源消耗。遗留系统中其实也存在类似的数据。
比如每个核保申请都有一个核保员对其进行核保。假设某个核保员叫张三,后来改名为李四,我们是希望由他核保的历史核保申请信息上,显示的仍然是张三,还是改名后的李四呢?如果仍然可以显示张三,那么我们就说核保申请上的核保员姓名这个信息,可以存储为快照数据。
按照大多数遗留系统的设计,核保申请表上存储的就是员工表的主键。当员工(核保员也是员工,因此信息存储在员工表里)改名后,连表查出来的员工姓名肯定会发生改变。我们在做服务拆分的时候,可以用 API 调用来取代这个连表查询,从而得到员工的新姓名。但当查询极其复杂,无法用 API 调用来取代的时候,你就可以回过头来想想,某个核保申请上的核保员姓名,是否必须是最新的,是否可以仅存一份快照数据而没必要更新。
冗余数据放在哪?有两种处理方法。
第一种方法是冗余到主表上。比如核保申请表上本来存储的是员工主键,现在可以增加一列姓名。在创建核保申请时,同时插入员工的主键和姓名。在查询时,不通过员工主键连表查询员工表,而是直接查出姓名。采取这种方法时,需要对原有 SQL 进行少量修改。
第二种方式是冗余到新表中。有时候要冗余的某张表的字段会很多,比如除了员工姓名,可能还需要员工号、员工级别、员工所在分公司等。除此之外,还包括申请核保的员工信息、保单的投保人信息、被保人信息等等。如果这些字段都冗余到主表,就会喧宾夺主,关注点不聚焦。因此我们可以把这些字段分别冗余到不同的新表中。
值得注意的是,新表的名称应该根据业务去命名,而不能跟原表的名称一样。
像申请人、核保员都来自原来的员工表,没有办法在核保库中建一个员工表,来解决这两份数据的快照。
因此要建一张核保申请人表,一张核保申请核保员表,分别来存储申请人和核保员的快照数据。
在快照表中,不再像原表那样以员工 ID 为主键,而应该以核保表的主键为主键,将原来的一对多关系改为一对一。
采取这种方法时,原有 SQL 的改动就更少了,可能只需要改一下表名。
2、业务数据的冗余
第二种需要冗余的数据是业务数据
。业务数据与快照数据不同,它的变化会影响到当前业务,因此不能按快照进行冗余。
比如被保人的年龄在申请的时候填错了,进行了修改,如果还是以快照数据来存储,在核保服务中就无法得到新的年龄,就会得出不同的核保结论。
因此对于冗余到核保库中的业务数据,必须进行同步,以得到最新值。在实际项目中,必须与业务人员一起讨论哪些数据是快照数据,哪些数据是业务数据。
这一点开发人员是决定不了的,必须由业务人员来决定,但开发人员可以给出一些建议。比如业务人员在得知要将核保员姓名改成快照的时候,他们和开发人员之间很可能会发生这样的对话:
业务人员:系统使用了很多年,都是随时可以查出最新的核保员姓名,这么改不符合业务(注意这里可能并不是真正的业务,而是软件系统培养出来的操作习惯,开发人员要善于捕捉这一点)。
开发人员:那我们想想在纸质办公时代,核保员就是在核保申请上签个字或盖个章,证明是他操作的核保申请。这个签章其实就是快照,他当时叫什么名字,签章就是什么名字,以后改名了当时的签章也不会变了,不是吗?业务人员若有所思:……开发人员趁热打铁:所以快照才是真正符合业务的,对吗?
业务人员:纸质时代签名盖章很可能是无奈之举,引入软件系统不就是为了带来这些便利吗?而且像报表之类的功能,比如按核保员名称去查询他所核保的所有核保申请,如果改名了就查不出来了,会非常不方便。
开发人员:您说的很有道理,报表这里我们会特殊处理以满足功能,但是在当前这个复杂查询的场景里,是否可以只查出当时的快照呢?
业务人员:可以吧……这当然是一个假想的偏理想化的对话情景,真实的情况下要想说服业务人员可能相当困难,因为看问题的视角不同。但也是非常值得去尝试的,因为一旦存储成快照数据,的确会给服务拆分带来极大的便利,显著地降低认知负载。
注意,以上两种类型的数据,都只需要冗余我们用到的字段,不需要的字段就没必要冗余了。
在第一次创建主表数据(即核保申请)的时候,可以连带着把所要冗余的快照数据和业务数据,一起插入到核保库的相应表中。
当业务数据发生变化的时候,再通过同步机制将其同步到核保库中。
3、参考数据的冗余
第三种需要冗余的数据是参考数据
,即 Reference Data。
参考数据是指那些对其他数据进行分类的数据,如国家的名称和缩写(如 CHN、USA 等)、机场的三字码(如 PEK、DAX 等)、货币代码(如 CNY、USD 等)。这些数据都是静态不变,或变化很慢的数据,因此也有人称之为静态数据。
在单体的遗留系统中,这类参考数据往往存储在单体的数据库或配置文件中,只有单体服务可以访问到。
在从单体向外拆分微服务的时候,这类参考数据也是需要冗余的,否则拆分出来的服务就无法使用了。
不管参考数据存储在数据库还是配置文件中,都可以直接在新服务中也创建一份,直接使用即可。这是认知负载最低的解决方案。
如果由于某种原因导致参考数据发生了变化,直接在两个库或配置文件中都进行修改就可以了,毕竟这种情况发生的概率很低,没有必要在它们之间建立同步机制。
当然更理想的方式是将参考数据加载到缓存中,供多个服务使用,这样就没有必要冗余了。
如果遗留系统原来就是这么做的,当然可以直接复用。但如果不是,就没有必要非按照理想的方式来,毕竟这需要额外的工作量。
在遗留系统现代化的过程中,要切忌这种发散式的思维方式。
本来目标是服务拆分,结果却引入了参考数据治理这个新的任务,导致工作不聚焦,工作范围扩大。
四、总结
一张表格,对比了拆分查询 SQL 的三种方案,以及上面没有提到的一些优势和不足。
结合自己的实际需求,对照表格选择更合理的方案。
解耦数据库表的方法都是基于查询的 SQL,对于 INSERT、UPDATE 和 DELETE 的 SQL,也可以用 API 调用来取代。