0%

设计原则讨论123

本设计原则,基于dubbo.io页面上整理而来。原文地址:http://dubbo.io/books/dubbo-dev-book/principals/introduction.html

1.代码基本要求

1.1 避免NPE和越界异常

在java中,虽然可能出现各种异常,甚至我们使用异常来驱动非法业务逻辑执行流程,但是如果在执行过程中出现 空指针异常和越界异常,是非常不允许的。这说明代码不够健壮,开发同学意识不够严谨。推荐的做法,对数据进行判断,打印详细的错误信息,以及可能的告知上次参数不合法等。

1.2 尽早失败FailFast

系统设计中,有一个尽早失败说法,表示,比如我们请求失败了,不进行重试,直接返回错误。代码中,也存在尽早失败的原则。比如,一个不合法的参数值或者记录状态,在请求的时候,应该进行参数检查,然后报错。在,开发过程中,有些同学,喜欢在使用参数的时候,才进行判断和错误处理,这样会导致耗费不必要的执行时间,甚至导致需要回滚之前的操作。

一般情况下,大家基本上会进行所有参数的前置校验;但是存在,一个请求,需要查询数据库判断是否状态合法时,可能有的时候,为了避免查表耗时,从而到用的时候才进行校验的情况。在正常业务下,为了这一点的RT提升,而可能让后续逻辑变得复杂,不太推荐。

1.3 异常防御

所谓,异常防御,是说我们的业务代码中,肯定有一些操作并不影响主流程流转。因此,我们需要对非核心逻辑的错误进行封装和容忍。

比如,银行卡签约支付,其中在支付过程中输入六要素完成预绑卡后,会调用商户平台绑卡接口,进行落绑卡记录数据。但是可能因为网络或者系统原因,并不会绑卡成功,这个并不会接下来的支付确认扣款操作,因此,我们会进行异常防御,捕获异常打印日志。

1.4 降低代码误解率

代码误解,就是整个项目的代码,在某些时候可能会让开发理解出现分歧,开发包括别人和自己。

比如,一个方法返回null表示执行过程出现错误,返回””表示业务正常。这之后,如果我们忘记了这个约定,就会导致任何时候可能我们都以为出错了,或者业务都正常运行的。

又或者list为null表示当前不进行切流,size为0表示所有流量都切过去。这个再后面的时候,可能因为什么原因,需要切流回滚,而自己忘了这个约定,直接设置[],导致都切流了。

因此,一个原则就是永远不要区分 null 引用和 empty 值。

1.5 提供代码可测性

所谓,可测性,就是我们可以很好的写单元测试的MOCK部分。可 Mock 性是隔离的基础,把间接依赖的逻辑屏蔽掉。

基本上,大家的代码,基于spring的框架,mock还是没啥问题的。目前, Mock 性的一个最大的杀手就是静态方法。因此,除了工具类,其他尽量少写静态代码。

1.6 查询修改分隔

参与修改的方法和只包含查询的方法,尽量区分开。

修改查询放在一起,会导致方法含义不清楚,其次可能会导致用错。比如,queryXXX(YYYDTO dto)方法,返回一个对象,但是其内部会对dto进行修改,这个时候,如果其他人不清楚内部实现,而直接用这个方法,从而不小心修改了不该被改的数据,最后数据莫名其妙出现变更,是非常不幸的。

2.设计基本常识

2.1 API和SPI分离

API (Application Programming Interface) 是给使用者用的,而 SPI (Service Provide Interface) 是给扩展者用的。API和SPI分离,就是说,两者不会混合在一起,做到使用者不要看到SPI的实现。

比如,作为一个支付使用者,只需要知道支付API怎么调用即可,在其API调用实现的地方,不应该掺杂 SPI的扩展实现逻辑在里面,比如卡券支付扩展了支付SPI的具体实现逻辑。

2.2 扩展接口职责单一

我们提供的一些接口,允许其他开发扩展的时候,尽量让接口功能单一,然后让使用者通过组合多个接口来完成一个大功能的实现。

对于RPC来说,dubbo提供了各个步骤的SPI接口给扩展者使用。包括:底层通讯,序列化,动态代理方式,编码,服务注册,负载均衡等等,这样比如,我们使用nova来实现服务注册发现,只需要修改registry部分就好。

2.3 对象生命周期控制

方法功能的设计,需要考虑清楚参数对象的生命周期管理。

比如,之前有个方法,要求传入dbConn数据库连接,然后进行操作数据库,操作完成之后,执行dbConn.close操作。嗯,这样子不会导致内存泄漏,但是,在后面另一个同学开发,在方法外面继续使用dbConn,然后发现出现问题(事实上,这里进行了简化。问题远比这个复杂,导致很久之后才发现问题,并且发现之后很久才定位出来)。

因此,有个宗旨,如果对象不是自己申请的,那么关闭释放的操作,也应该由申请方来完成。

2.4 可配置一定可编程

所谓,可配置一定可编程,就是说我们在开发组件的时候,为了方便使用,会采取配置的方式提供出去。但是,作为一个通用的组件,应该做到能配置文件完成功能的地方,那么也应该提供接口,方便其他框架组件和你的组件进行集成。

比如,支付网关业务方接入,需要引入一个基于spring的dubbo reference 配置,如果业务方不是spring/spring boot 框架呢,就不可用了。即使他使用spring框架,也有可能其无法通过配置来调用,如果提供一个代码或者按照约定配置(enable)亦可。

因此,我们使用配置是为了方便用户使用接入,而不是限制其使用场景。

2.5 扩充式扩展与增量式扩展

业务需求的不断增长变化,会导致之前的接口设计,需要进行功能增加和逻辑上的变更。这个时候,就出现两种扩展:扩充式扩展 和 增量式扩展。

扩充式扩展,指的是说,我们在老的接口上,增加一些判断和代码的调整,从而做到对于新老功能都能适用的一个通用实现。

增量式扩展,指的是说,放弃老的接口,新的功能需求独立实现,不调整老的接口功能。

一般情况下,我们习惯适用扩充式的扩展方式。因为,其保持接口数量尽量少,并且,修改也比较直接快速。比如,有个接口,之前没有描述字段,现在需要增加自定义描述,则直接在接口上加个字段,然后一步步透传,老接口默认为空就好了。

但是,有些情况,使用增量式扩展,会更好很多。

比如,dubbo的泛化调用。在泛化之前,接口是 invoke(Method method, Object[] args),因为泛化没有方法和类,所以直觉上直接改成 invoke(String methodName, String[] parameterTypes, String returnTypes, Object[] args) 就可行。但是,后面会发现,为了支持泛化,要求基于jar调用都需要parameterTypes先将class转成string,得不偿失。因此,后面dubbo 针对泛化新增了逻辑,而不是修改老的基于jar的代码。

2.6 避免不必要的复杂

帕累托原理,也就是所谓的二八法则。也就是说,我们拿出20%的努力去服务80%设计即可。因此,不要把我们的设计,搞得需要满足所有人所有需求,这样子,可能需要花费80%的时间耗在20%的设计上,或者根本就无法产出这样子的设计。

比如,之前的签约并发问题。业务方退款非常低,此外,基本上不存在退款完之后又订购的情况,更不用说,退订完立马订购的场景了。这个时候,就不一定非要去给自己找问题了(当然,这种态度是好的)。

因此,在把系统方案设计得尽量通用的前提下,避免钻进繁杂的20%黑洞里。此外,我们可以让业务逻辑调整一下来避免有些问题。

所以,在系统设计里面,尤其是分布式系统中,解决一个问题最好的办法,就是想办法绕过它。

2.7 不可避免的兼容

经常会遇到接口升级,这个时候,就需要考虑历史老接口的逻辑和数据的兼容问题。

服务方接口的升级,业务方并不会一定同步配合升级。这个时候老的业务和新的业务数据,能否兼容,是需要考虑的。

比如,后期新老认证迁移的时候,需要考虑两边的数据同步备份问题。在保证数据增量入口切到新的认证接口之后,还需要保证所有查询的结果数据,是一致的完整的。

3.系统的健壮性

3.1 日志

日志,是发现问题,定位问题最好的最常用的一种方式。

3.1.1 日志级别

关于日志级别的约束:

  • WARN 表示可以恢复的问题,无需人工介入。
  • ERROR 表示需要人工介入问题。

有了这样的约定,监管系统发现日志文件的中出现 ERROR 字串就报警,又尽量减少了发生。过多的报警会让人疲倦,使人对报警失去警惕性,使 ERROR 日志失去意义。再辅以人工定期查看 WARN 级别信息,以评估系统的“亚健康”程度

3.1.2 日志收集

日志中,如果内容信息过多,会导致太杂乱,可能重要的信息就被忽略了,此外,对IO来说,也是一种压力。但是,日志太少了,又不方便查问题。

因此,我们需要定义关键日志信息:

  • 出问题时的现场信息,即排查问题要用到的信息。如服务调用失败时,要给出使用 Dubbo 的版本、服务提供者的 IP、使用的是哪个注册中心;调用的是哪个服务、哪个方法等等。这些信息如果不给出,那么事后人工收集的,问题过后现场可能已经不能复原,加大排查问题的难度。
  • 如果可能,给出问题的原因和解决方法。这让维护和问题解决变得简单,而不是寻求精通者(往往是实现者)的帮助。

这里重点说下,问题原因和解决方法。之前使用HSF框架的时候,其错误出现的时候,都会给出对应的wiki地址,使用者直接跳转到wiki页面,找到出现错误的原因和解决方案,非常方便。

因此,我们也可以借鉴。比如,遇到支付失败,商家配置密钥错误之类的,打日志的时候,出现一个链接地址,值班同学直接根据链接,找到解决方案。

3.2 线程池

java的线程池非常好用,可以解决很多性能问题。但是,对于如何设计队列,以及拒绝策略,是个注意点。

一般,我们使用 LinkedBlockingQueue 来设置线程池阻塞队列,这个时候,需要考虑队列的长度了。如果你使用newFixedThreadPool,其内部没有设置queue的长度,可能会导致内存耗尽服务宕机。因此,一定要设置阻塞队列Queue的大小。

使用LinkedBlockingQueue还有一个可能的问题,就是服务超时恶化。当前面执行的任务时间较长,导致后面的服务都阻塞在队列中,而等到这些任务执行的时候,可能调用方认为服务已经超时了,然后重新调用,这个时候,老的任务继续执行,新的任务在阻塞队列等待,从而导致服务质量恶化。

因此,在HSF的实现中,没有采用LinkedBlockingQueue,而是使用SynchronousQueue,也就是没有等待的任务。线程不够的时候,就看拒绝策略了。

默认拒绝策略是AbortPolicy,也就是任务没有线程执行,队列满了之后,则执行丢弃操作。

但是,有些实现,我比较喜欢使用CallerRunsPolicy,也就是如果子线程不够的时候,由调用线程来执行。因为有的时候,我们无法对单个线程池进行评估,但是我们会对系统的处理能力进行评估,因此请求后续的任务交给调用线程来做,其实也只是将线程异步转为同步而已,但是某些阶段不会丢弃请求。

使用CallerRunsPolicy有个问题,关于ThreadLocal透传使用,会导致不容易区分是否应该被清除clear。

3.3 容错可靠

如今的分布式应用,经常会依赖各种外部系统。对于一些非核心的依赖,要想办法去容错。

比如,实现一个限流,依赖redis和动态配置config。

我们把限流值配置在动态config上,然后依赖redis去统计qps从而来判断是否需要限流。但是如果config挂了,或者redis挂了呢?

动态config会保证即使server挂了,本地还会有一份快照来保证上一次的配置信息,因此高可靠。

redis呢?如果redis挂了,我们会切到本地单机限流模式,避免限流逻辑被停止不可用。

此外,dubbo的服务发现也是这样,如果zookeeper或者其他数据中心挂了,consumer端内存中还存在一份地址列表,只要不重启,就能一直使用,而不会找不到服务提供者机器。

3.4 重试策略

不好的重试方式,会把下游的系统给搞死。此外,重试要求业务方幂等。

3.4.1 重试次数

要不要重试,重试多少次,都需要好好考虑。

有些服务,其实没必要系统来重试,直接由用户再次点击刷新就可以完成了。

有些服务,保证了最终成功性,可能需要一种重试,直到服务可用。

还有一种,由于网络抖动,可能需要重试1、2次,尽量保证请求都是成功的。

3.4.2 重试时机

决定了重试,就需要考虑审核时候重试。

比如,服务调用3s超时,立马发起第二次重试,

比如,重试连接,由于可能出现雪崩的情况,所以重试的间隔时候,是随机的,避免瞬间请求到了服务方,把系统给打死了。

4.业务异常

5.事件驱动