Dubbo作者聊 设计原则

2018-12-12 10:16:26 浏览数 (1)

以下内容均来自 梁飞 的个人博客 http://javatar.iteye.com/blog/1056664

  • 魔鬼在细节
  • 一些设计上的基本常识
  • 谈谈扩充式扩展与增量式扩展
  • 配置设计
  • 设计实现的健壮性
  • 防痴呆设计
  • 扩展点重构

魔鬼在细节中

转于自己在公司的Blog: http://pt.alibaba-inc.com/wp/experience_1301/code-detail.html

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

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

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

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

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

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

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

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

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


一些设计上的基本常识

转于自己在公司的Blog: http://pt.alibaba-inc.com/wp/experience_886/software_design_general_knowledge.html

最近给团队新人讲了一些设计上的常识,可能会对其它的新人也有些帮助, 把暂时想到的几条,先记在这里。

1. 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接口做渲染输出。

image.png

image.png

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

任何框架或组件,总会有核心领域模型,比如: Spring的Bean,Struts的Action,Dubbo的Service,Napoli的Queue等等 这个核心领域模型及其组成部分称为实体域,它代表着我们要操作的目标本身, 实体域通常是线程安全的,不管是通过不变类,同步状态,或复制的方式, 服务域也就是行为域,它是组件的功能集,同时也负责实体域和会话域的生命周期管理, 比如Spring的ApplicationContext,Dubbo的ServiceManager等, 服务域的对象通常会比较重,而且是线程安全的,并以单一实例服务于所有调用, 什么是会话?就是一次交互过程, 会话中重要的概念是上下文,什么是上下文? 比如我们说:“老地方见”,这里的“老地方”就是上下文信息, 为什么说“老地方”对方会知道,因为我们前面定义了“老地方”的具体内容, 所以说,上下文通常持有交互过程中的状态变量等, 会话对象通常较轻,每次请求都重新创建实例,请求结束后销毁。 简而言之: 把元信息交由实体域持有, 把一次请求中的临时状态由会话域持有, 由服务域贯穿整个过程。

image.png

image.png

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

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

image.png

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

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

image.png

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

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

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

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

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

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

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

因为使用环境的不确定因素很多,框架总会有一些配置, 一般都会到classpath直扫某个指定名称的配置,或者启动时允许指定配置路径, 做为一个通用框架,应该做到凡是能配置文件做的一定要能通过编程方式进行, 否则当使用者需要将你的框架与另一个框架集成时就会带来很多不必要的麻烦, 另外,尽可能做一个标准约定,如果用户按某种约定做事时,就不需要该配置项。 比如:配置模板位置,你可以约定,如果放在templates目录下就不用配了, 如果你想换个目录,就配置下。

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

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

10. 增量式扩展,而不要扩充原始核心概念 参见:http://javatar.iteye.com/blog/690845


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

转于自己在公司的Blog: http://pt.alibaba-inc.com/wp/experience_760/generic_vs_composite_expansibility.html

我们平台的产品越来越多,产品的功能也越来越多, 平台的产品为了适应各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处理。

image.png

image.png


配置设计

转于自己在公司的Blog: http://pt.alibaba-inc.com/wp/experience_1182/sofeware-configuration-design.html

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

1. 配置分类

首先,配置的用途是有多种的,大致可以分为: (1) 环境配置,比如:连接数,超时等配置。 (2) 描述配置,比如:服务接口描述,服务版本等。 (3) 扩展配置,比如:协议扩展,策略扩展等。

2. 配置格式

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

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

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

3. 配置加载

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

Dubbo新版本去掉了dubbo.properties,因为该约定经常造成配置冲突。

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

在Dubbo即存在描述配置,也有环境配置, 一部分用spring的schame配置加载,一部分从classpath扫描properties配置加载, 用户感觉非常不便,所以在新版本中进行了合并, 统一放到spring的schame配置加载,也增加了配置的灵活性。

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

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

4. 可编程配置

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

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

5. 配置缺省值

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

6. 配置一致性

配置总会隐含一些风格或潜规则,应尽可能保持其一致性, 比如:很多功能都有开关,然后有一个配置值: (1) 是否使用注册中心,注册中心地址。 (2) 是否允许重试,重试次数。 你可以约定: (1) 每个都是先配置一个boolean类型的开关,再配置一个值。 (2) 用一个无效值代表关闭,N/A地址,0重试次数等。 不管选哪种方式,所有配置项,都应保持同一风格,Dubbo选的是第二种, 相似的还有,超时时间,重试时间,定时器间隔时间, 如果一个单位是秒,另一个单位是毫秒(C3P0的配置项就是这样),配置人员会疯掉。

7. 配置覆盖

提供配置时,要同时考虑开发人员,测试人员,配管人员,系统管理员, 测试人员是不能修改代码的,而测试的环境很可能较为复杂, 需要为测试人员留一些“后门”,可以在外围修改配置项, 就像spring的PropertyPlaceholderConfigurer配置,支持SYSTEM_PROPERTIES_MODE_OVERRIDE, 可以通过JVM的-D参数,或者像hosts一样约定一个覆盖配置文件, 在程序外部,修改部分配置,便于测试。 Dubbo支持通过JVM参数-Dcom.xxx.XxxService=dubbo://10.1.1.1:1234 直接使远程服务调用绕过注册中心,进行点对点测试。 还有一种情况,开发人员增加配置时,都会按线上的部署情况做配置,如: <dubbo:registry address="${dubbo.registry.address}" /> 因为线上只有一个注册中心,这样的配置是没有问题的, 而测试环境可能有两个注册中心,测试人员不可能去修改配置,改为: <dubbo:registry address="${dubbo.registry.address1}" /> <dubbo:registry address="${dubbo.registry.address2}" /> 所以这个地方,Dubbo支持在${dubbo.registry.address}的值中, 通过竖号分隔多个注册中心地址,用于表示多注册中心地址。

8. 配置继承

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

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

image.png

9. 配置向后兼容

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


实现的健壮性

转于自己在公司的Blog:http://pt.alibaba-inc.com/wp/experience_1224/robustness-of-implement.html

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分钟,把重连分散开。


防痴呆设计

转于自己在公司的Blog: http://pt.alibaba-inc.com/wp/experience_1014/design-for-dummy.html

最近有点痴呆,因为解决了太多的痴呆问题, 服务框架实施面超来超广,已有50多个项目在使用, 每天都要去帮应用查问题,来来回回, 发现大部分都是配置错误,或者重复的文件或类,或者网络不通等, 所以准备在新版本中加入防痴呆设计,估且这么叫吧, 可能很简单,但对排错速度还是有点帮助, 希望能抛砖引玉,也希望大家多给力,想出更多的防范措施共享出来。

(1) 检查重复的jar包 最痴呆的问题,就是有多个版本的相同jar包, 会出现新版本的A类,调用了旧版本的B类, 而且和JVM加载顺序有关,问题带有偶然性,误导性, 遇到这种莫名其妙的问题,最头疼, 所以,第一条,先把它防住, 在每个jar包中挑一个一定会加载的类,加上重复类检查, 给个示例:

代码语言:javascript复制
static {  
    Duplicate.checkDuplicate(Xxx.class);  
} 

检查重复工具类:

代码语言:javascript复制
public final class Duplicate {

    private Duplicate() {}

    public static void checkDuplicate(Class cls) {
        checkDuplicate(cls.getName().replace('.', '/')   ".class");
    }

    public static void checkDuplicate(String path) {
        try {
            // 在ClassPath搜文件
            Enumeration urls = Thread.currentThread().getContextClassLoader().getResources(path);
            Set files = new HashSet();
            while (urls.hasMoreElements()) {
                URL url = urls.nextElement();
                if (url != null) {
                    String file = url.getFile();
                    if (file != null &amp;&amp; file.length() &gt; 0) {
                        files.add(file);
                    }
                }
            }
            // 如果有多个,就表示重复
            if (files.size() &gt; 1) {
                logger.error("Duplicate class "   path   " in "   files.size()   " jar "   files);
            }
        } catch (Throwable e) { // 防御性容错
            logger.error(e.getMessage(), e);
        }
    }

}

(2) 检查重复的配置文件 配置文件加载错,也是经常碰到的问题, 用户通常会和你说:“我配置的很正确啊,不信我发给你看下,但就是报错”, 然后查一圈下来,原来他发过来的配置根本没加载, 平台很多产品都会在classpath下放一个约定的配置, 如果项目中有多个,通常会取JVM加载的第一个, 为了不被这么低级的问题折腾, 和上面的重复jar包一样,在配置加载的地方,加上:

代码语言:javascript复制
Duplicate.checkDuplicate("xxx.properties");  

(3) 检查所有可选配置 必填配置估计大家都会检查,因为没有的话,根本没法运行, 但对一些可选参数,也应该做一些检查, 比如:服务框架允许通过注册中心关联服务消费者和服务提供者, 也允许直接配置服务提供者地址点对点直连, 这时候,注册中心地址是可选的, 但如果没有配点对点直连配置,注册中心地址就一定要配, 这时候也要做相应检查。

(4) 异常信息给出解决方案 在给应用排错时,最怕的就是那种只有简单的一句错误描述,啥信息都没有的异常信息, 比如上次碰到一个Failed to get session异常, 就这几个单词,啥都没有,哪个session出错? 什么原因Failed? 看了都快疯掉,因是线上环境不好调试,而且有些场景不是每次都能重现, 异常最基本要带有上下文信息,包括操作者,操作目标,原因等, 最好的异常信息,应给出解决方案,比如上面可以给出: "从10.20.16.3到10.20.130.20:20880之间的网络不通, 请在10.20.16.3使用telnet 10.20.130.20 20880测试一下网络, 如果是跨机房调用,可能是防火墙阻挡,请联系SA开通访问权限" 等等,上面甚至可以根据IP段判断是不是跨机房。 另外一个例子,是spring-web的context加载, 如果在getBean时spring没有被启动, spring会报一个错,错误信息写着: 请在web.xml中加入:<listener>...<init-param>... 多好的同学,看到错误的人复制一下就完事了,我们该学学, 可以把常见的错误故意犯一遍,看看错误信息能否自我搞定问题, 或者把平时支持应用时遇到的问题及解决办法都写到异常信息里。

(5) 日志信息包含环境信息 每次应用一出错,应用的开发或测试就会把出错信息发过来,询问原因, 这时候我都会问一大堆套话, 用的哪个版本呀? 是生产环境还是开发测试环境? 哪个注册中心呀? 哪个项目中的? 哪台机器呀? 哪个服务? 。。。 累啊,最主要的是,有些开发或测试人员根本分不清, 没办法,只好提供上门服务,浪费的时间可不是浮云, 所以,日志中最好把需要的环境信息一并打进去, 最好给日志输出做个包装,统一处理掉,免得忘了。 包装Logger接口如:

代码语言:javascript复制
public void error(String msg, Throwable e) {  
    delegate.error(msg   " on server "   InetAddress.getLocalHost()   " using version "   Version.getVersion(), e);  
}  

获取版本号工具类:

代码语言:javascript复制
public final class Version {

    private Version() {}

    private static final Logger logger = LoggerFactory.getLogger(Version.class);

    private static final Pattern VERSION_PATTERN = Pattern.compile("([0-9][0-9\.\-]*)\.jar");

    private static final String VERSION = getVersion(Version.class, "2.0.0");

    public static String getVersion(){
        return VERSION;
    }

    public static String getVersion(Class cls, String defaultVersion) {
        try {
            // 首先查找MANIFEST.MF规范中的版本号
            String version = cls.getPackage().getImplementationVersion();
            if (version == null || version.length() == 0) {
                version = cls.getPackage().getSpecificationVersion();
            }
            if (version == null || version.length() == 0) {
                // 如果MANIFEST.MF规范中没有版本号,基于jar包名获取版本号
                String file = cls.getProtectionDomain().getCodeSource().getLocation().getFile();
                if (file != null &amp;&amp; file.length() &gt; 0 &amp;&amp; file.endsWith(".jar")) {
                    Matcher matcher = VERSION_PATTERN.matcher(file);
                    while (matcher.find() &amp;&amp; matcher.groupCount() &gt; 0) {
                        version = matcher.group(1);
                    }
                }
            }
            // 返回版本号,如果为空返回缺省版本号
            return version == null || version.length() == 0 ? defaultVersion : version;
        } catch (Throwable e) { // 防御性容错
            // 忽略异常,返回缺省版本号
            logger.error(e.getMessage(), e);
            return defaultVersion;
        }
    }

}

(6) kill之前先dump 每次线上环境一出问题,大家就慌了, 通常最直接的办法回滚重启,以减少故障时间, 这样现场就被破坏了,要想事后查问题就麻烦了, 有些问题必须在线上的大压力下才会发生, 线下测试环境很难重现, 不太可能让开发或Appops在重启前, 先手工将出错现场所有数据备份一下, 所以最好在kill脚本之前调用dump, 进行自动备份,这样就不会有人为疏忽。 dump脚本示例:

代码语言:javascript复制
JAVA_HOME=/usr/java
OUTPUT_HOME=~/output
DEPLOY_HOME=`dirname $0`
HOST_NAME=`hostname`

DUMP_PIDS=`ps  --no-heading -C java -f --width 1000 | grep "$DEPLOY_HOME" |awk '{print $2}'`
if [ -z "$DUMP_PIDS" ]; then
    echo "The server $HOST_NAME is not started!"
    exit 1;
fi

DUMP_ROOT=$OUTPUT_HOME/dump
if [ ! -d $DUMP_ROOT ]; then
    mkdir $DUMP_ROOT
fi

DUMP_DATE=`date  %Y%m%d%H%M%S`
DUMP_DIR=$DUMP_ROOT/dump-$DUMP_DATE
if [ ! -d $DUMP_DIR ]; then
    mkdir $DUMP_DIR
fi

echo -e "Dumping the server $HOST_NAME ...c"
for PID in $DUMP_PIDS ; do
    $JAVA_HOME/bin/jstack $PID > $DUMP_DIR/jstack-$PID.dump 2>&1
    echo -e ".c"
    $JAVA_HOME/bin/jinfo $PID > $DUMP_DIR/jinfo-$PID.dump 2>&1
    echo -e ".c"
    $JAVA_HOME/bin/jstat -gcutil $PID > $DUMP_DIR/jstat-gcutil-$PID.dump 2>&1
    echo -e ".c"
    $JAVA_HOME/bin/jstat -gccapacity $PID > $DUMP_DIR/jstat-gccapacity-$PID.dump 2>&1
    echo -e ".c"
    $JAVA_HOME/bin/jmap $PID > $DUMP_DIR/jmap-$PID.dump 2>&1
    echo -e ".c"
    $JAVA_HOME/bin/jmap -heap $PID > $DUMP_DIR/jmap-heap-$PID.dump 2>&1
    echo -e ".c"
    $JAVA_HOME/bin/jmap -histo $PID > $DUMP_DIR/jmap-histo-$PID.dump 2>&1
    echo -e ".c"
    if [ -r /usr/sbin/lsof ]; then
    /usr/sbin/lsof -p $PID > $DUMP_DIR/lsof-$PID.dump
    echo -e ".c"
    fi
done
if [ -r /usr/bin/sar ]; then
/usr/bin/sar > $DUMP_DIR/sar.dump
echo -e ".c"
fi
if [ -r /usr/bin/uptime ]; then
/usr/bin/uptime > $DUMP_DIR/uptime.dump
echo -e ".c"
fi
if [ -r /usr/bin/free ]; then
/usr/bin/free -t > $DUMP_DIR/free.dump
echo -e ".c"
fi
if [ -r /usr/bin/vmstat ]; then
/usr/bin/vmstat > $DUMP_DIR/vmstat.dump
echo -e ".c"
fi
if [ -r /usr/bin/mpstat ]; then
/usr/bin/mpstat > $DUMP_DIR/mpstat.dump
echo -e ".c"
fi
if [ -r /usr/bin/iostat ]; then
/usr/bin/iostat > $DUMP_DIR/iostat.dump
echo -e ".c"
fi
if [ -r /bin/netstat ]; then
/bin/netstat > $DUMP_DIR/netstat.dump
echo -e ".c"
fi
echo "OK!"

Dubbo扩展点重构

转于自己在公司的Blog: http://pt.alibaba-inc.com/wp/dev_related_1283/dubbo-extension.html

随着服务化的推广,网站对Dubbo服务框架的需求逐渐增多, Dubbo的现有开发人员能实现的需求有限,很多需求都被delay, 而网站的同学也希望参与进来,加上领域的推动, 所以平台计划将部分项目对公司内部开放,让大家一起来实现, Dubbo为试点项目之一。

既然要开放,那Dubbo就要留一些扩展点, 让参与者尽量黑盒扩展,而不是白盒的修改代码, 否则分支,质量,合并,冲突都会很难管理。

先看一下Dubbo现有的设计:

image.png

这里面虽然有部分扩展接口,但并不能很好的协作, 而且扩展点的加载和配置都没有统一处理,所以下面对它进行重构。

第一步,微核心,插件式,平等对待第三方。

即然要扩展,扩展点的加载方式,首先要统一, 微核心 插件式,是比较能达到OCP原则的思路,

由一个插件生命周期管理容器,构成微核心, 核心不包括任何功能,这样可以确保所有功能都能被替换, 并且,框架作者能做到的功能,扩展者也一定要能做到,以保证平等对待第三方, 所以,框架自身的功能也要用插件的方式实现,不能有任何硬编码。

通常微核心都会采用Factory,IoC,OSGi等方式管理插件生命周期, 考虑Dubbo的适用面,不想强依赖Spring等IoC容器, 自已造一个小的IoC容器,也觉得有点过度设计, 所以打算采用最简单的Factory方式管理插件,

最终决定采用的是JDK标准的SPI扩展机制,参见:java.util.ServiceLoader 也就是扩展者在jar包的META-INF/services/目录下放置与接口同名的文本文件, 内容为接口实现类名,多个实现类名用换行符分隔, 比如,需要扩展Dubbo的协议,只需在xxx.jar中放置: 文件:META-INF/services/com.alibaba.dubbo.rpc.Protocol 内容为:com.alibaba.xxx.XxxProtocol Dubbo通过ServiceLoader扫描到所有Protocol实现。

并约定所有插件,都必须标注:@Extension("name"), 作为加载后的标识性名称,用于配置选择。

第二步,每个扩展点只封装一个变化因子,最大化复用。

每个扩展点的实现者,往往都只是关心一件事, 现在的扩展点,并没有完全分离,

比如:Failover, Route, LoadBalance, Directory没有完全分开,全由RoutingInvokerGroup写死了。

再比如,协议扩展,扩展者可能只是想替换序列化方式,或者只替换传输方式, 并且Remoting和Http也能复用序列化等实现, 这样,需为传输方式,客户端实现,服务器端实现,协议头解析,数据序列化,都留出不同扩展点。

拆分后,设计如下:

image.png

第三步,全管道式设计,框架自身逻辑,均使用截面拦截实现。

现在很多的逻辑,都是放在基类中实现,然后通过模板方法回调子类的实现, 包括:local, mock, generic, echo, token, accesslog, monitor, count, limit等等, 可以全部拆分使用Filter实现,每个功能都是调用链上的一环。

比如:(基类模板方法)

代码语言:javascript复制
public abstract AbstractInvoker implements Invoker {

    public Result invoke(Invocation inv) throws RpcException {
        // 伪代码
        active   ;
        if (active > max)
            wait();
        
        doInvoke(inv);
        
        active --;
        notify();
    }
    
    protected abstract Result doInvoke(Invocation inv) throws RpcException

}

改成:(链式过滤器)

代码语言:javascript复制
public abstract LimitFilter implements Filter {

    public Result invoke(Invoker chain, Invocation inv) throws RpcException {
         // 伪代码
        active   ;
        if (active > max)
            wait();
        
        chain.invoke(inv);
        
        active --;
        notify();
    }

}

第四步,最少概念,一致性概念模型。

保持尽可能少的概念,有助于理解,对于开放的系统尤其重要, 另外,各接口都使用一致的概念模型,能相互指引,并减少模型转换,

比如,Invoker的方法签名为:

代码语言:javascript复制
Result invoke(Invocation invocation) throws RpcException;

而Exporter的方法签名为:

代码语言:javascript复制
Object invoke(Method method, Object[] args) throws Throwable;

但它们的作用是一样的,只是一个在客户端,一个在服务器端,却采用了不一样的模型类。

再比如,URL以字符串传递,不停的解析和拼装,没有一个URL模型类, 而URL的参数,却时而Map, 时而Parameters类包装,

代码语言:javascript复制
export(String url)
createExporter(String host, int port, Parameters params);

使用一致模型:

代码语言:javascript复制
export(URL url)
createExporter(URL url);

再比如,现有的:Invoker, Exporter, InvocationHandler, FilterChain 其实都是invoke行为的不同阶段,完全可以抽象掉,统一为Invoker,减少概念。

第五步,分层,组合式扩展,而不是泛化式扩展。 原因参见:http://javatar.iteye.com/blog/690845 泛化式扩展指:将扩展点逐渐抽象,取所有功能并集,新加功能总是套入并扩充旧功能的概念。 组合式扩展指:将扩展点正交分解,取所有功能交集,新加功能总是基于旧功能之上实现。 上面的设计,不自觉的就将Dubbo现有功能都当成了核心功能, 上面的概念包含了Dubbo现有RPC的所有功能,包括:Proxy, Router, Failover, LoadBalance, Subscriber, Publisher, Invoker, Exporter, Filter等, 但这些都是核心吗?踢掉哪些,RPC一样可以Run?而哪些又是不能踢掉的? 基于这样考虑,可以将RPC分解成两个层次,只是Protocol和Invoker才是RPC的核心, 其它,包括Router, Failover, Loadbalance, Subscriber, Publisher都不核心,而是Routing, 所以,将Routing作为Rpc核心的一个扩展,设计如下:

image.png

第六步,整理,梳理关系。

整理后,设计如下:

image.png


个人介绍: 高广超:多年一线互联网研发与架构设计经验,擅长设计与落地高可用、高性能、可扩展的互联网架构。

0 人点赞