摘录dubbo服务框架的设计原则,来了解下高可用框架的设计师在担心什么问题以及思想层面的远大差距

作者: admin 分类: Dubbo 发布时间: 2019-07-14 18:43  阅读: 243 views

最近刚看完dubbo的中文版文档,内心OS“我就是个假程序猿无疑”。这NM差距大的不是一点两点,而是十年、二十年。

摘录下,以供瞻仰


魔鬼在细节

最近一直担心如果 Dubbo 分布式服务框架维护人员增多或变更,会出现质量的下降的问题, 我在想,有没有什么规则是需要大家共同遵守的。根据平时写代码时的一习惯,总结了以下在写代码过程中,尤其是框架代码,要时刻牢记的细节。可能下面要讲的这些,大家都会觉得很简单,很基础,但要做到时刻牢记。在每一行代码中都考虑这些因素,是需要很大耐心的, 大家经常说,魔鬼在细节中,确实如此。

防止空指针和下标越界

这是我最不喜欢看到的异常,尤其在核心框架中,我更愿看到信息详细的参数不合法异常。这也是一个编写健壮程序的开发人员,在写每一行代码都应在潜意识中防止的异常。基本上要能确保每一次写完的代码,在不测试的情况下,都不会出现这两个异常才算合格。

保证线程安全性和可见性

对于框架的开发人员,对线程安全性和可见性的深入理解是最基本的要求。需要开发人员,在写每一行代码时都应在潜意识中确保其正确性。因为这种代码,在小并发下做功能测试时,会显得很正常。但在高并发下就会出现莫明其妙的问题,而且场景很难重现,极难排查。

尽早失败和前置断言

尽早失败也应该成为潜意识,在有传入参数和状态变化时,均在入口处全部断言。一个不合法的值和状态,在第一时间就应报错,而不是等到要用时才报错。因为等到要用时,可能前面已经修改其它相关状态,而在程序中很少有人去处理回滚逻辑。这样报错后,其实内部状态可能已经混乱,极易在一个隐蔽分支上引发程序不可恢复。

分离可靠操作和不可靠操作

这里的可靠是狭义的指是否会抛出异常或引起状态不一致,比如,写入一个线程安全的 Map,可以认为是可靠的,而写入数据库等,可以认为是不可靠的。开发人员必须在写每一行代码时,都注意它的可靠性与否,在代码中尽量划分开,并对失败做异常处理,并为容错,自我保护,自动恢复或切换等补偿逻辑提供清晰的切入点,保证后续增加的代码不至于放错位置,而导致原先的容错处理陷入混乱。

异常防御,但不忽略异常

这里讲的异常防御,指的是对非必须途径上的代码进行最大限度的容忍,包括程序上的 BUG,比如:获取程序的版本号,会通过扫描 Manifest 和 jar 包名称抓取版本号,这个逻辑是辅助性的,但代码却不少,初步测试也没啥问题,但应该在整个 getVersion() 中加上一个全函数的 try-catch 打印错误日志,并返回基本版本,因为 getVersion() 可能存在未知特定场景异常,或被其他的开发人员误修改逻辑(但一般人员不会去掉 try-catch),而如果它抛出异常会导致主流程异常,这是我们不希望看到的。但这里要控制个度,不要随意 try-catch,更不要无声无息的吃掉异常。

缩小可变域和尽量 final

如果一个类可以成为不变类(Immutable Class),就优先将它设计成不变类。不变类有天然的并发共享优势,减少同步或复制,而且可以有效帮忙分析线程安全的范围。就算是可变类,对于从构造函数传入的引用,在类中持有时,最好将字段 final,以免被中途误修改引用。不要以为这个字段是私有的,这个类的代码都是我自己写的,不会出现对这个字段的重新赋值。要考虑的一个因素是,这个代码可能被其他人修改,他不知道你的这个弱约定,final 就是一个不变契约。

降低修改时的误解性,不埋雷

前面不停的提到代码被其他人修改,这也开发人员要随时紧记的。这个其他人包括未来的自己,你要总想着这个代码可能会有人去改它。我应该给修改的人一点什么提示,让他知道我现在的设计意图,而不要在程序里面加潜规则,或埋一些容易忽视的雷,比如:你用 null 表示不可用,size 等于 0 表示黑名单,这就是一个雷,下一个修改者,包括你自己,都不会记得有这样的约定,可能后面为了改某个其它 BUG,不小心改到了这里,直接引爆故障。对于这个例子,一个原则就是永远不要区分 null 引用和 empty 值。

提高代码的可测性

这里的可测性主要指 Mock 的容易程度,和测试的隔离性。至于测试的自动性,可重复性,非偶然性,无序性,完备性(全覆盖),轻量性(可快速执行),一般开发人员,加上 JUnit 等工具的辅助基本都能做到,也能理解它的好处,只是工作量问题。这里要特别强调的是测试用例的单一性(只测目标类本身)和隔离性(不传染失败)。现在的测试代码,过于强调完备性,大量重复交叉测试,看起来没啥坏处,但测试代码越多,维护代价越高。经常出现的问题是,修改一行代码或加一个判断条件,引起 100 多个测试用例不通过。时间一紧,谁有这个闲功夫去改这么多形态各异的测试用例?久而久之,这个测试代码就已经不能真实反应代码现在的状况,很多时候会被迫绕过。最好的情况是,修改一行代码,有且只有一行测试代码不通过。如果修改了代码而测试用例还能通过,那也不行,表示测试没有覆盖到。另外,可 Mock 性是隔离的基础,把间接依赖的逻辑屏蔽掉。可 Mock 性的一个最大的杀手就是静态方法,尽量少用。

一些设计上的基本常识

API 与 SPI 分离

框架或组件通常有两类客户,一个是使用者,一个是扩展者。API (Application Programming Interface) 是给使用者用的,而 SPI (Service Provide Interface) 是给扩展者用的。在设计时,尽量把它们隔离开,而不要混在一起。也就是说,使用者是看不到扩展者写的实现的。

比如:一个 Web 框架,它有一个 API 接口叫 Action,里面有个 execute() 方法,是给使用者用来写业务逻辑的。然后,Web 框架有一个 SPI 接口给扩展者控制输出方式,比如用 velocity 模板输出还是用 json 输出等。如果这个 Web 框架使用一个都继承 Action 的 VelocityAction 和一个 JsonAction 做为扩展方式,要用 velocity 模板输出的就继承 VelocityAction,要用 json 输出的就继承 JsonAction,这就是 API 和 SPI 没有分离的反面例子,SPI 接口混在了 API 接口中。

合理的方式是,有一个单独的 Renderer 接口,有 VelocityRenderer 和 JsonRenderer 实现,Web 框架将 Action 的输出转交给 Renderer 接口做渲染输出。

服务域/实体域/会话域分离

任何框架或组件,总会有核心领域模型,比如:Spring 的 Bean,Struts 的 Action,Dubbo 的 Service,Napoli 的 Queue 等等。这个核心领域模型及其组成部分称为实体域,它代表着我们要操作的目标本身。实体域通常是线程安全的,不管是通过不变类,同步状态,或复制的方式。

服务域也就是行为域,它是组件的功能集,同时也负责实体域和会话域的生命周期管理, 比如 Spring 的 ApplicationContext,Dubbo 的 ServiceManager 等。服务域的对象通常会比较重,而且是线程安全的,并以单一实例服务于所有调用。

什么是会话?就是一次交互过程。会话中重要的概念是上下文,什么是上下文?比如我们说:“老地方见”,这里的“老地方”就是上下文信息。为什么说“老地方”对方会知道,因为我们前面定义了“老地方”的具体内容。所以说,上下文通常持有交互过程中的状态变量等。会话对象通常较轻,每次请求都重新创建实例,请求结束后销毁。简而言之:把元信息交由实体域持有,把一次请求中的临时状态由会话域持有,由服务域贯穿整个过程。

在重要的过程上设置拦截接口

如果你要写个远程调用框架,那远程调用的过程应该有一个统一的拦截接口。如果你要写一个 ORM 框架,那至少 SQL 的执行过程,Mapping 过程要有拦截接口;如果你要写一个 Web 框架,那请求的执行过程应该要有拦截接口,等等。没有哪个公用的框架可以 Cover 住所有需求,允许外置行为,是框架的基本扩展方式。这样,如果有人想在远程调用前,验证下令牌,验证下黑白名单,统计下日志;如果有人想在 SQL 执行前加下分页包装,做下数据权限控制,统计下 SQL 执行时间;如果有人想在请求执行前检查下角色,包装下输入输出流,统计下请求量,等等,就可以自行完成,而不用侵入框架内部。拦截接口,通常是把过程本身用一个对象封装起来,传给拦截器链,比如:远程调用主过程为 invoke(),那拦截器接口通常为 invoke(Invocation),Invocation 对象封装了本来要执行过程的上下文,并且 Invocation 里有一个 invoke() 方法,由拦截器决定什么时候执行,同时,Invocation 也代表拦截器行为本身,这样上一拦截器的 Invocation 其实是包装的下一拦截器的过程,直到最后一个拦截器的 Invocation 是包装的最终的 invoke() 过程;同理,SQL 主过程为 execute(),那拦截器接口通常为 execute(Execution),原理一样。当然,实现方式可以任意,上面只是举例。

重要的状态的变更发送事件并留出监听接口

这里先要讲一个事件和上面拦截器的区别,拦截器是干预过程的,它是过程的一部分,是基于过程行为的,而事件是基于状态数据的,任何行为改变的相同状态,对事件应该是一致的。事件通常是事后通知,是一个 Callback 接口,方法名通常是过去式的,比如 onChanged()。比如远程调用框架,当网络断开或连上应该发出一个事件,当出现错误也可以考虑发出一个事件,这样外围应用就有可能观察到框架内部的变化,做相应适应。

扩展接口职责尽可能单一,具有可组合性

比如,远程调用框架它的协议是可以替换的。如果只提供一个总的扩展接口,当然可以做到切换协议,但协议支持是可以细分为底层通讯,序列化,动态代理方式等等。如果将接口拆细,正交分解,会更便于扩展者复用已有逻辑,而只是替换某部分实现策略。当然这个分解的粒度需要把握好。

微核插件式,平等对待第三方

大凡发展的比较好的框架,都遵守微核的理念。Eclipse 的微核是 OSGi, Spring 的微核是 BeanFactory,Maven 的微核是 Plexus。通常核心是不应该带有功能性的,而是一个生命周期和集成容器,这样各功能可以通过相同的方式交互及扩展,并且任何功能都可以被替换。如果做不到微核,至少要平等对待第三方,即原作者能实现的功能,扩展者应该可以通过扩展的方式全部做到。原作者要把自己也当作扩展者,这样才能保证框架的可持续性及由内向外的稳定性。

不要控制外部对象的生命周期

比如上面说的 Action 使用接口和 Renderer 扩展接口。框架如果让使用者或扩展者把 Action 或 Renderer 实现类的类名或类元信息报上来,然后在内部通过反射 newInstance() 创建一个实例,这样框架就控制了 Action 或 Renderer 实现类的生命周期,Action 或 Renderer 的生老病死,框架都自己做了,外部扩展或集成都无能为力。好的办法是让使用者或扩展者把 Action 或 Renderer 实现类的实例报上来,框架只是使用这些实例,这些对象是怎么创建的,怎么销毁的,都和框架无关,框架最多提供工具类辅助管理,而不是绝对控制。

可配置一定可编程,并保持友好的 CoC 约定

因为使用环境的不确定因素很多,框架总会有一些配置,一般都会到 classpath 直扫某个指定名称的配置,或者启动时允许指定配置路径。做为一个通用框架,应该做到凡是能配置文件做的一定要能通过编程方式进行,否则当使用者需要将你的框架与另一个框架集成时就会带来很多不必要的麻烦。

另外,尽可能做一个标准约定,如果用户按某种约定做事时,就不需要该配置项。比如:配置模板位置,你可以约定,如果放在 templates 目录下就不用配了,如果你想换个目录,就配置下。

区分命令与查询,明确前置条件与后置条件

这个是契约式设计的一部分,尽量遵守有返回值的方法是查询方法,void 返回的方法是命令。查询方法通常是幂等性的,无副作用的,也就是不改变任何状态,调 n 次结果都是一样的,比如 get 某个属性值,或查询一条数据库记录。命令是指有副作用的,也就是会修改状态,比如 set 某个值,或 update 某条数据库记录。如果你的方法即做了修改状态的操作,又做了查询返回,如果可能,将其拆成写读分离的两个方法,比如:User deleteUser(id),删除用户并返回被删除的用户,考虑改为 getUser() 和 void 的 deleteUser()。 另外,每个方法都尽量前置断言传入参数的合法性,后置断言返回结果的合法性,并文档化。

谈谈扩充式扩展与增量式扩展

我们平台的产品越来越多,产品的功能也越来越多。平台的产品为了适应各 BU 和部门以及产品线的需求,势必会将很多不相干的功能凑在一起,客户可以选择性的使用。为了兼容更多的需求,每个产品,每个框架,都在不停的扩展,而我们经常会选择一些扩展的扩展方式,也就是将新旧功能扩展成一个通用实现。我想讨论是,有些情况下也可以考虑增量式的扩展方式,也就是保留原功能的简单性,新功能独立实现。我最近一直做分布式服务框架的开发,就拿我们项目中的问题开涮吧。

比如:远程调用框架,肯定少不了序列化功能,功能很简单,就是把流转成对象,对象转成流。但因有些地方可能会使用 osgi,这样序列化时,IO 所在的 ClassLoader 可能和业务方的 ClassLoader 是隔离的。需要将流转换成 byte[] 数组,然后传给业务方的 ClassLoader 进行序列化。为了适应 osgi 需求,把原来非 osgi 与 osgi 的场景扩展了一下,这样,不管是不是 osgi 环境,都先将流转成 byte[] 数组,拷贝一次。然而,大部分场景都用不上 osgi,却为 osgi 付出了代价。而如果采用增量式扩展方式,非 osgi 的代码原封不动,再加一个 osgi 的实现,要用 osgi 的时候,直接依赖 osgi 实现即可。

再比如:最开始,远程服务都是基于接口方法,进行透明化调用的。这样,扩展接口就是, invoke(Method method, Object[] args),后来,有了无接口调用的需求,就是没有接口方法也能调用,并将 POJO 对象都转换成 Map 表示。因为 Method 对象是不能直接 new 出来的,我们不自觉选了一个扩展式扩展,把扩展接口改成了 invoke(String methodName, String[] parameterTypes, String returnTypes, Object[] args),导致不管是不是无接口调用,都得把 parameterTypes 从 Class[] 转成 String[]。如果选用增量式扩展,应该是保持原有接口不变,增加一个 GeneralService 接口,里面有一个通用的 invoke() 方法,和其它正常业务上的接口一样的调用方式,扩展接口也不用变,只是 GeneralServiceImpl 的 invoke() 实现会将收到的调用转给目标接口,这样就能将新功能增量到旧功能上,并保持原来结构的简单性。

再再比如:无状态消息发送,很简单,序列化一个对象发过去就行。后来有了同步消息发送需求,需要一个 Request/Response 进行配对,采用扩展式扩展,自然想到,无状态消息其实是一个没有 Response 的 Request,所以在 Request 里加一个 boolean 状态,表示要不要返回 Response。如果再来一个会话消息发送需求,那就再加一个 Session 交互,然后发现,原来同步消息发送是会话消息的一种特殊情况,所有场景都传 Session,不需要 Session 的地方无视即可。

如果采用增量式扩展,无状态消息发送原封不动,同步消息发送,在无状态消息基础上加一个 Request/Response 处理,会话消息发送,再加一个 SessionRequest/SessionResponse 处理。

 

配置设计

Dubbo 现在的设计是完全无侵入,也就是使用者只依赖于配置契约。经过多个版本的发展,为了满足各种需求场景,配置越来越多。为了保持兼容,配置只增不减,里面潜伏着各种风格,约定,规则。新版本也将配置做了一次调整,将想到的一些记在这,备忘。

配置分类

首先,配置的用途是有多种的,大致可以分为:

环境配置,比如:连接数,超时等配置。
描述配置,比如:服务接口描述,服务版本等。
扩展配置,比如:协议扩展,策略扩展等。

配置格式
通常环境配置,用 properties 配置会比较方便,因为都是一些离散的简单值,用 key-value 配置可以减少配置的学习成本。

而描述配置,通常信息比较多,甚至有层次关系,用 xml 配置会比较方便,因为树结构的配置表现力更强。如果非常复杂,也可以考自定义 DSL 做为配置。有时候这类配置也可以用 Annotation 代替, 因为这些配置和业务逻辑相关,放在代码里也是合理的。

另外扩展配置,可能不尽相同。如果只是策略接口实现类替换,可以考虑 properties 等结构。如果有复杂的生命周期管理,可能需要 XML 等配置。有时候扩展会通过注册接口的方式提供。

配置加载

对于环境配置,在 java 世界里,比较常规的做法,是在 classpath 下约定一个以项目为名称的 properties 配置,比如:log4j.properties,velocity.properties等。产品在初始化时,自动从 classpath 下加载该配置。我们平台的很多项目也使用类似策略,如:dubbo.properties,comsat.xml 等。这样有它的优势,就是基于约定,简化了用户对配置加载过程的干预。但同样有它的缺点,当 classpath 存在同样的配置时,可能误加载,以及在 ClassLoader 隔离时,可能找不到配置,并且,当用户希望将配置放到统一的目录时,不太方便。

而对于描述配置,因为要参与业务逻辑,通常会嵌到应用的生命周期管理中。现在使用 spring 的项目越来越多,直接使用 spring 配置的比较普遍,而且 spring 允许自定义 schema,配置简化后很方便。当然,也有它的缺点,就是强依赖 spring,可以提编程接口做了配套方案。

在 Dubbo 既存在描述配置也有环境配置。一部分用 spring 的 schema 做配置加载,一部分从 classpath 扫描 properties 做配置加载。在新版本中做了一个优先级约定,统一以 spring 的 schema 驱动配置加载,dubbo.properties作为配置补充。

同时,在 Spring 的场景下,除了使用 schema 外,还支持完全以 application.properties 的方式配置:

# Dubbo Application
## The default value of dubbo.application.name is ${spring.application.name}
## dubbo.application.name=${spring.application.name}

# Dubbo Protocol
dubbo.protocol.name=dubbo
dubbo.protocol.port=12345

## Dubbo Registry
dubbo.registry.address=N/A
扩展配置,通常对配置的聚合要求比较高。因为产品需要发现第三方实现,将其加入产品内部。在 java 世界里,通常是约定在每个 jar 包下放一个指定文件加载,比如:eclipse 的 plugin.xml,struts2 的 struts-plugin.xml 等,这类配置可以考虑 java 标准的服务发现机制,即在 jar 包的 META-INF/services 下放置接口类全名文件,内容为每行一个实现类类名,就像 jdk 中的加密算法扩展,脚本引擎扩展,新的 JDBC 驱动等,都是采用这种方式。

Dubbo 旧版本通过约定在每个 jar 包下,放置名为 dubbo-context.xml 的 spring 配置进行扩展与集成,新版本改成用 jdk 自带的 META-INF/services 方式,去掉过多的 spring 依赖。

可编程配置

配置的可编程性是非常必要的,不管你以何种方式加载配置文件,都应该提供一个编程的配置方式,允许用户不使用配置文件,直接用代码完成配置过程。因为一个产品,尤其是组件类产品,通常需要和其它产品协作使用,当用户集成你的产品时,可能需要适配配置方式。

Dubbo 新版本提供了与 xml 配置一对一的配置类,如:ServiceConfig 对应 ,并且属性也一对一,这样有利于文件配置与编程配置的一致性理解,减少学习成本。

配置缺省值

配置的缺省值,通常是设置一个常规环境的合理值,这样可以减少用户的配置量。通常建议以线上环境为参考值,开发环境可以通过修改配置适应。缺省值的设置,最好在最外层的配置加载就做处理。程序底层如果发现配置不正确,就应该直接报错,容错在最外层做。如果在程序底层使用时,发现配置值不合理,就填一个缺省值,很容易掩盖表面问题,而引发更深层次的问题。并且配置的中间传递层,很可能并不知道底层使用了一个缺省值,一些中间的检测条件就可能失效。Dubbo 就出现过这样的问题,中间层用“地址”做为缓存 Key, 而底层,给“地址”加了一个缺省端口号,导致不加端口号的“地址”和加了缺省端口的“地址”并没有使用相同的缓存。

配置一致性

配置总会隐含一些风格或潜规则,应尽可能保持其一致性。比如:很多功能都有开关,然后有一个配置值:

是否使用注册中心,注册中心地址。
是否允许重试,重试次数。
你可以约定:

每个都是先配置一个 boolean 类型的开关,再配置一个值。
用一个无效值代表关闭,N/A地址,0重试次数等。
不管选哪种方式,所有配置项,都应保持同一风格,Dubbo 选的是第二种。相似的还有,超时时间,重试时间,定时器间隔时间。如果一个单位是秒,另一个单位是毫秒(C3P0的配置项就是这样),配置人员会疯掉。

配置覆盖

提供配置时,要同时考虑开发人员,测试人员,配管人员,系统管理员。测试人员是不能修改代码的,而测试的环境很可能较为复杂,需要为测试人员留一些“后门”,可以在外围修改配置项。就像 spring 的 PropertyPlaceholderConfigurer 配置,支持 SYSTEM_PROPERTIES_MODE_OVERRIDE,可以通过 JVM 的 -D 参数,或者像 hosts 一样约定一个覆盖配置文件,在程序外部,修改部分配置,便于测试。

Dubbo 支持通过 JVM 参数 -Dcom.xxx.XxxService=dubbo://10.1.1.1:1234 直接使远程服务调用绕过注册中心,进行点对点测试。还有一种情况,开发人员增加配置时,都会按线上的部署情况做配置,如: 因为线上只有一个注册中心,这样的配置是没有问题的,而测试环境可能有两个注册中心,测试人员不可能去修改配置,改为: , ,所以这个地方,Dubbo 支持在 ${dubbo.registry.address} 的值中,通过竖号分隔多个注册中心地址,用于表示多注册中心地址。

配置继承

配置也存在“重复代码”,也存在“泛化与精化”的问题。比如:Dubbo 的超时时间设置,每个服务,每个方法,都应该可以设置超时时间。但很多服务不关心超时,如果要求每个方法都配置,是不现实的。所以 Dubbo 采用了方法超时继承服务超时,服务超时再继承缺省超时,没配置时,一层层向上查找。

另外,Dubbo 旧版本所有的超时时间,重试次数,负载均衡策略等都只能在服务消费方配置。但实际使用过程中发现,服务提供方比消费方更清楚,但这些配置项是在消费方执行时才用到的。新版本,就加入了在服务提供方也能配这些参数,通过注册中心传递到消费方, 做为参考值,如果消费方没有配置,就以提供方的配置为准,相当于消费方继承了提供方的建议配置值。而注册中心在传递配置时,也可以在中途修改配置,这样就达到了治理的目的,继承关系相当于:服务消费者 –> 注册中心 –> 服务提供者

配置向后兼容

向前兼容很好办,你只要保证配置只增不减,就基本上能保证向前兼容。但向后兼容,也是要注意的,要为后续加入新的配置项做好准备。如果配置出现一个特殊配置,就应该为这个“特殊”情况约定一个兼容规则,因为这个特殊情况,很有可能在以后还会发生。比如:有一个配置文件是保存“服务=地址”映射关系的,其中有一行特殊,保存的是“注册中心=地址”。现在程序加载时,约定“注册中心”这个Key是特殊的,做特别处理,其它的都是“服务”。然而,新版本发现,要加一项“监控中心=地址”,这时,旧版本的程序会把“监控中心”做为“服务”处理,因为旧代码是不能改的,兼容性就很会很麻烦。如果先前约定“特殊标识+XXX”为特殊处理,后续就会方便很多。

设计实现的健壮性

Dubbo 作为远程服务暴露、调用和治理的解决方案,是应用运转的经络,其本身实现健壮性的重要程度是不言而喻的。

这里列出一些 Dubbo 用到的原则和方法。

日志

日志是发现问题、查看问题一个最常用的手段。日志质量往往被忽视,没有日志使用上的明确约定。重视 Log 的使用,提高 Log 的信息浓度。日志过多、过于混乱,会导致有用的信息被淹没。

要有效利用这个工具要注意:

严格约定WARN、ERROR级别记录的内容

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

日志中,尽量多的收集关键信息

哪些是关键信息呢?

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

同一个或是一类异常日志连续出现几十遍的情况,还是常常能看到的。人眼很容易漏掉淹没在其中不一样的重要日志信息。要尽量避免这种情况。在可以预见会出现的情况,有必要加一些逻辑来避免。

如为一个问题准备一个标志,出问题后打日志后设置标志,避免重复打日志。问题恢复后清除标志。

虽然有点麻烦,但是这样做保证日志信息浓度,让监控更有效。

界限设置

资源是有限的,CPU、内存、IO 等等。不要因为外部的请求、数据不受限的而崩溃。

线程池(ExectorService)的大小和饱和策略

Server 端用于处理请求的 ExectorService 设置上限。ExecutorService 的任务等待队列使用有限队列,避免资源耗尽。当任务等待队列饱和时,选择一个合适的饱和策略。这样保证平滑劣化。

在 Dubbo 中,饱和策略是丢弃数据,等待结果也只是请求的超时。

达到饱和时,说明已经达到服务提供方的负荷上限,要在饱和策略的操作中日志记录这个问题,以发出监控警报。记得注意不要重复多次记录哦。(注意,缺省的饱和策略不会有这些附加的操作。)根据警报的频率,已经决定扩容调整等等,避免系统问题被忽略。

集合容量

如果确保进入集合的元素是可控的且是足够少,则可以放心使用。这是大部分的情况。如果不能保证,则使用有有界的集合。当到达界限时,选择一个合适的丢弃策略。

容错-重试-恢复

高可用组件要容忍其依赖组件的失败。

Dubbo 的服务注册中心

目前服务注册中心使用了数据库来保存服务提供者和消费者的信息。注册中心集群不同注册中心也通过数据库来进行同步数据,以感知其它注册中心上提供者的变化。注册中心会在内存中保存一份提供者和消费者数据,数据库不可用时,注册中心独立对外提供服务以保证正常运转,只是拿不到其它注册中心的数据。当数据库恢复时,重试逻辑会将内存中修改的数据写回数据库,并拿到数据库中新数据。

服务的消费者

服务消费者从注册中心拿到提供者列表后,会保存提供者列表到内存和磁盘文件中。这样注册中心宕机后消费者可以正常运转,甚至可以在注册中心宕机过程中重启消费者。消费者启动时,发现注册中心不可用,会读取保存在磁盘文件中提供者列表。重试逻辑保证注册中心恢复后,更新信息。

重试延迟策略

上一点的子问题。Dubbo 中碰到有两个相关的场景。

数据库上的活锁

注册中心会定时更新数据库一条记录的时间戳,这样集群中其它的注册中心感知它是存活。过期注册中心和它的相关数据 会被清除。数据库正常时,这个机制运行良好。但是数据库负荷高时,其上的每个操作都会很慢。这就出现:

A 注册中心认为 B 过期,删除 B 的数据。 B 发现自己的数据没有了,重新写入自己的数据的反复操作。这些反复的操作又加重了数据库的负荷,恶化问题。

可以使用下面逻辑:

当 B 发现自己数据被删除时(写入失败),选择等待这段时间再重试。重试时间可以选择指数级增长,如第一次等 1 分钟,第二次 10 分钟、第三次 100 分钟。

这样操作减少后,保证数据库可以冷却(Cool Down)下来。

Client 重连注册中心

当一个注册中心停机时,其它的 Client 会同时接收事件,而去重连另一个注册中心。Client 数量相对比较多,会对注册中心造成冲击。避免方法可以是 Client 重连时随机延时 3 分钟,把重连分散开。

防痴呆设计

这块的设计很重要,首先是为了程序开发的有效性、便捷性。通过一些方式处理、解决一些烦人的异常问题。这里以后单独摘出并汇总处理(不断整理汇总)

http://dubbo.apache.org/zh-cn/docs/dev/principals/dummy.html

扩展点重构

这里是技术层面的整理,也很重要,不搬砖了看原址吧!

http://dubbo.apache.org/zh-cn/docs/dev/principals/extension.html


   原创文章,转载请标明本文链接: 摘录dubbo服务框架的设计原则,来了解下高可用框架的设计师在担心什么问题以及思想层面的远大差距

如果觉得我的文章对您有用,请随意打赏。您的支持将鼓励我继续创作!

一条评论
  • 137博客

    2019年7月19日 00:44

    文章写的还可以,我基本上看完了,谢谢分享

发表评论

电子邮件地址不会被公开。 必填项已用*标注

更多阅读