码农戏码

新生代农民工的自我修养

0%

从年初就开始温习SOLID原则,原则看似很简单,有些其实就是一句话,但对照这些原则去观看自己的过往代码,还是很多违背这些原则的,诚然,没有完美的实践,但需要保持在走向完美的路上

当然,如果你说天天在CRUD,从没有用到过这些原则,这就是另一个话题了

最近就写了一段很臭的代码,反省一下

1
2
3
4
5
6
7
8
9
10
public AbstractInvoice(InvoiceCode invoiceCode, InvoiceNo invoiceNo, PaperDrewDate paperDrewDate, CheckCode checkCode) {
this.invoiceCode = invoiceCode;
this.invoiceNo = invoiceNo;
this.paperDrewDate = paperDrewDate;
if (checkCode instanceof VerifyCheckCode) {
this.checkCode = checkCode.toNormal();
} else {
this.checkCode = checkCode;
}
}

这段代码很简单,业务是发票,一张发票由基本的几个要素组成:发票代码、发票号码、开票时间、检验码、金额

形象点,找个平常普通发票的票样

其次,这是一个抽象类,构建时带有发票几票素,这些InvoiceCode,InvoiceNo,PaperDrewDate,CheckCode都是简单的Primitive Domain(不了解这个概念也没关系,下回分解;简单讲就是业务自包含的基本类型)

完整的抽象类关系

但代码里面用到了instanceof,当用到这个关键字,而且是在抽象实体时,基本上可以断定是抽象的层次不够,
可能违背了LSP

LSP原则很明了:子类可以随时替换父类;这儿用了instanceof,说明有不可替换的成份在

再追看CheckCode的层次

一个简单的校验码,为什么也要抽象成这样?

1
2
3
4
5
6
7
8
9
10
11
public abstract class CheckCode {

protected String checkCode;

public CheckCode(String checkCode) {
this.checkCode = checkCode;
}

public CheckCode(int length, String checkCode, boolean isNumeric) {
}
}

校验码有几种形式

  1. 标准的20位长度的完整检验码
  2. 后6位简短的检验码,发票做验真业务时使用
  3. 区块链发票的检验码,5位长度,字母数字组合

因为有三种形态,不同的长度就是不同的类型;验真时只需要简短检验码;查看完整信息时需要完整的检验码

在发票接口中,也有相应的获取行为,也正因为有这种形为,需要在创建发票对象时,把VerifyCheckCode转换成NormalCheckCode

1
2
3
4
5
public interface Invoice {

public CheckCode getCheckCode();

public String getVerifyCheckCode();

这儿有个疑问,为什么不在构建发票前,把verifyCheckCode转成normalCheckCode,而不是到Invoice的构建内部再转化,那也就没有instanceof的事


但再细想,对于发票来讲,其实只有一个checkCode属性,没有VerifyCheckCode,那只是在请求验真时的一种形态,所以在发票对象中,不应该有verifyCheckCode属性

所以Invoice对象应该是这样

1
2
3
4
5
6
7
8
9
10
11
12
public interface Invoice {

public CheckCode getCheckCode();
}

public AbstractInvoice(InvoiceCode invoiceCode, InvoiceNo invoiceNo, PaperDrewDate paperDrewDate, CheckCode checkCode) {
this.invoiceCode = invoiceCode;
this.invoiceNo = invoiceNo;
this.paperDrewDate = paperDrewDate;
this.checkCode = checkCode;
}

对于primitive domain,CheckCode自包含中应该带有verifyCheckCode

每一种CheckCode都有各自不同的行为


一般通过instanceof判断子类型时,都有不满足LSP的嫌疑;在这个场景中也差不多,但抓住了这一点,重新思考一下,类层次与结构行为可以设计得更合理

接口隔离原则,ISP,Interface Segregation Principle

用于处理胖接口(fat interface)所带来的问题。如果类的接口定义暴露了过多的行为,则说明这个类的接口定义内聚程度不够好

第一种定义: Clients should not beforced to depend upon interfaces that they don’t use.

客户端不应该依赖它不需用的接口

第二种定义:The dependency of oneclass to another one should depend on the smallest possible interface。

类间的依赖关系应该建立在最小的接口上


ISP还是比较简单的,通过行为分离,达到高内聚效果

不遵循ISP

类A依赖接口I中的方法1、方法2、方法3,类B是对类A依赖的实现。类C依赖接口I中的方法1、方法4、方法5,类D是对类C依赖的实现。

对于类B和类D来说,虽然他们都存在着用不到的方法(也就是图中红色字体标记的方法),但由于实现了接口I,所以也必须要实现这些用不到的方法

显然接口I是个胖接口,客户端依赖了他不需要用的接口方法

遵循ISP

将原有的接口I拆分为三个接口,类A不需要用到“方法4”和“方法5”,就可以选择不依赖接口I3

实例

设计一个门接口,它包含了一款自动门所需要的功能,开关,自动关闭等。

但是并不是每个门都有自动关闭的功能,所以timeOut超时这个方法放在该接口中,就会导致所有实现这个接口的子类都会默认的继承了这个方法,哪怕子类中不对这个方法做处理。

把接口拆分成2个,拆分成Door和TimeClient,2个接口,这样Door就只保持原有的基本功能,而timeOut超时的方法则放到TimeClient接口中,这样就可以解决上述中接口臃肿的问题

VS SRP

很多人会觉的接口隔离原则跟之前的单一职责原则很相似,其实不然

其一,单一职责原则原注重的是职责;而接口隔离原则注重对接口依赖的隔离。

其二,单一职责原则主要是约束类,其次才是接口和方法,它针对的是程序中的实现和细节;而接口隔离原则主要约束接口接口,主要针对抽象,针对程序整体框架的构建


在单一职责原则中,一个接口可能有多个方法,提供给多种不同的调用者所调用,但是它们始终完成同一种功能,因此它们符合单一职责原则,却不符合接口隔离原则,因为这个接口存在着多种角色,因此可以拆分成更多的子接口,以供不同的调用者所调用。

比如说,项目中我们通常有一个Web服务管理的类,接口定义中,我们可能会将所有模块的数据调用方法都在接口中进行定义,因为它们都完成的是同一种功能:和服务器进行数据交互;但是对于具体的业务功能模块来说,其他模块的数据调用方法它们从来不会使用,因此不符合接口隔离原则

架构

在一般情况下,任何层次的软件设计如果依赖于不需要的东西,都会是有害。

从源代码层次来说,这亲的依赖关系会导致不必要的重新编译和重新部署

对更高层次的软件架构设计来说,问题也类似

如果D中包含了F不需要的功能,那么这些功能同样也会是S不需要的。对D中这些功能的修改会导致F需要被重新部署,后者又会导致S的重新部署。

更糟糕的是,D中一个无关功能的错误也可能会导致F和S运行出错

TIPS

采用接口隔离原则对接口进行约束时,要注意以下几点:

  • 接口尽量小,但是要有限度。对接口进行细化可以提高程序设计灵活性是不挣的事实,但是如果过小,则会造成接口数量过多,使设计复杂化。所以一定要适度。
  • 为依赖接口的类定制服务,只暴露给调用的类它需要的方法,它不需要的方法则隐藏起来。只有专注地为一个模块提供定制服务,才能建立最小的依赖关系。
  • 提高内聚,减少对外交互。使接口用最少的方法去完成最多的事情。

运用接口隔离原则,一定要适度,接口设计的过大或过小都不好。设计接口的时候,只有多花些时间去思考和筹划,才能准确地实践这一原则

Reference

《整洁架构之道》

Interface Segregation Principle(ISP)–接口隔离原则

最近连续做了两个新项目,借着新项目的机会,重新审视一下之前一些实践方法,进而寻求一下背后的理论支撑

新项目开始,首先一个就是会新建一个project,那么这个project怎么分层,怎么创建module,怎么分包呢?

经典分层

以传统方式,经典的MVC分层,就controller,service,model

找来一张servlet时代的经典处理流程,虽然技术手段日益更新,但处理流程是一样的

抽象一下,经典的分层就是:

现在大多数系统都是这种分层结构。JavaBean里面只有简单的get和set方法,被很多人鄙视的“贫血模型”;业务逻辑变得复杂时,service会变得很臃肿,出现很多的“上帝类”

回想一下,我们的所有的代码都写在ServiceImpl里面,前几行代码是做validation的事,接下来几行是做convert的事,然后是几行业务处理逻辑的代码,穿插着,我们需要通过RPC或者DAO获取更多的数据,拿到数据后,又是几行convert的代码,在接上一段业务逻辑代码,然后还要落库,发消息…等等

这也是事务脚本开发方式,横行于世。数据与行为被分离。

简单业务这样开发是合适的,但业务一复杂起来,需求频繁变更后,就没人能理解任何一段代码,业务逻辑和技术细节糅杂在一起,业务领域模型没法清晰表达出来,开发人员只知道怎么处理了数据,但背后的业务语义完全不知道

其实呢?还有很多的包,如config,common,core等等,
如果使用了一些中间件,如rabbitmq,还会相应创建上对应的包,简单点可能就被放在了service包下

从有了maven之,module概念更加显现化

1
2
3
4
5
6
<modules>
<module>service</module>
<module>common</module>
<module>core</module>
<module>test</module>
</modules>

我们的那么多包有了更加明确的地方放置,不再是直接放置在工程目录下

由于上面的这些问题 ,我们似乎可以指出经典的三层架构的弱点:

  • 架构被过分简化,如果解决方案中包含发送邮件通知,代码应该放置在哪些层?
  • 它虽然提出了业务逻辑隔离,但没有明确的架构元素指导我们如何隔离

DDD

虽然技术日新月异,但大多仅仅是技术,带了实现的便利性,但对于业务层次,更多的还是经验。随着业务的复杂性提升,系统的腐化速度和复杂性呈指数上升。

DDD带了很多的认知的改变,最大的好处是将业务语义显现化,不再是分离数据与行为,而是通过领域对象将领域概念清晰的显性化表达出来

当然这世间并没有银弹,但至少能给我们带来一种改进经典分层的理论支撑

DDD中带来了Repository概念,以及基础设施层,再结合【DIP原则】,可以把三层结构变成


再细看一下Controller,这一层,做些什么呢?

轻业务逻辑,参数校验,异常兜底。通常这种接口可以轻易更换接口类型,所以业务逻辑必须要轻,甚至不做具体逻辑

但在现实中,有些更极端,在servlet时代,还做下HttpRequest转换成DTO,传入service,现在有了springmvc,struts2框架的转换,不需要转换了,那么controller成了一个透传层,直接调用service

这儿有两个问题,既然controller是透传,那有必要存在吗? controller调用的service,这个service指的服务是什么呢?

第一controller显然有必要存在,不再于业务,而在于技术实现。不管是http,rpc都得有个请求入口。像thrift可能会比其他的一些rpc框架例如dubbo会多出一层,作用和controller层类似

Tservice与controller的作用是一样的

第二service服务指的是什么?领域服务吗?如果一个复杂的业务,那么会跨越多个领域,自然需要多个领域服务。如果放在controller里面,也就是在controller里面去编排领域服务,如果切换到thrift,那Tservice就得重复

因此,此时需要另一个service,在DDD中就是应用服务

应用服务算是领域服务的facade,应用层是高层抽象的概念,但表达的是业务的含义,领域层是底层实现的概念,表达的是业务的细节


1
2
3
4
5
6
7
<modules>
<module>controller</module>
<module>application</module>
<module>domain</module>
<module>infrastructure</module>
<module>start</module>
</modules>

controller模块:restfull风格的少不了这层

application模块:类似以前的service包

domain模块:如果是事务脚本方式,domain模块就没有了

infrastructure模块:基础模块,类似之前的dao包,但这里面都是实现类,而像repository接口则在domain模块,还需要对应的convertor

模块里面各个包,可能需要按实践情况而定了,后面再从项目中抽取个archetype,使用maven直接生成

里氏代换原则 LSP,Liskov Substitution Principle

子类型必须能够替换掉它们的基类型

若对每个类型S的对象O1,都存在一个类型T的对象O2,使得在所有针对T编写的程序P中,用O1替换O2后,程序P行为功能不变,则S是T的子类型

LSP是继承关系设计基本原则,也是使OCP成为可能的主要原则之一。正是子类型的可替换性才使得使用基类类型的模块在无需修改的情况下就可以扩展,对于LSP的违反常常会导致以明显违反OCP的方式使用运行时类型辨别(RTTI),这种方式常常是使用一个显式的if语句或才if/else链去确定一个对象的类型

假设一个函数f,它的参数为指向某个基类B的指针或者引用。同样假设B的某个派生类D,如果把D对象作为B类型传递给f,会导致f出现错误的行为。那么D就违反了LSP。显然,D对于f来说是脆弱的。

f的编写者会想去对D进行一些测试,以便于在把D的对象传递给f时,可以使f具有正确的行为。这个测试违反了OCP,因为此时f对于B的所有派生类都不再是封闭的

IS-A

“IS-A”是严格的分类学意义上的定义,意思是一个类是另一个类的“一种”

我们经常说继承是IS-A关系,也就是如果一个新类型的对象被认为和一个已有类的对象之间满足IS-A关系,那么这个新对象的类应该从这个已用对象的类派生

从一般意义上讲,一个正方形就是一个矩形。因此,把Sequare类视为从Rectangle类派生是合乎逻辑的

1
2
3
4
5
void f(Rectangle r) {
r.setWidth(5);
r.setHeight(4);
assert(r.Area() == 20)
}

此时,如果传入的是Sequare对象,那这个函数f不能正确处理,也就是Squauare不能替换Rectangle,也就违反了LSP,意味着LSP与通常的数学法则和生活常识有不可混淆的区别

在OOD中IS-A关系是就行为方式而言,而不是属性,这也就是面向接口编程;派生类的行为方式和输出不能违反基类已经确立的任何限制。基类的用户不应该被派生类的输出扰乱

简单判断就是“可替换性”,子类是否能替换父类并保持原有行为不变

LSP与架构

LSP从诞生开始,也就差不多这些内容,主要是指导如何使用继承关系的一种方法。随着时间推移,在更宏观上,LSP逐渐演变成了一种更广泛的、指导接口与其实现方式的设计原则

可以是java风格的接口,具有多个实现类:甚至可以是几个服务响应同一个rest接口,用户都依赖于一种接口,并且都期待实现该接口的类之间能具有可替换性

一旦违背了可替换性,该系统架构就不得不为此增添大量复杂的应对机制

开闭原则 OCP Open-Closed Principle

设计良好的计算机软件应该易于扩展,同时抗拒修改

换句话说,一个良好的计算机系统应该在不需要修改的前提下就可以轻易被扩展

遵循开闭原则设计出的模块具有两个特征:

  1. “对于扩展是开放的”,当应用的需求改变时,我们可以对模块进行扩展,使其具有满足那些改变的新行为
  2. “对于更改是封装的”,对模块进行扩展时,不必改动原有的代码

其实这也是研究软件架构的根本目的。如果对原始需求的小小延伸就需要对原有的软件系统进行大幅修改,那么这个系统的架构设计显示是失败的

一个好的软件架构设计师会努力将旧代码的修改需求量降至最小,甚至为0

这原则看着很矛盾,需要扩展,但却又不要修改;那么如何实现这个原则呢?

抽象,面向接口编程

模块可以操作一个抽象体。由于模块依赖于一个固定的抽象体,所以它对于更改可以是关闭的。同时,通过从这个抽象体派生,也可以扩展此模块的行为

client,server都是具体类,client使用server

如果client想使用另一个server对象,那么需要修改client中使用server的地方

显然这样违反了OCP

在新的设计中,添加了ClientInterface接口,此接口是一个拥有抽象成员函数的抽象类。Client类使用这个抽象类。如果我们希望client对象使用不同的server,只需要从clientinterface类派生一个新类,client无需任何修改

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
interface ClientInterface
{
public void Message();
//Other functions
}

class Server:ClientInterface
{
public void Message();
}

class Client
{
ClientInterface ci;
public void GetMessage()
{
ci.Message();
}
public void Client(ClientInterface paramCi)
{
ci=paramCi;
}
}

//那么在主函数(或主控端)则
public static void Main()
{
ClientInterface ci = new Server();
//在上面如果有新的Server类只要替换Server()就行了.
Client client = new Client(ci);
client.GetMessage();
}

OCP设计类与模块时的重要原则,但是在架构层面,这项原则意义更重大。

在设计时,可以先将满足不同需求的代码分组(SRP),然后再来调整这些分组之间的依赖关系(DIP)

IOC是不是也有OCP的味道

OCP算是面向对象设计的核心所在。遵循这个原则可以带来面向对象技术的巨大好处(灵活性,可重用性以及可维护性)

然而,并不是说只要使用一种面向对象语言就得遵循这个原则。对于应用程序中每个部分都肆意地进行抽象同样不是一个好主意。正确的做法是,开发人员应该仅仅对程序中呈现出频繁变化的那些部分进行抽象,拒绝不成熟的抽象和抽象本身一样重要

在一个复杂的软件中为什么会建议“尽量”不要违背OCP?

最核心的原因就是一个现有逻辑的变更可能会影响一些原有的代码,导致一些无法预见的影响。这个风险只能通过完整的单元测试覆盖来保障,但在实际开发中很难保障单测的覆盖率。OCP的原则能尽可能的规避这种风险,当新的行为只能通过新的字段/方法来实现时,老代码的行为自然不会变


Common Closure Principle(CCP)共同封闭原则

CCP延伸了开闭原则(OCP)的“关闭”概念,当因为某个原因需要修改时,把需要修改的范围限制在一个最小范围内的包里

一个包中所有的类应该对同一种类型的变化关闭。一个变化影响一个包,便影响了包中所有的类。一个更简短的说法是:一起修改的类,应该组合在一起(同一个包里)。如果必须修改应用程序里的代码,我们希望所有的修改都发生在一个包里(修改关闭),而不是遍布在很多包里。CCP原则就是把因为某个同样的原因而需要修改的所有类组合进一个包里。如果2个类从物理上或者从概念上联系得非常紧密,它们通常一起发生改变,那么它们应该属于同一个包。

CCP还是解决分布式单体可怕的反模式的法宝

在现流行的微服务架构中,按业务能力和子域以及SRP和CCP进行分解是将应用程序分解为服务的好方法

单一职责原则 SRP,single responsibility principle

SRP是所有原则中最简单的之一,也是最难正确运用的之一,也是我们日常中最常用的一个

不管是编码,重构,甚至当下流行的微服务中

在很多团队的规范中,都会听到一条编码规范:一个方法不要超过x行代码

作为一群自命不凡的程序员,为什么在规范中却有如此一条格调不对称规范

主要问题就在于思维对SRP的缺失


微服务这个术语的一个问题是会将你的关注点错误地聚集在“微”上。它暗示服务应该非常小。很多团队在实施时,也是往小了去考虑,偏移了核心目标

微服务的目标是将精心设计的服务定义为能够由小团队开发的服务,并且交付时间最短,与其它团队协作最小。

理论上,团队可能只负责单一服务,因此服务绝对不是微小的

单一

从个人理解可以分为狭义与广义

狭义:

狭义只是从面向底层实现细节的设计原则

一个类而言,应该仅有一个引起它变化的原因

在日常中,在编写方法或者重构方法,也是以这个为原则,即确保一个函数只完成一个功能。

在将大方法重构成小方法时经常会用到这个原则

广义:

相对狭义,适用的范围相对大些,不再是一个类,一个方法,亦或是一个原因

任何一个软件模块都应该只对某一类行为者负责

“软件模块”不再只是一个类,可以是一组紧密相关的函数和数据结构,甚至一个独立应用服务

职责

什么是职责?如果一个类承担多于一个职责,那么引起它变化的原因就会有多个

在SRP中,职责定义为“变化的原因”,如果你能够想到多于一个的动机去改变一个类,那么这个类就具有多于一个的职责

因此对于职责的定义需要结合具体业务,有时从感性上理解一个类的多个方法应该拆分,但如果应用程序的变化方式总是导致这几个职责同时变化,那么就不需要分离

这是近期在公司做的一次分享,这几年的互联网开发,算比较幸运,团队一直践行完善这套规范,没有太多的阻碍,得益于公司整体氛围,以及团队对规范和写文档的不排斥,形成了良好的开发习惯

在这次分享后,发现好些大V也在谈规范,写文档,估计是前段时间阿里又发布了开发手册(华山版),借鉴于一下,对一些细节做些补充,整理出来

整体流程

这个流程整体分为三个大阶段:需求阶段,开发阶段,上线阶段

需求阶段

需求分析

这个阶段主要是产品主导,收集痛点,归集需求,制定目标,与架构师讨论架构方案,与安全评估业务安全性

这儿可根据需求大小,具体行事,比如有些需求涉及方比较多,可能需要多次分组开会,不管是可行性分析,还是讨论方案,不是一次就能完成的

需求评审

当产品已经确定好这些方面,开始输出PRD,与研发、测试一起评审需求

研发与测试需要理解需求,更要挑战需求;

挑战需求的目的不是砍需求,而是确认产品在需求分析阶段的工作完备程度,比如遗留数据处理,对当前系统的影响层面,是否与当前系统重复度过高,业务价值

只有经过充分讨论,才能理解需求,完善需求,防止后期需求返工,某个细节没有考虑,影响整体设计实现

这儿各个团队实践方式各不相同,比如有些是所有团队成员都要参与,而有些只有具体参与开发测试参与

个人推崇所有成员都参与,这样一是讨论可以更充分,二是信息共享,防止因某成员个人原因,推迟需求进度

需求排期

对需求大家都达成了共识,此时就需要产品去需求确定优先级,排期开发

确定开发周期,这儿也有很多具体做法,有些团队是TL根据需求难易程序,变动大小,结合具体开发人员直接给出时间;有些团队是具体开发自行评估时间

一般都是由开发人员自行评估,只要在合理范围内,都给予认同

当然在确定开发周期时,必须给出依据,依据来源于详细设计,对于详细设计文档具体形式后面再谈,至少开发人员知道需要增加多少接口,修改多少接口,大概逻辑;不然评估时长就是一个空谈,造成整个项目的失控

开发阶段

需求阶段,从产品需求分析到开发人员评估出开发周期就已经结束了;下一个阶段进入开发阶段

开发阶段的进度,可以说八成是依赖第一阶段的成果。编码速度,实现手段只要是正常业务需求,一般都不会拖延时长

第一阶段成果,对于开发人员来讲,就是详细设计文档,文档中有了相应流程图,伪代码,具体涉及接口也有了,此时就是一个代码翻译过程

此阶段测试,需要输出测试用例,进行冒烟,回归测试;

自动化脚本完善

上线阶段

测试完成之后,就准备上线了。

根据确定的上线日期,提前核对checklist

对一些需要提前的变更开始申请审批流程

配合监控系统,需要对一些业务监控项进行配置

产品开始对预发布环境进行验收,验收成功后;发布正式环境

上线收尾

收尾工作,这个阶段,还有大量工作需要去做

  1. 产品对需求进行总结,收集数据,分析效果,为下一期需求做准备
  2. 开发需要对代码进行整理,比如有些是为了灰度而生的无用代码可以删除

一个完整的需求开发流程到此结束,当然这只是理想状态,还有很多不可预测问题,当然你也会吐槽,这是典型的瀑布开发模型,在敏捷大行其道时,是不是太守旧,太迟钝,都2091年了,为什么还在玩这一套

理想是丰满的,现实是骨感的。好比敏捷开发的参与者是一群开发经验丰富和才华横溢的开发人员,而这样的团队有多少?强硬为了敏捷而敏捷会不会造成项目的不可控呢?

当然瀑布模型也有天生的缺点:每个阶段的严格性,缺乏灵活性,而现实需求却是经常变化的

所以单纯地选择哪个模型是不可取的,只能根据实际情况出发,为业务提供最大化服务


细则规范

很多人都在要规范,但好像从没思考过为什么需要规范?

《阿里巴巴java开发手册》:手册的愿景是码出高效,码出质量

现代软件架构的复杂性需要协同开发完成,如何高效地协同呢?无规矩不成方圆,无规范难以协同,比如,制订交通法规表面上是要限制行车权,实际上是保障公众的人身安全,试想如果没有限速,没有红绿灯,谁还敢上路行驶?对软件来说,适当的规范和标准绝不是消灭代码内容的创造性、优雅性,而是限制过度个性化,以一种普遍认可的统一方式一起做事,提升协作效率,降低沟通成本。 代码的字里行间流淌的是软件系统的血液,质量的提
升是尽可能少踩坑,杜绝踩重复的坑,切实提升系统稳定性,码出质量

从上段可以看出几个目的

  1. 高效协同,降低沟通成本;书同文,车同轨
  2. 码出质量,降低故障率;
  3. 工匠精神,追求卓越

评审会议

很多开发人员最怕开会,更要命的是很多会议是效率低下的。主要表现:

  • 在没有基本的认知共识就被拉去开会:这可能是主持者没有提前知会,同步资料;以及没有在线下达到一定共识就开会,结果会上各种讨论;也可能是参会人员本身也没有提前准备
  • 会后没有结论,或者结论不明确

所以在参与评审后,需要有一份输出,文档或者邮件,主要包括以下内容

  1. 评审日期与轮次
  2. 业务需求的目的及价值描述
  3. 参与人员及角色
  4. 评审附件(PRD或邮件)
  5. 评审结论
  6. 评审遗留问题及跟进人

需求PRD

开发人员,最烦就是口头需求,一句话需求,需要明文禁止

产品写PRD,其实是个基本职业素养,结果现在还要明文规定,也算是个悲哀。

为什么要出PRD,其实不是开发人员故意为难产品,而是让产品深刻理解需求,看这又是个怪事,产品还有不理解业务需求的,但就是常有产品自己都不理解业务,还一本正经给研发提开发需求。

写PRD的过程,就是梳理思考的过程,让需求更明确,流程更完整,细节更透彻,这样就不会出现提交给开发时,被开发一堆问题阻塞住。也可以防止在开发过程中,却发现整个业务没有形成闭环,造成返工,延时

那么开发如何拒绝口头需求,一句话需求呢?

有时产品比较强势,开发人员不好沟通,此时TL就应该对团队明确规定,禁止产品此类行为,也要禁止开发接受此类需求,不能为了一时快,而整个工期慢

在接受到此类需求时,也需要一定的沟通技巧:

  1. 多问几个为什么:这比如你这个需求背后的目的和价值是什么?做了之后有什么预期的收益,为什么这么做就可以达到这个收益,你可以不直接问业务方,但是你也需要问自己,业务方的这个目标和做这个需求的路径是否可以匹配得上,如果实现路径存在逻辑漏洞或者不是最佳的则这个需求也就没有做的必要性

  2. 给出替代方案:经过上面的步骤,其实你会发现你已经过滤了一批无效的一句话需求,而有些需求可能是有一定的存在价值,但是可能业务方提到的点并不是有效的方案或者说成本太大的方案,这时你就需要思考替代方案,尽量通过现有方案或者小成本的方式来满足业务方,间接的达到“拒绝”的效果

  3. 不能直接说不,但可以有条件的说是:当你确定这个需求是ok的,但你确实暂时抽不出时间来搞定这个事情的时候,这时关键在于我们不能直接拒绝业务方,长此以往会影响到后续的合作关系,这种情况你可以说,这个需求我接受,但是我可能需要较长一些的缓冲时间或者砍一些需求(部分满足),又或者必须要按时上的话,不能保证项目的上线后的效果、质量等,让业务方来做部分的取舍

详细设计文档

首先禁止没有文档,直接修改代码;这跟需求PRD类似,强迫开发人员思考需求,完善需求,胸中有丘壑,下笔自有神;不能在编码过程,边写边思考,不仅慢,还会漏洞百出

其实让团队写文档,也是个难事,推动很难,尤其管理者没有引起重视,就更难。这是个怪事,不喜欢写文档,却喜欢被返工,在开发过程因需求逻辑不完备而加班赶点,从上到下默认了这种怪事的正常化

为什么要写设计文档?

  • 对自己,让自己在动手写代码之前,帮助自己想得更清楚;
  • 对项目,保证信息的一致性,保证项目的可控性,减少项目风险;
  • 对团队,确保知识的沉淀与传承;

项目涉及多少个子系统,每个子系统涉及多少个模块,模块间的依赖关系如何,彼此要提供多少个接口,每个接口的参数如何,接口实现过程中上下游交互如何,核心逻辑用什么技术方案实现…

难道相关技术人都一清二楚么?很多自信的技术大神,“以为”懂了,但却讲不明白,其实就是不懂。很多“讲得明白”,却“写不清楚”,其实就是没懂透。把一个项目,一个技术问题,按照逻辑,用文字来一遍,才表示真正的想清楚了

这也是现在流行的,一解释都懂,一问都不知,一讨论就吵架


不再写过多为什么要写文档了,一个习惯的改变不可能一下子就改变,这需要一个过程,也需要自我大胆尝试

这儿给出一些实践,详细设计文档写些什么

其实一份详细设计文档,整体分为两个部分:功能需求与非功能需求

1、需求信息

这儿包括需求背景,业务价值,预计上线时间,架构设计wiki,产品及开发负责人,涉及到的服务,上下游服务

2、系统流程图

阐述整体设计思路,涉及算法时,还需要详细算法思路

包含上下游系统交互和数据流向,建议viso或者astash图,要保存原图文件以防后期维护修改

当然最好还要把设计思路背景说明一下,有多种方案时也罗列一下,因为系统现有状况,进度安排,历史数据等等原因,而选择了当前方案,这样自我分析完整,也免将来别人接手时可以追溯

3、接口列表

这儿列出所有涉及到的接口列表

标明新增加或者修改,以及接口详细入参,返回值

一般会有api doc,或者类似swagger工具,接口变化时,也可以相应变化;如果没有,那只能在文档中详细输出

4、定时任务

有些任务不需要,有些任务可能有很多,需要指出任务功能,频率

5、存储变更

比如缓存,数据结构,过期时间,预计数据增长

DB表设计,表修改,索引信息,数据增长量;有新的业务场景,一定要请DBA帮忙评估索引或者其他信息

6、配置组件

配置:比如配置中心,增加修改配置项,常为了灰度增加一些开关之类

组件:第三方依赖jar,不管是公司自研,还是外部开源;关注新特性给系统带来的变化;这个对开发工作量很小,只需要修改版本号,但测试可能需要一些回归量,尤其常出现的包冲突,造成日志不能正常输出

7、非功能需求

接口在日常和大促时的调用量评估,是否有降级方案,灰度方案,能不能重试,需不需要压测,这些都是围绕服务治理做预案

这其实是个很大的模块,很多已经深入骨髓,变成常态,比如灰度,现在都是7*24小时在线服务的,前后版本的兼容必须考虑到

总结

当然这些并不是必须的,可以根据实际情况变通,有增有减;当然你也可能从不写文档,很多人喜欢看源码,而不看文档;其实这有些本末倒置,源码只是告诉你了how,而文档才解释了what,why

架构师是很多码农的追求,架构师如何设计系统,如何让开发人员去实施呢?当然是文档,总不能直接给代码吧

监控之前只总结了一篇《微服务-监控》,比较宏观。其中很多细节没有过深关注到,主要还是没有实践过,更没有去深度思考,所以很多有意思的技术点都错过了,比如traceid的生成,传递

大牛圈总的大作《微服务系统架构之分布式traceId追踪参考实现》已经给出解决方案,但还是再主动总结一下

意义

为什么需要traceid,为了查看完整的调用链,一旦调用过程中出现问题,可以第一时间定位到问题现场

整个调用链是一棵树形结构,traceid的传递涉及到主干与支干,进程内与进程外

生成

原则是唯一不重复,比如现成的UUID

但UUID一是丑、无意义,二是string;

从字面意义以及未来落盘都不能说是最佳方案,比如想让traceid包含信息更丰富一些,能一眼看出此traceid是主干还是分支

此traceid有没有最终落盘(这儿涉及到落盘抽样率,每天服务处理海量请求,总不能每个traceid都落盘)

Random

这儿引申到如何更好地获取一个随机数又是一个课题,另开篇吧

传递

《熔断机制》中提过,服务调用是一个1->N扇出,调用链展现出对应的树形结构,但调用嵌套都不会深,一般两层就差不多了

  • traceId1
    • traceId1.1
      • traceId1.1.1
    • traceId2.1
    • traceId3.1

进程外

服务之间的传递

serverA –> serverB – serverC

这儿在设计传输协议时,在协议头里面带上traceid

进程内

主干

这种场景ThreadLocal是最佳手法

支干

比如serviceA – > remote.serviceB

trace是个树形结构,可以将remote.serviceB的traceId.parentId = serviceA.traceId

异步子任务

子线程可以通过InheritableThreadLocal传递traceid

顺带一下,InheritableThreadLocal的详细实现,先可补习一下ThreadLocal《解析ThreadLocal》

在创建Thread时,会从父线程的inheritableThreadLocals复制到子线程中去,这样在子线程中就能拿到在父线程中的赋值

1
2
3
4
5
6
7
8
9
/* ThreadLocal values pertaining to this thread. This map is maintained
* by the ThreadLocal class. */
ThreadLocal.ThreadLocalMap threadLocals = null;

/*
* InheritableThreadLocal values pertaining to this thread. This map is
* maintained by the InheritableThreadLocal class.
*/
ThreadLocal.ThreadLocalMap inheritableThreadLocals = null;
1
2
3
if (parent.inheritableThreadLocals != null)
this.inheritableThreadLocals =
ThreadLocal.createInheritedMap(parent.inheritableThreadLocals);

线程池

如果没有线程池,以上就算是解决所有问题了,可实现毕竟是实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
/**
* 子线程从父线程中取值
* @throws InterruptedException
*/
private static void testThreadpool() throws InterruptedException {
ExecutorService executorService = Executors.newFixedThreadPool(1);
final ThreadLocal<String> threadLocal = threadLocal();//new InheritableThreadLocal<>()
threadLocal.set("parent");
for(int i=0;i<1;i++) {
Runnable runnable = new Runnable() {
@Override
public void run() {
System.out.println(Thread.currentThread().getName() +" get parent value:" + threadLocal.get());
threadLocal.set("sun");
System.out.println(Thread.currentThread().getId() + "==" + threadLocal.get());
}
};
executorService.execute(runnable);
Thread.sleep(100);
executorService.execute(runnable);
Thread.sleep(100);
System.out.println("main:" + threadLocal.get());
}
executorService.shutdown();
}

为了好重现问题,线程池大小为1,但会连续跑两次任务

1
2
3
4
5
pool-1-thread-1 get parent value:parent
11==sun
pool-1-thread-1 get parent value:sun
11==sun
main:parent

在第二次取父线程值时,却是第一次任务线程中的赋值,在线程池中子线程不能正常获取父线程值

线程池中,线程会复用,线程中的inheritableThreadLocals没有被清空

解决方法一是:池中线程数大于任务线程,让线程没有重用机会

1
ExecutorService executor = Executors.newFixedThreadPool(>=[任务线程数])

但在多线程应用中,明显不能解决问题,任务数肯定远远超过线程数

解决方法二是:自定义实现在使用完线程主动清空inheritableThreadLocals

阿里开源transmittable-thread-local就实现这样的功能

整体思路也是从主线程复制,使用,再清理

TtlRunnable 构造方法中,调用了 TransmittableThreadLocal.Transmitter.capture() 获取当前线程中所有的上下文,并储存在 AtomicReference 中

当线程执行时,调用 TtlRunnable run 方法,TtlRunnable 会从 AtomicReference 中获取出调用线程中所有的上下文,并把上下文给 TransmittableThreadLocal.Transmitter.replay 方法把上下文复制到当前线程。并把上下文备份

当线程执行完,调用 TransmittableThreadLocal.Transmitter.restore 并把备份的上下文传入,恢复备份的上下文,把后面新增的上下文删除,并重新把上下文复制到当前线程

transmittable-thread-local代码不多,但有很多亮点,可以自行膜拜

在此场景,transmittable-thread-local还是太重了,其实可以简单借鉴一下transmittable-thread-local的思路,自定义Runnable

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public TransRunnable(Runnable runnable){
this.runnable = runnable;
//在创建时,获取父traceId
this.parentId = TranceContext.getParentTrace();
}
@Override
public void run() {
//
String old = TranceContext.getParentTrace();
//设置父traceid
TranceContext.setParentTrace(parentId);
runnable.run();
//还原
TranceContext.setParentTrace(old);
}

在创建子线程时,把父traceId带进去,就能在子线程业务方法中拿到父traceId,这样整个调用链也不会断

schedule

traceid生成,有主动请求时,会生成,但如果是个系统的定时任务呢?

  1. 让taskService调用一下入口,类似模拟用户行为
  2. 主动生成一个parent traceId

总结

到此,对于traceid的知识结构丰满了很多

想写个一份百万QPS系统的JVM参数,感觉太标题党了,虽然这的确是,但还是朴实点;

JVM参数调优是性能重器,安全第一,不可乱用,更不能因为网上推荐文章(此篇)而随便用

之前关于JVM的几篇文章《是否需要主动GC》《JIT优化》《GC及JVM参数》

这些都涉及到JVM参数,然道理懂不少,还是配置不好参数;调优的确是个费劲的事。这儿直接给一份参数,可以直接拿来主义,当然也有些参数需要配合硬件及应用环境,斟酌使用,一切以实战为准

其实有很多现成的,如elasticsearchcassandraVIP

jvm参数总体分两种:标准参数(以-开始)与非标准参数;

非标准参数又分了两种:不太标准(以-X开始)与特别不标准(以-XX开始)

参数列表就标准到非标准一一进行说明

标准参数

顾名思义,标准参数中包括功能和输出的参数都是很稳定的,很可能在将来的JVM版本中不会改变。你可以用java命令(或者是用 java -help)检索出所有标准参数

-server

这个参数涉及分层编译策略,简单讲,就是把更多的代码更早地编译成本地代码

-D

应用附加配置参数,通过System.getProperty读取

-Djava.security.egd=file:/dev/./urandom

-Djava.net.preferIPv4Stack=true

-Djava.awt.headless=true

-Dspring.profiles.active=dev

非标准参数

非标准化的参数在将来的版本中可能会改变

在实际情况中X参数和XX参数并没有什么不同。X参数的功能是十分稳定的,然而很多XX参数仍在实验当中(主要是JVM的开发者用于debugging和调优JVM自身的实现)

X参数

-Xms2048m

-Xmx2048m

-Xmn2048m

-Xss512K

这几个老面孔,设置堆大小

Xms和Xmx设置一样,可以减轻伸缩堆大小带来的压力;Xmn新年代大小

Xss规定了每个线程堆栈的大小。一般情况下256K是足够了; 如果线程数较多,函数的递归较少,线程栈内存可以调小节约内存,默认1M

-Xloggc

-Xloggc:/dev/shm/gc.log

有人担心写GC日志会影响性能,但测试下来实在没什么影响,GC问题是Java里最常见的问题,没日志怎么行。

后来又发现如果遇上高IO的情况,GC时操作系统正在flush pageCache 到磁盘,也可能导致GC log文件被锁住,从而让GC结束不了。所以把它指向了/dev/shm 这种内存中文件系统,避免这种停顿,详见Eliminating Large JVM GC Pauses Caused by Background IO Traffic


XX参数

XX参数 虽然是最不稳定参数,但使用的最多,好神奇,很多特殊的性能调优都需要用到

  • 对于布尔类型的参数,我们有”+”或”-“,然后才设置JVM选项的实际名称。例如,-XX:+用于激活选项,而-XX:-用于注销选项
  • 对于需要非布尔值的参数,如string或者integer,我们先写参数的名称,后面加上”=”,最后赋值。例如, -XX:=给赋值

在使用此类参数时,可以使用两个命令先确认一下JVM默认值,防止JVM变动,最好还是明确设置

1
2
-XX:+PrintFlagsInitial表示打印出所有XX选项的默认值
-XX:+PrintFlagsFinal表示打印出XX选项在运行程序时生效的值

这些参数从功能大体分类一下

  1. 空间大小,类似-X参数,但这些空间各个JVM可能不同实现,如PermSize
  2. 监控类,帮助确定问题Trouble shooting Options
  3. 优化类,调优性能
  4. GC策略类

内存类

-XX:PermSize=512m -XX:MaxPermSize=512m

1
2
Java HotSpot(TM) 64-Bit Server VM warning: ignoring option PermSize=200m; support was removed in 8.0
Java HotSpot(TM) 64-Bit Server VM warning: ignoring option MaxPermSize=256m; support was removed in 8.0

在JDK8之后,永久代向元空间的转换,配置项变成了

-XX:MetaspaceSize=200m -XX:MaxMetaspaceSize=256m

为什么需要这样转换?

1、字符串存在永久代中,容易出现性能问题和内存溢出。
2、类及方法的信息等比较难确定其大小,因此对于永久代的大小指定比较困难,太小容易出现永久代溢出,太大则容易导致老年代溢出。
3、永久代会为 GC 带来不必要的复杂度,并且回收效率偏低。
4、Oracle 可能会将HotSpot 与 JRockit 合二为一。

-XX:MaxDirectMemorySize=2048m

堆外内存的最大值,默认为Heap区总内存减去一个Survivor区的大小;像使用netty之类框架,用得多些。

在DirectByteBuffer中,首先向Bits类申请额度,Bits类有一个全局的 totalCapacity变量,记录着全部DirectByteBuffer的总大小,每次申请,都先看看是否超限 – 堆外内存的限额默认与堆内内存(由-XMX 设定)相仿,可用 -XX:MaxDirectMemorySize 重新设定。

如果已经超限,会主动执行Sytem.gc(),期待能主动回收一点堆外内存。然后休眠一百毫秒,看看totalCapacity降下来没有,如果内存还是不足,就抛出大家最头痛的OOM异常;详细可见《堆外内存》

-XX:ReservedCodeCacheSize=240M

JIT编译后二进制代码的存放区,满了之后就不再编译,对性能影响很大。JDK7默认不开多层编译48M,开了96M,而JDK8默认开多层编译240M。可以在JMX里看看CodeCache的大小,JDK7下的48M一般够了,也可以把它设大点,反正内存多

-XX:NewRatio=1

这个参数,与-Xmn or (-XX:NewSize and -XX:MaxNewSize) or -XX:NewRatio并列,都是设置年轻代大小。默认值为2, 也就是新生代占堆大小的1/3, 个人喜欢把对半分, 增大新生代的大小,能减少GC的频率(但也会加大每次GC的停顿时间),主要是看老生代里没多少长期对象的话,占2/3太多了。可以用-Xmn 直接赋值(等于-XX:NewSize and -XX:MaxNewSize同值的缩写),或把NewRatio设为1来对半分(但如果想设置新生代比老生代大就只能用-Xmn)

参数中带Ratio的还有一个-XX:SurvivorRatio,从字面意思看好像是新年代占比、survivor占比。其实是反的

-XX:NewRatio=4表示年老代与年轻代的比值为4:1

-XX:SurvivorRatio=8表示Eden区与Survivor区的大小比值是8:1:1, 因为Survivor区有两个

监控类

-XX:+PrintCommandLineFlags

让JVM打印出那些已经被用户或者JVM设置过的详细的XX参数的名称和值,还会打印出以及因为这些参数隐式影响的参数

打印出来,需要核实线上运行状态时,有据可查

-XX:-OmitStackTraceInFastThrow

有时查线上问题时,看到有异常信息,但只有

1
2
3
4
5
...
java.lang.NullPointerException
java.lang.NullPointerException
java.lang.NullPointerException
...

具体的异常栈没了,有时异常监控不位,人工发现这些异常时,却看不出哪里的问题,很是恼火

JVM对一些特定的异常类型做了Fast Throw优化,如果检测到在代码里某个位置连续多次抛出同一类型异常的话,C2会决定用Fast Throw方式来抛出异常,而异常Trace即详细的异常栈信息会被清空。这种异常抛出速度非常快,因为不需要在堆里分配内存,也不需要构造完整的异常栈信息

特定异常:

1
2
3
4
5
NullPointerException
ArithmeticException
ArrayIndexOutOfBoundsException
ArrayStoreException
ClassCastException

-XX:+PrintGCCause

打印产生GC的原因,比如AllocationFailure什么的,在JDK8已默认打开,JDK7要显式打开一下

-XX:+PrintGCApplicationStoppedTime

这是个非常非常重要的参数,但它的名字没起好,其实除了打印清晰的完整的GC停顿时间外,还可以打印其他的JVM停顿时间,比如取消偏向锁,class 被agent redefine,code deoptimization等等,有助于发现一些原来没想到的问题

1
2018-10-18T03:27:39.204+0800: 33729.026: Total time for which application threads were stopped: 0.0059280 seconds, Stopping threads took: 0.0001000 seconds

-XX:+PrintGCDateStamps

输出GC的时间戳(以日期的形式,如 2013-05-04T21:53:59.234+0800)

用PrintGCDateStamps而不是PrintGCTimeStamps,打印可读的日期而不是时间戳

-XX:+PrintGCDetails

输出GC的详细日志

-XX:ErrorFile

-XX:ErrorFile=${MYLOGDIR}/jvmerr_%p.log

JVM crash时,hotspot 会生成一个error文件,提供JVM状态信息的细节。如前所述,将其输出到固定目录,避免到时会到处找这文件。文件名中的%p会被自动替换为应用的PID

-XX:+HeapDumpOnOutOfMemoryError

两个配合使用 -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=${LOGDIR}/

在Out Of Memory,JVM快死掉的时候,输出Heap Dump到指定文件。不然开发很多时候还真不知道怎么重现错误。

路径只指向目录,JVM会保持文件名的唯一性,叫java_pid${pid}.hprof。因为如果指向文件,而文件已存在,反而不能写入。

但在容器环境下,输出4G的HeapDump,在普通硬盘上会造成20秒以上的硬盘IO跑满,也是个十足的恶邻,影响了同一宿主机上所有其他的容器


优化类

-XX:AutoBoxCacheMax

-XX:AutoBoxCacheMax=20000

这个参数的意义是缓存自动装箱最大值

设为20000后,我们应用的QPS有足足4%的影响

看代码

1
2
3
4
Integer i = 129;
Integer j = 129;

i == j //true or false ?

JDK默认只缓存 -128 ~ +127的Integer 和 Long,超出范围的数字就要即时构建新的Integer对象;

如果这儿配置了最大值20000,那就是[-128,20000]都不再创建新对象,但有点奇怪的时,你不能认为AutoBoxCacheMax的默认值是127

为什么配置值是20000呢,就得说到-XX:+AggressiveOpts参数,这是是一些还没默认打开的优化参数集合, -XX:AutoBoxCacheMax是其中的一项。但这个参数在关键系统里不建议打开

There’s a JVM option -XX:+AggressiveOpts that supposedly makes your JVM faster. Lots of people turn this on in Eclipse to try to make it faster. But it makes your JVM less correct. Today I found it to be the cause of a longstanding bug in dx.
http://code.google.com/p/android/issues/detail?id=5817

-XX:+AggressiveOpts was deprecated in JDK 11 and should be removed in JDK 12

1
2
3
4
5
-     bool AggressiveOpts                           := false           {product}
+ bool AggressiveOpts := true {product}

- intx AutoBoxCacheMax = 128 {C2 product}
+ intx AutoBoxCacheMax = 20000 {C2 product}

通过上面的打印设置配置值的参数,可以看出此项默认值是128,在打开AggressiveOpts参数时,是20000

-XX:-UseCounterDecay

禁止JIT调用计数器衰减。默认情况下,每次GC时会对调用计数器进行砍半的操作,导致有些方法一直温热,
永远都达不到触发C2编译的1万次(server默认值)的阀值,详细可参考《JIT优化》

-XX:-UseBiasedLocking

JDK1.6开始默认打开的偏向锁,会尝试把锁赋给第一个访问它的线程,取消同步块上的synchronized原语

如果始终只有一条线程在访问它,就成功略过同步操作以获得性能提升

为什么会有偏向锁出现?因为经验表明,其实大部分情况下,都会是同一个线程进入同一块同步代码块

但线上应用基本都是使用多线程,一旦出现锁竞争,就会锁膨胀,GC日志中有不少RevokeBiasd的纪录,像GC一样Stop The World的干活,虽然只是很短的停顿,但对于多线程并发的应用,取消掉它反而有性能的提升

-XX:+PerfDisableSharedMem

JVM经常会默默的在/tmp/hperf目录写上一点statistics数据,如果刚好遇到PageCache刷盘,把文件阻塞了,就不能结束这个Stop the World的安全点

禁止JVM写statistics数据的代价,是jps和jstat用不了;详细可看jstat的具体实现

-XX:MaxTenuringThreshold=4

这是改动效果最明显的一个参数了。对象在Survivor区最多熬过多少次Young GC后晋升到年老代,JDK8里默认是15

Young GC是最大的应用停顿来源,而新生代里GC后存活对象的多少又直接影响停顿的时间,所以如果清楚Young GC的执行频率和应用里大部分临时对象的最长生命周期,可以把它设的更短一点,让其实不是临时对象的新生代对象赶紧晋升到年老代,别呆着。

用-XX:+PrintTenuringDistribution观察下,如果后面几代的大小总是差不多,证明过了某个年龄后的对象总能晋升到老生代,就可以把晋升阈值设小,比如JMeter里2就足够了

-XX:+UnlockDiagnosticVMOptions -XX: ParGCCardsPerStrideChunk=1024

Linkined的黑科技, 上一个版本的文章不建议打开,后来发现有些场景的确能减少YGC时间,详见《难道这些 Java 大牛说的都是真的?》,简单说就是影响YGC时扫描老生代的时间,默认值256太小了,但32K也未必对,需要自己试验

-XX:+ExplicitGCInvokesConcurrent

full gc时,使用CMS算法,不是全程停顿,必选

-XX:+AlwaysPreTouch

启动时访问并置零内存页面;启动时就把参数里说好了的内存全部舔一遍,可能令得启动时慢上一点,但后面访问时会更流畅,比如页面会连续分配,比如不会在晋升新生代到老生代时才去访问页面使得GC停顿时间加长。ElasticSearch和Cassandra都打开了它


GC策略

配置-server时默认使用ParallelScavenge系的GC,是个吞吐量优先的收集器

虽然现在有了G1 GC,甚至JDK11后的ZGC,但在大型互联网项目上估计CMS还是主流

CMS三个基本配置

-XX:+UseConcMarkSweepGC -XX:CMSInitiatingOccupancyFraction=75 -XX:+UseCMSInitiatingOccupancyOnly

因为我们的监控系统会通过JMX监控内存达到90%的状况(留点处理的时间),所以设置让它75%就开始跑了,早点开始也能避免Full GC等意外情况(概念重申,这种主动的CMS GC,和JVM的老生代、永久代、堆外内存完全不能分配内存了而强制Full GC是不同的概念)。为了让这个设置生效,还要设置-XX:+UseCMSInitiatingOccupancyOnly,否则75只被用来做开始的参考值,后面还是JVM自己算

-XX:+ParallelRefProcEnabled -XX:+CMSParallelInitialMarkEnabled

并行的处理Reference对象,如WeakReference,默认为false,除非在GC log里出现Reference处理时间较长的日志,否则效果不会很明显,但我们总是要JVM尽量的并行,所以设了也就设了。同理还有-XX:+CMSParallelInitialMarkEnabled,JDK8已默认开启,但小版本比较低的JDK7甚至不支持

建议参数

-XX:ParallelGCThreads=? -XX:ConcGCThreads=?

-XX:ParallelGCThreads=n GC在并行处理阶段多少个线程,默认值和平台有关。(译者注:和程序一起跑的时候,使用多少个线程)

-XX:ConcGCThreads=n 并发收集的时候使用多少个线程,默认值和平台有关。(译者注:stop-the-world的时候,并发处理的时候使用多少个线程)

ParallelGCThreads=Processor < 8 ? 8 : 8 +( Processor - 8 ) ( 5/8 );

ConcGCThreads = (ParallelGCThreads + 3)/4

24个处理器,小于8个处理器时ParallelGCThreads按处理器数量,大于时按上述公式YGC线程数=18, CMS GC线程数=5。

CMS GC线程数的公式太怪,也有人提议简单改为YGC线程数的1/2。

一些不在乎停顿时间的后台辅助程序,比如日志收集的logstash,建议把它减少到2,避免在GC时突然占用太多CPU核,影响主应用。

而另一些并不独占服务器的应用,比如旁边跑着一堆sidecar的,也建议减少YGC线程数。

一个真实的案例,24核的服务器,默认18条YGC线程,但因为旁边有个繁忙的Service Mesh Proxy在跑着,这18条线程并不能100%的抢到CPU,出现了不合理的慢GC。把线程数降低到12条之后,YGC反而快了很多。 所以那些贪心的把YGC线程数=CPU 核数的,通常弄巧成拙。

不要-XX:+DisableExplicitGC

像R大说的,System GC是保护机制(如堆外内存满时清理它的堆内引用对象),禁了system.gc() 未必是好事,只要没用什么特别烂的类库,真有人调了总有调的原因,所以不应该加这个烂大街的参数。

Reference

关键业务系统的JVM参数推荐

JVM源码分析之Jstat工具原理完全解读

《难道这些 Java 大牛说的都是真的?》

SecureRandom的江湖偏方与真实效果

zk相关文章已经有了三篇
《zookeeper-paxos》
《zookeeper知识结构》
《zookeeper知识结构2-zab协议》

但都没有到具体到应用,此篇弥补一下

talk is cheap,show me the code

client

如何使用zk

除了zk提供原生客户端,还有能过编程方式

zkcli

zkcli原生操作指令比较简单

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
zkcli    连接默认zookeeper服务器

zkcli -server ip:port 连接指定的zookeeper服务器

create -s -e path data [acl] 创建节点,-s表示顺序,-e表示临时,默认是持久节点,acl缺省表示不做任何权限限制

ls path [watch] 显示path下的节点,不递归显示,watch注册监听,命令行可忽视

ls2 path 显示当前节点下的节点和当前节点的属性信息

get path [watch] 获取path的属性信息和数据内容

set path data [version] 更新path的数据内容,version是做类似cas的功能的对应dataversion,命令行可忽略

delete path [version] 删除节点,不能递归删除,只能删除叶子节点

setacl path acl 设置节点acl,例子(scheme:id:password=:perm)-(digest:example:sha-1(base64(pwd))=:cdrwa) create delete read write admin

getacl path 获取path节点的acl

stat path 查看path的属性信息

quit 退出zkcli

ZkClient VS Curator

这两个常用的开源组件

相对zkclient,Curator已经成为Apache的顶级项目,不仅解决了非常底层的细节开发工作,包括连接重连、反复注册Watcher和NodeExistsException异常等,还提供了Zookeeper各种应用场景(Recipe,如共享锁服务、Master选举机制和分布式计算器等)的抽象封装

所以推荐使用curator

应用

主要介绍两种常见情景,一是分布式锁,二是master选举

分布式锁

为什么zk能实现分布式锁?

像redis原理是通过全局key是否存,而zk则是通过其特定的数据结构来实现:利用节点名称的唯一性

ZooKeeper抽象出来的节点结构是一个和unix文件系统类似的小型的树状的目录结构。ZooKeeper机制规定:同一个目录下只能有一个唯一的文件名。例如:我们在Zookeeper目录/jjk目录下创建,两个客户端创建一个名为Lock节点,只有一个能够成功

思路一:持久节点

利用名称唯一性,加锁操作时,只需要所有客户端一起创建/lock节点,只有一个创建成功,成功者获得锁。解锁时,只需删除/lock节点,其余客户端再次进入竞争创建节点,直到所有客户端都获得锁

代码片段

尝试加锁

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
/**
* 尝试加锁,直接创建节点,如果节点创建失败,说明加锁失败
* @param lockName
* @return
*/
public boolean tryLock(String lockName) {
try {
//创建节点
String path = cf.create().creatingParentsIfNeeded().withMode(CreateMode.PERSISTENT).forPath(getLockPath(lockName), lockName.getBytes());
logger.info(Thread.currentThread().getName()+ "try lock success ,path:"+path+" tid:"+Thread.currentThread().getName());
return true;
}catch (KeeperException.NodeExistsException e) {
logger.info("try lock fail,"+" tid:"+Thread.currentThread().getName());
}catch (Exception ex) {
ex.printStackTrace();
}
return false;
}

尝试加锁失败后,阻塞等待

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
/**
* 尝试加锁失败后,阻塞等待
* @param lockName
* @throws Exception
*/
public void waitForLock(String lockName) throws Exception {
//监听子节点
PathChildrenCache pathChildrenCache = new PathChildrenCache(cf, LOCK_PATH, false);
pathChildrenCache.start(PathChildrenCache.StartMode.BUILD_INITIAL_CACHE);
pathChildrenCache.getListenable().addListener(new PathChildrenCacheListener() {
@Override
public void childEvent(CuratorFramework client, PathChildrenCacheEvent event) throws Exception {
switch (event.getType()) {
case CHILD_REMOVED:
logger.info("path:" + event.getData().getPath() + " has removed,start to lock:{}",Thread.currentThread().getName());
countDownLatch.countDown();
break;
default:
//logger.info(" has changeed,{},start to lock:{}",event.getType(),Thread.currentThread().getName());
break;
}
}
});
boolean hasLock = false;
while(!hasLock) {
Stat stat = cf.checkExists().forPath(getLockPath(lockName));
//节点存在,此处与unlock非原子操作,如果在checkExists返回true时刻,成功unlock,那此端无限等待
if (stat == null) {
logger.info("waitForLock not exists:{}", Thread.currentThread().getName());
hasLock = tryLock(lockName);

if(hasLock) {
logger.info("waitForLock get lock :{}", Thread.currentThread().getName());
break;
}
} else {
logger.info("waitForLock exists:{}", Thread.currentThread().getName());
countDownLatch.await();
}
}
}

解锁

1
2
3
4
5
6
public boolean unlock(String lockName) throws Exception {
logger.info("start unlock:" + lockName + " tid:" + Thread.currentThread().getName());
cf.delete().forPath(getLockPath(lockName));
logger.info("end unlock:" + lockName + " tid:" + Thread.currentThread().getName());
return true;
}

这个方案比较简单,但会出现两个问题:

  1. “惊群效应”,所有客户端都是监听这个节点变化,当一端释放锁时,别的端都会抢占
  2. 如果加锁成功的client突然崩溃,那么锁无法正常释放,全局进入死锁状态

思路二:临时有序节点

为了应对上面的问题,可以使用临时有序节点:EPHEMERAL_SEQUENTIAL,之前的篇章中说明了临时节点特点,在client与zk断开连接时,临时节点会自动删除

加锁算法:

  1. 客户端调用create()方法创建名为“/lock”的节点,需要注意的是,这里节点的创建类型需要设置为EPHEMERAL_SEQUENTIAL
  2. 客户端调用getChildren(“lock”)方法来获取所有已经创建的子节点,同时在这个节点上注册上子节点变更通知的Watcher
  3. 客户端获取到所有子节点path之后,如果发现自己在步骤1中创建的节点是所有节点中序号最小的,那么就认为这个客户端获得了锁
  4. 如果在步骤3中发现自己并非是所有子节点中最小的,说明自己还没有获取到锁,就开始等待,直到下次子节点变更通知的时候,再进行子节点的获取,判断是否获取锁

解锁算法:

  • 删除自己创建的那个子节点

代码片段

尝试加锁失败后,阻塞等待

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
public void waitForLock(String lockName) throws Exception {
logger.info("waitForLock {}:{}",beforeNode, Thread.currentThread().getName());
boolean hasLock = false;
while(!hasLock) {
//是不是最小节点
boolean isMin = isMinNode();
if (isMin) {//是 则成功获取锁
logger.info("waitForLock getLock:{}", Thread.currentThread().getName());
break;
} else {
try {
cf.getData().usingWatcher(new Watcher() {
@Override
public void process(WatchedEvent event) {
switch (event.getType()) {
case NodeDeleted:
logger.info("path:" + event.getPath() + " has removed,before:{},start to lock:{}", beforeNode, Thread.currentThread().getName());
countDownLatch.countDown();
break;
}
}
}).forPath(beforeNode);
logger.info("waitForLock waiting:{}", Thread.currentThread().getName());
countDownLatch.await();
logger.info("开始抢占:{},{}", Thread.currentThread().getName(), currentNode);
}catch (KeeperException.NoNodeException e){
logger.info("waitForLock has delete:{},{}",beforeNode, Thread.currentThread().getName());
}
}
}
}

这个方案解决了思路一中的问题

  1. 只监听当前节点的上一个节点,这样就解决了“惊群”现象
  2. 临时节点,当连接断开后,就会自动删除,不会出现过期时间问题

完美了吗?

再回看《剖析分布式锁》。zk实现方式完美了吗?

显然示例中没有达到好锁的标准,更完善的实现可以看看curator中的InterProcessLock

单机

此锁高可用了吗?对比一下Redis,哪种方案更完美?

客户端1发生GC停顿的时候,zookeeper检测不到心跳,也是有可能出现多个客户端同时操作共享资源的情形

redis的最新set指令,zk的临时节点两个性质都是一样的,解决了因过期时间问题引起的死锁

有了“续命丸”方案,在单机情况下,redis更完美些,至少不会出现zk临时节点因session超时提前删除问题

集群

在集群下呢? 线上环境,为了高可用不大会使用单点

如redis的cluster,哨兵模式;但由于Redis的主从复制(replication)是异步的,这可能会出现在数据同步过程中,master宕机,slave来不及同步数据就被选为master,从而数据丢失

  1. 客户端1从Master获取了锁
  2. Master宕机了,存储锁的key还没有来得及同步到Slave上
  3. Slave升级为Master
  4. 客户端2从新的Master获取到了对应同一个资源的锁

RedLock算法

为了应对这个情形, redis的作者antirez提出了RedLock算法,步骤如下(该流程出自官方文档),假设我们有N个master节点(官方文档里将N设置成5,其实大等于3就行)

  1. 获取当前时间(单位是毫秒)
  2. 轮流用相同的key和随机值在N个节点上请求锁,在这一步里,客户端在每个master上请求锁时,会有一个和总的锁释放时间相比小的多的超时时间。比如如果锁自动释放时间是10秒钟,那每个节点锁请求的超时时间可能是5-50毫秒的范围,这个可以防止一个客户端在某个宕掉的master节点上阻塞过长时间,如果一个master节点不可用了,我们应该尽快尝试下一个master节点
  3. 客户端计算第二步中获取锁所花的时间,只有当客户端在大多数master节点上成功获取了锁(在这里是3个),而且总共消耗的时间不超过锁释放时间,这个锁就认为是获取成功了
  4. 如果锁获取成功了,那现在锁自动释放时间就是最初的锁释放时间减去之前获取锁所消耗的时间
  5. 如果锁获取失败了,不管是因为获取成功的锁不超过一半(N/2+1)还是因为总消耗时间超过了锁释放时间,客户端都会到每个master节点上释放锁,即便是那些他认为没有获取成功的锁

缺陷

比如一下场景,两个客户端client 1和client 2,5个redis节点nodes (A, B, C, D and E)。

  1. client 1从A、B、C成功获取锁,从D、E获取锁网络超时
  2. 节点C的时钟不准确(如时钟跳跃),导致锁快速超时(算法第4点)
  3. client 2从C、D、E成功获取锁,从A、B获取锁网络超时
  4. 这样client 1和client 2都获得了锁

对于步骤2,还有一种情况,比如节点C崩溃重启了,但客户端1在C上加的锁没有持久化下来,丢失了;节点C重启后,client2从C、D、E成功获取锁

对于这两种情况,redis作者antirez给出了两种人为补偿措施

  • 一时钟问题,不允许人员修改时间
  • 二节点重启,提出延迟重启的概念,即一个节点崩溃后,先不立即重启它,而是等待一段时间再重启,等待的时间大于锁的有效时间。采用这种方式,这个节点在重启前所参与的锁都会过期,它在重启后就不会对现有的锁造成影响

Redlock的问题,最关键的一点在于Redlock需要客户端去保证写入的一致性,后端5个节点完全独立,所有的客户端都得操作这5个节点。如果5个节点有一个leader,客户端只要从leader获取锁,其他节点能同步leader的数据,这样,分区、超时、冲突等问题都不会存在。所以为了保证分布式锁的正确性,我觉得使用强一致性的分布式协调服务能更好的解决问题

而强一致问题,zk可以完成,zk是个CP系统,zk内部机制就保证了各数据的一致性

分布式锁

到此,对分布式锁的实现可以总结一下

zookeeper可靠性比redis强太多,只是效率低了点,如果并发量不是特别大,追求可靠性,首选zookeeper

为了效率,则首选redis实现

总结

除了分布式锁,还有一个常用场景:master选举。在curator中也有相应封装:LeaderSelector;具体实现可以自行阅读源码

参考资料

基于zookeeper的分布式锁

《Is Redlock safe?》

《How to do distributed locking》

curator使用说明

分布式锁实现抉择

聊一聊分布式锁的设计