码农戏码

新生代农民工的自我修养

0%

RPC

原理

image

什么是Stub?

Stub是一段代码,用来转换RPC过程中传递的参数。处理内容包括不同OS之间的大小端问题。另外,Client端一般叫Stub,Server端一般叫Skeleton。

生产方式:

  1. 手动生成,比较麻烦;
  2. 自动生成,使用IDL(InterfaceDescriptionLanguate),定义C/S的接口

RPC的套路:

自古深情留不住 唯有套路留人心

RPC最本质的就是通过socket把方法信息传输到远程服务器并执行相应method

在java界的rpc框架的实现手法:

  • 服务端:socket + 反射
  • 客户端:动态代理 + socket

之前也解析过motain框架,《motain客服端分析》、《motain服务端分析》

thrift

由于我司框架是通过thrift改造,发现这个框架没有按java套路出牌,可能这是跨语言类RPC的套路,有必要了解一下

thrift最初由facebook开发用做系统内各语言之间的RPC通信 。2007年由facebook贡献到apache基金 ,08年5月进入apache孵化器,支持多种语言之间的RPC方式的通信:php语言client可以构造一个对象,调用相应的服务方法来调用java语言的服务 ,跨越语言的C/S RPC调用   

thrift

示例

IDL文件

1
2
3
4
5
//HelloService.thrfit
namespace java com.jack.thrift
service HelloService{
string helloString(1:string what)
}

生成代码

1
2
运行  thrift -gen HelloService.thrfit

会生成一个HelloService类

实现服务端与客服端

让服务端打印出客户端传入的参数

服务端

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public class ThriftServer {

/**
* 启动thrift服务器
* @param args
*/
public static void main(String[] args) throws Exception {
try {
System.out.println("服务端开启....");
TProcessor tprocessor = new HelloService.Processor<HelloService.Iface>(new HelloServiceImpl());
// 简单的单线程服务模型
TServerSocket serverTransport = new TServerSocket(9898);
TServer.Args tArgs = new TServer.Args(serverTransport);
tArgs.processor(tprocessor);
tArgs.protocolFactory(new TBinaryProtocol.Factory());
TServer server = new TSimpleServer(tArgs);
server.serve();
}catch (Exception e) {
e.printStackTrace();
}
}

}

客户端

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
public class ThriftClient {

public static void main(String[] args) {
System.out.println("客户端启动....");
TTransport transport = null;
try {
transport = new TSocket("localhost", 9898, 30000);
// 协议要和服务端一致
TProtocol protocol = new TBinaryProtocol(transport);
HelloService.Client client = new HelloService.Client(protocol);
transport.open();
String result = client.helloString("哈哈");
System.out.println(result);
} catch (TTransportException e) {
e.printStackTrace();
} catch (TException e) {
e.printStackTrace();
} finally {
if (null != transport) {
transport.close();
}
}
}
}

解析

可以看出server,client代码相对很简单,主要看看生成的HelloService类,这个类就是stub代码

来看一下,这个类是如何封装,把method和args传输到远程的

client

1
2
HelloService.Client client = new HelloService.Client(protocol);
String result = client.helloString("哈哈");

关键点在HelloService.Client.helloString()方法

1
2
3
4
5
public String helloString(String what) throws org.apache.thrift.TException
{
send_helloString(what);
return recv_helloString();
}

发送消息

1
2
3
4
5
6
public void send_helloString(String what) throws org.apache.thrift.TException
{
helloString_args args = new helloString_args();
args.setWhat(what);
sendBase("helloString", args);
}
  1. 把args抽象成了一个类
  2. 属性赋值
  3. 发送

主要看下sendBase()方法

1
2
3
4
5
6
private void sendBase(String methodName, TBase<?,?> args, byte type) throws TException {
oprot_.writeMessageBegin(new TMessage(methodName, type, ++seqid_));
args.write(oprot_);
oprot_.writeMessageEnd();
oprot_.getTransport().flush();
}
  • 1.oprot_.writeMessageBegin 根据Protocol写数据,比如这儿使用的TBinaryProtocol,以二进制写数据
1
2
3
4
5
6
7
8
9
10
11
12
public void writeMessageBegin(TMessage message) throws TException {
if (strictWrite_) {
int version = VERSION_1 | message.type;
writeI32(version);
writeString(message.name);
writeI32(message.seqid);
} else {
writeString(message.name);
writeByte(message.type);
writeI32(message.seqid);
}
}

再深入看看怎么写二进制数据的

int类型

1
2
3
4
5
6
7
public void writeI32(int i32) throws TException {
inoutTemp[0] = (byte)(0xff & (i32 >> 24));
inoutTemp[1] = (byte)(0xff & (i32 >> 16));
inoutTemp[2] = (byte)(0xff & (i32 >> 8));
inoutTemp[3] = (byte)(0xff & (i32));
trans_.write(inoutTemp, 0, 4);
}

string类型,先写长度,再写bytes

1
2
3
4
5
6
7
8
9
public void writeString(String str) throws TException {
try {
byte[] dat = str.getBytes("UTF-8");
writeI32(dat.length);
trans_.write(dat, 0, dat.length);
} catch (UnsupportedEncodingException uex) {
throw new TException("JVM DOES NOT SUPPORT UTF-8");
}
}

这儿写最终还是使用Transport.write,比如这儿使用的TSocket

1
2
3
4
5
6
7
8
9
10
public void write(byte[] buf, int off, int len) throws TTransportException {
if (outputStream_ == null) {
throw new TTransportException(TTransportException.NOT_OPEN, "Cannot write to null outputStream");
}
try {
outputStream_.write(buf, off, len);
} catch (IOException iox) {
throw new TTransportException(TTransportException.UNKNOWN, iox);
}
}

就是写到

1
outputStream_ = new BufferedOutputStream(socket_.getOutputStream(), 1024);
  • 2.args.write(oprot_);
1
2
3
4
5
6
7
8
9
10
11
12
public void write(org.apache.thrift.protocol.TProtocol oprot, helloString_args struct) throws org.apache.thrift.TException {
struct.validate();

oprot.writeStructBegin(STRUCT_DESC);
if (struct.what != null) {
oprot.writeFieldBegin(WHAT_FIELD_DESC);
oprot.writeString(struct.what);
oprot.writeFieldEnd();
}
oprot.writeFieldStop();
oprot.writeStructEnd();
}

这就是写field,也就是向输出流里写参数内容

  • 3.oprot_.writeMessageEnd();
    这表示消息写完成了,各个协议处理不同,比如二进制就是空实现,但如json就需要写个”}”,以完成json格式

  • 4.oprot_.getTransport().flush(); 直接flush

1
2
3
4
5
6
7
8
9
10
11
12
13
/**
* Flushes the underlying output stream if not null.
*/
public void flush() throws TTransportException {
if (outputStream_ == null) {
throw new TTransportException(TTransportException.NOT_OPEN, "Cannot flush null outputStream");
}
try {
outputStream_.flush();
} catch (IOException iox) {
throw new TTransportException(TTransportException.UNKNOWN, iox);
}
}

client总结

整个发送消息就结束了,虽然没有按套路使用动态代理,而是通过生成的stub代码,把methodName,args给封装好了

server

服务端也没有通过反射的方式

主要逻辑在生成的HelloService$Processor类中

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
public static class Processor<I extends Iface> extends org.apache.thrift.TBaseProcessor<I> implements org.apache.thrift.TProcessor {
private static final org.slf4j.Logger _LOGGER = org.slf4j.LoggerFactory.getLogger(Processor.class.getName());
public Processor(I iface) {
super(iface, getProcessMap(new java.util.HashMap<String, org.apache.thrift.ProcessFunction<I, ? extends org.apache.thrift.TBase>>()));
}

protected Processor(I iface, java.util.Map<String, org.apache.thrift.ProcessFunction<I, ? extends org.apache.thrift.TBase>> processMap) {
super(iface, getProcessMap(processMap));
}

private static <I extends Iface> java.util.Map<String, org.apache.thrift.ProcessFunction<I, ? extends org.apache.thrift.TBase>> getProcessMap(java.util.Map<String, org.apache.thrift.ProcessFunction<I, ? extends org.apache.thrift.TBase>> processMap) {
processMap.put("helloString", new helloString());
return processMap;
}

public static class helloString<I extends Iface> extends org.apache.thrift.ProcessFunction<I, helloString_args> {
public helloString() {
super("helloString");
}

public helloString_args getEmptyArgsInstance() {
return new helloString_args();
}

protected boolean isOneway() {
return false;
}

@Override
protected boolean handleRuntimeExceptions() {
return false;
}

public helloString_result getResult(I iface, helloString_args args) throws org.apache.thrift.TException {
helloString_result result = new helloString_result();
result.success = iface.helloString(args.what);
return result;
}
}

}
  • 1.先看构造函数
1
2
3
4
5
6
7
8
protected Processor(I iface, java.util.Map<String, org.apache.thrift.ProcessFunction<I, ? extends org.apache.thrift.TBase>> processMap) {
super(iface, getProcessMap(processMap));
}

private static <I extends Iface> java.util.Map<String, org.apache.thrift.ProcessFunction<I, ? extends org.apache.thrift.TBase>> getProcessMap(java.util.Map<String, org.apache.thrift.ProcessFunction<I, ? extends org.apache.thrift.TBase>> processMap) {
processMap.put("helloString", new helloString());
return processMap;
}

这段把methodName与对应的处理类映射,那后面的事就简单了,当接受到消息,取得methodName,通过map获取对就的处理类回调就可以

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
public static class helloString<I extends Iface> extends org.apache.thrift.ProcessFunction<I, helloString_args> {
public helloString() {
super("helloString");
}

public helloString_args getEmptyArgsInstance() {
return new helloString_args();
}

protected boolean isOneway() {
return false;
}

@Override
protected boolean handleRuntimeExceptions() {
return false;
}

public helloString_result getResult(I iface, helloString_args args) throws org.apache.thrift.TException {
helloString_result result = new helloString_result();
result.success = iface.helloString(args.what);
return result;
}
}

处理类,继承ProcessFunction类,实现getResult(),这个方法就是调用了对应service.helloString()

可以再深入看一下,在socket监听消息时

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
client = serverTransport_.accept();
if (client != null) {
processor = processorFactory_.getProcessor(client);
inputTransport = inputTransportFactory_.getTransport(client);
outputTransport = outputTransportFactory_.getTransport(client);
inputProtocol = inputProtocolFactory_.getProtocol(inputTransport);
outputProtocol = outputProtocolFactory_.getProtocol(outputTransport);
if (eventHandler_ != null) {
connectionContext = eventHandler_.createContext(inputProtocol, outputProtocol);
}
while (true) {
if (eventHandler_ != null) {
eventHandler_.processContext(connectionContext, inputTransport, outputTransport);
}
if(!processor.process(inputProtocol, outputProtocol)) {
break;
}
}

关键行:processor.process(inputProtocol, outputProtocol)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public boolean process(TProtocol in, TProtocol out) throws TException {
TMessage msg = in.readMessageBegin();
ProcessFunction fn = processMap.get(msg.name);
if (fn == null) {
TProtocolUtil.skip(in, TType.STRUCT);
in.readMessageEnd();
TApplicationException x = new TApplicationException(TApplicationException.UNKNOWN_METHOD, "Invalid method name: '"+msg.name+"'");
out.writeMessageBegin(new TMessage(msg.name, TMessageType.EXCEPTION, msg.seqid));
x.write(out);
out.writeMessageEnd();
out.getTransport().flush();
return true;
}
fn.process(msg.seqid, in, out, iface);
return true;
}

这就很明显了,通过methodName从map中取得ProccessFunction,再执行process方法,调用相应service的方法

总结

虽然thrift没有按以往java套路出牌,但最根本的把method发送到远程执行是一致的。可能对于多语言来讲,便于所以语言一致性,的确需要通过生成的stub代码手法来实现RPC

当然thrift并不简单,还有很多的内容需要深挖学习,但至少这个简单示例可以了解跨语言型的RPC,相关IDL,Stub的知识,有清晰认知,而不局限于概念

软技能-代码之外的生存技能

《软技能-代码之外的生存技能》这本书可以算是《原则》的实践指南,作者对每一个建议都是事无巨细地指导方案,虽然此书对任何职业都有指导意义,但由于作者程序员的身份,让此书对程序员的实际指导更具体明确

之前《软技能笔记之职业与学习》写了职业与学习,这一篇写营销与生产力;作者的做法有些真是自己实践的,莫名喜感

营销

酒香也怕巷子深,尤其追求实干的程序员是得掌握一些自我营销知识

营销

营销就是一场争夺人们注意力的竞赛

凡人听到营销都会皱眉头,名声实在是不怎么样。
但实事上营销追求的是“实现价值在先,要求回报在后”

价值在先

自我营销的正确方式就是为他人提供价值,学习如何控制好自己要传达的信息,塑造好自己的形象,扩展信息送达的人群

营销并不能确保你一定成功,但是它却是你可控的重要元素

基本机制:要想让人们追随你、倾听你、你就要带给他们价值:为他们的问题提供答案,甚至是给他们带去欢乐

正视自我

我不是专家,没什么可营销的 ———— 其实很多人都喜欢向只比自己稍微优秀一点点的人学习,因为这些人才是可望而又可及的

一直谈论自己并试图证明自己价值连城。然而,你会发现,能解决他人的问题,真正能够帮到他人,你更容易获得成功。

营销手段

  1. 创建品牌:品牌即承诺,承诺按照你预期的方式交付你所预期的价值
  2. 打造博客:最大秘诀有且仅有一个————持之以恒,持之以恒地坚持写作,坚持不懈地产生高品质的内容。感觉没什么可写,提前头脑风暴出各种不同的想法,每当有新想法时,添加到主题列表中
  3. 演讲
  4. 著书立说

思维障碍

看起来像个傻瓜
这种想法,我也有,比如我不太敢主动分享自己的文章,感觉写得傻,蠢。好在我没有不行动,还在坚持写

在我的职业生涯中,我一共错失了9000多次投篮,输掉了近300场比赛。我本来有26次绝杀的机会却投球不进。我失败了一次又一次。 这就是我能够成功的原因 ————迈克尔 乔丹

生产力

这是一个快速变化的世界,时间飞逝,人难免焦虑,很多人会去看很多关于时间管理方面的书籍,但时间真的能管理吗?

如何让自己成为一台性能卓越、品质出众的超级高效机器

这一章节,有些地方给了新的认知,有些我自己也在践行,效果不错,值得一试

制定计划

这不用再重复,没有方向的油轮,是永远到达不了目的地的

可以从季度计划、月计划、周计划、日计划,让自己知道在前进,在向目标靠近

番茄工作法

我没有实践过这个方式,但对于程序员来讲,看一段源码,分析一个bug,几个小时的专注其实没什么问题,有时真是废寝忘食。

所以我对此方法也不屑,但作者的看法,带给我启发

番茄工作法只有被当作估算和评估工作的工具时,才能发挥它的真正威力

制订任务列表全凭主观臆断,每天能够专注完成的工作量才是最重要的

其实作者的意思就是要对自己的专注能力进行量化。量化是很重要的,大多数都是感觉,比如感觉自己很专注的工作,但真正专注了多久呢?上班8小时,专注了几个小时?一周专注工作几个小时?平均每个月能专注多久,这些是需要量化的。

无论是制订目标,还是改进目标,都需要量化,数据说话。就好比性能优化,不能凭感觉,而得给出具体数据。

我很认同作者的这个提法,所以现在也在尝试看自己一天能专注几个番茄时间

定额工作法

制订一些固定周期内的工作

比如:

  1. 每周跑3次
  2. 每周一篇博客
  3. 每天学习一个算法

实现定额制后,我发现自己的工作成果比以往多了很多。最大好处在于,长期坚持这么做,我就能随着时间的推移度量并标记自己的进度。可以确切知道自己在给定的一段时间内能够完成的工作量。

承诺是“定额工作法”的核心这其实很明显,太多人是只会制订计划,但从不执行,所以这是对自己的一份承诺

可以帮助克服意志力薄弱的问题,通过预先设定好的必须要遵循的过程,消除需要做出决策的部分

为什么这种工作法有效呢?

以缓慢但稳定的节奏工作,要优于快速但缺乏持久和坚持的工作方式

这个方法,其实我就在使用,比如我自己制定每月至少写一篇读书笔记、一篇技术学习

开始很难做到,但会时时提醒自己,要去读书,对学习的技术,要做整理。

这样坚持一段时间后,现在我算是超额完成,现在每月要写四篇文章,两篇关于读书的,两篇关于技术的

这样也正好呼应了营销部分的打造一个好博客,坚持写作,一个月四篇,一年就有48篇。其实看看一年量化,这数字也太少了,但要完成还真不容易

有了对自己的承诺,会强迫自己去兑现

保持激情

是人就会疲惫,新鲜感会消退,产生倦怠

想起李笑来的一句话,任何事,前期都不要用力过猛,越猛后期就越乏力

赛跑比的是谁耐力更长久,而不是看谁冲刺更有力

所以生产力的真正秘诀在于:长期坚持做一些小事


延伸阅读:

《软技能笔记之职业与学习》

《原则》读书笔记

之前看的《原则》,有没有感觉作者的原则都对,但实际操作又感觉假大空呢?

软技能-代码之外的生存技能

《软技能-代码之外的生存技能》这本书可以算是《原则》的实践指南,作者对每一个建议都是事无巨细地指导方案,虽然此书对任何职业都有指导意义,但由于作者程序员的身份,让此书对程序员的实际指导更具体明确

呐喊

这是此前github上很火的评论,我称之为程序员的呐喊

程序员呐喊

人人都推崇要活到老,学到老。但当真正身陷这个快速变更的行业里,有时难免出现焦躁,疲惫感

如何应对职业生涯,制定好职业规划,是此行业从业者们必须时时反省的话题

软技能

软技能

作者从七个方面阐述他的思想,这一篇记录下职业与学习两方面

职业

职业

作者阐述了在职业中可能遇到的各种问题,面面俱到

心态

不想当将军的士兵不是好士兵

把自己看成老板,不要安逸于一名职员

把自己当做一个软件企业,把雇主当做企业的一个客户,你应当能够提供某种产品或者服务

目标

《原则》中人生五步中的第一步就是确定目标,大多数人没有目标,但更大的问题是不知道如何确定目标

人际交往

talk is cheap ,show me the code

程序员特殊崇尚这句话,喜欢静静的写代码!但实事上每个职业工作都是与人打交道

code is cheap , show me the answer

面试

最快捷的方式让面试官不见其人先闻其声

让面试官对你怀有好感

学习

教育就是当一个人把在学校所学全部忘光之后剩下的东西 –爱因斯坦

教育的首要目标,并不在于“知”而在于“行” –赫伯特 斯宾塞

三要素

为了能够掌握一门技术,我需要了解以下三个要点

  1. 如何开始 ——要想开始使用自己所学的,我需要掌握哪些基本知识?
  2. 学科范围 ——我现在学的东西有多宏大?我应该怎么做?在开始阶段,我不需要了解每个细节,但是如果我能对该学
    科的轮廓有大致的了解,那么将来我就能发现更多细节。
  3. 基础知识 ——不止在开始阶段,要想使用一项特定的技术,我需要了解基本的用户案例和最常见的问题,也需要知道
    自己学的哪20%就能满足80%的日常应用

学习十步法

学习十步法

从第一步到第六步:这些步骤只用作一次!

第一步:了解全局

通常完成这一步我们可以使用网络搜索来完成大量的研究。在这一阶段我们只需要对要学习的东西有一个大概的了解即可。

第二步 :确定范围

现在我们在对我们要学习的东西有一定的了解的情况下,接下来就要集中精力去明确自己到底想要学习什么?在任何项目中,明确项目范围是至关重要的,唯有这样才能了解项目的全局,做好相应的准备工作。

而在这样的一个过程中,我们都很容易犯一个错误就是试图解决太大的问题而把自己搞得不堪重负,因此,我们要明确自己的学习范围。为此我们需要运用在第一步中所获得信息来让自己的关注点落脚在更小也可控制的范围内。

但在此过程中,我们可能会受到诱惑,为了学习该主题下不同的主题,我们可能会扩张自己的学习范围导致自己不够聚焦,所以请务必的抵制这个有诱惑,尽可能的保持专注,一次只能学习一样东西。我们可以稍后再回头学习别的领域的分支。

最后,请一定注意:明确学习范围的时候要考虑的时间因素,你的学习范围务必大小适当,既能够符合你的学习理由,又能符合你的时间限制。

第三步:定义目标

在我们全力以赴之前,明确“成功”的含义极为重要。如果不知道成功是什么样子,很难找准目标,也很难知道自己什么时候已经真正达到目标。所以在当你知道自己的目标是什么的时候,你就可以更轻松的使用倒推方式,明确实现目标所需要的步骤。

这一步的目标是形成一份简明清晰的陈述,勾勒出你勤奋学习后的成功图景。但是一定要确保其中包含的的具体成功标准,从而能让你用来充分评估自己是否已经达成学习目标。

好的目标应该是具体的,无二义性的,不要对自己想要完成的任务进行含糊不清的描述

你想从自己的学习经历中获取什么决定了你的成功标准是什么。请确保你能借此在学习结束后评估自己是否达成目标。好的成功标准也能让你向着既定目标不断前进。

第四步:寻找资源

要尝试收集到多种多样的资源来帮助你学习,而不是只读一本关于这一主题的书。资源是多种多样的,不局限于书籍。现在随着网络的广泛应用,你几乎可以针对自己感兴趣的人和主题找到大量的资源。

在这一不中,你要尽可能多的寻找自己所选择的相关资料,而且此时你无需考虑这些资源的质量。在你寻找过后,你要对你找到的这些资源进行过滤,去伪存真。

如果你不想因为单一来源的信息而产生偏见,那你就尽可能的去获取各种各样的信息吧。

第五步:创建学习计划

好的技术书都遵循着这样的规律:打好基础,做好铺垫,然后逐个展开每一章的论述。对于大多数学科而言,学习是一个自然的过程。从A开始,前进到B,然后到达Z。这个顺序对你掌握随机的碎片化知识价值不大。你需要找出在最短时间内从A到Z的正确路径,并且到达沿途的重要地标。

在这一步,你需要创建自己的学习路径。把它看作自己写作时候打大纲。

打造自己的学习计划,一个好方法就是借鉴吸取他人的方法,我们这时候可以翻看自己在第四步找来的资料,看看他们是如何学习这个主题的,如果很多不同的作者都把内容分解为相同的模块和顺序,你不妨可以去试一试,效仿他们去做一个自己的学习计划。

第六步:筛选资源

现在,我们知道自己要学习什么,按照什么样的方式去学习,那么是时候决定要使用哪些资源来完成自己的学习任务。现在时候对这些资源进行筛选,挑选最有价值记的几项来帮至自己实现目标。

在这一步中,把我们在第四步中收集的全部资料浏览一遍,找出哪些内容能够覆盖自己的学习计划。

一旦完成了这一步,我们就可以准备进到学习计划中的第一个模块了!

但在我们实现自己的目标之前,我们还需要为每个模块重复第7步到第10步。

第七步到第十步:循环往复(学习——实践——掌握——教授)

第七步: 开始学习,浅尝辄止

在这一步中,我们的目标是获得足够多的与所学主题相关的信息,从让能让我们开始学习,并在下一步中动手操作。

这一步的关键在于过犹不及。我们通常会很容易的就失去自控力,开始消化计划学习中列出的所有资源。但是你会发现,如果你能经受住这样的诱惑,你会取得更大的成就。你要专注于掌握自己所需的、能再下一步动手操作的的最小量的知识。

第八步:动手操作,边学边玩

这一步既有趣又可怕。说它有趣是因为你真的是在玩耍,说它可怕是因为这一步完全没有边际。在一部没有任何规则,你可以做任何你想做的事情,如何更好地实施这一步,完全由你来决定。

大多数人会尝试通过读书或者观看视频来掌握某个主题,他们会提前吸收很多信息,然后再付诸实践。这一方法的问题在于,在他们读书或者看视频的时候,他们并不知道哪些内容是重点。他们只是在因循他们设计好的学习路径。

现在,我们无需提前了解全部内容,你要做的首要的一件事情就是亲自操作和亲身体验。采用这种方法,你通过探索和时间学习。在操作的过程中,你的大脑自然地产生各种各样的问题:它是如何工作的?如果我这么做,它会发生什么?我该如何解决这个问题?这些问题引导着你走向真正重要的方向。当你回过头来寻找问题答案的时候,不只是这些问题迎刃而解,而且你记得的东西比你学习的东西要多得多,因为你所学到的都是对你很重要的东西。

在这一步中,不要担心结果,勇敢探索吧。

第九步:全面掌握,学以致用

好奇心是学习特别是自学的重要组成部分。这一步的目标就是让你找会好奇寻驱动的学习。在第八步中,你通过动手操作发现了一些尚未找到的答案的问题。现在时候来回答这些问题了,在这一步中,你要利用先前收集到的所有资料,进行深入学习。

为了有效利用自己选择的材料,为上一步产生的问题寻找答案,阅读文字、观看视频、与他们交流都是必要的手段。这能让你沉浸在学习材料中,尽可能地汲取知识。

不要害怕回头再去操作,付出更多,因为这不仅能让你找到问题的答案,也能让你学到新的东西。给自己足够多的时间去深入理解自己的主题,你可以阅读,可以实验,可以观察,也可以操作。

不过请记住,你依然没有必要把收集到的所有资料全部仔细看看一遍。你只需要阅读或者观看与当前所学有关的部分。

最后请不要忘了,你在第三步中定义的成功标准。试着把自己正在学习的内容与最终目标关联起来。你掌握的每个模块,都应该以某种方式推动你向着终极目标前行。

第十步:乐为人师,融会贯通

如果你真的想深入地掌握一门学问,想对这门学问做到融会贯通,那么你必须要做到”好为人师“。除此之外,别无他法。

在现实中,你只需要超前别人一步,就可以成为他们的老师。有时候,比学生超前太多的专家反而不能得心应手的教,因为他们无法与学生产生共鸣。他们忘记了初学者是什么样子,很容易专注于他们认为简单的细节。

在这一步中,我们要要求自己走出自己的舒适区,将自己学到的知识教给别人。要想确定你确实掌握了某些知识,这是唯一的办法;同时在我们将自己所学到的东西介绍给别人时,这也是查缺补漏的好办法。

你可以通过很多方式将自己所学交给他们。你可以写博客,也可以制作视频。你也可以跟自己的舍友,基友,爱人以及朋友探讨,将自己所学解释给他们。

重点在于,你要花时间将自己学到的东西从大脑提取出来,以别人能理解的方式组织起来。在经历这整个过程之后,你会发现,有很多你以为自己明白了的知识点,其实并没有摸透。于是你将会将那些以前自己没太明白的东西联系起来,并且简化自己大脑中已有的信息,将它们浓缩并经常复习。

后续

这篇只记录了职业与学习两部分,后续还有程序员更需要关注的营销部分

标题有些吸引眼球了,但并不浮夸,甚至还会远远超过百万,现在的平均响应时间在1ms内,0.08ms左右

如此高的QPS,如此低的AVG,为什么会有如此效果,关键点还是在多级缓存上

在开发高并发系统时有三把利器用来保护系统:缓存、降级和限流

概述

查询过程

上图基本上就是查询的通用方案,缓存中是否存在,存在就返回,不存在再查询Db,查询到的结果load进缓存

实践

缓存,逃不过三种操作,创建、查询、删除

此实践可能不保证全场景通用,但满足当前系统各项指标,当然没有完美的方案,只有适合的方案。

下面的时序图中,cache lv1是指本地缓存,cache lv2是cache cluster

查询

查询

查询过程:

从一级缓存开始查,如果没有,再向下一级查询,直到db

注意点:

  1. 一直查到db时,需要回源各级cache
  2. 防止击穿,需要在cache中填充value

创建

创建

创建过程:

  1. 创建cacheObject
  2. 放入Db(为了性能,以及db的降级,这儿可以引入异步开关)
  3. 放入cache lv2
  4. 放入cache lv1
  5. publish创建成功消息
  6. 消息监听服务会通知其它服务更新本地缓存

注意点:

  1. 到底是先放入Db,还是先放入cache
  2. db与cache的一致性保障

删除

删除

删除过程:

  1. 通过key查询cacheobject
  2. 清除db
  3. 清除各级cache
  4. publish消除成功消息
  5. 监听服务清除其它服务的本地缓存

注意点:

  1. 先清除db还是cache
  2. Db与cache的一致性保障

缓存操作模式

除了创建,查询,删除,还有更新操作;但我们业务场景没有。

对于我们的实践是不是放之四海而皆准,肯定是不行的。不以业务为基础的设计都是无根之木

先看下业界常见的操作缓存模式

更新缓存的的Design Pattern有四种:Cache aside, Read through, Write through, Write behind caching

Cache aside

  • 失效:应用程序先从cache取数据,没有得到,则从数据库中取数据,成功后,放到缓存中。
  • 命中:应用程序从cache中取数据,取到后返回。
  • 更新:先把数据存到数据库中,成功后,再让缓存失效。

查询

更新

这是标准的design pattern,包括Facebook的论文《Scaling Memcache at Facebook》也使用了这个策略。为什么不是写完数据库后更新缓存?你可以看一下Quora上的这个问答《Why does Facebook use delete to remove the key-value pair in Memcached instead of updating the Memcached during write request to the backend?》,主要是怕两个并发的写操作导致脏数据。

那么,是不是Cache Aside这个就不会有并发问题了?不是的,比如,一个是读操作,但是没有命中缓存,然后就到数据库中取数据,此时来了一个写操作,写完数据库后,让缓存失效,然后,之前的那个读操作再把老的数据放进去,所以,会造成脏数据。

但,这个case理论上会出现,不过,实际上出现的概率可能非常低,因为这个条件需要发生在读缓存时缓存失效,而且并发着有一个写操作。而实际上数据库的写操作会比读操作慢得多,而且还要锁表,而读操作必须在写操作前进入数据库操作,而又要晚于写操作更新缓存,所有的这些条件都具备的概率基本并不大。

Cache Aside,我们的应用代码需要维护两个数据存储,一个是缓存(Cache),一个是数据库(Repository)。所以,应用程序比较啰嗦。而Read/Write Through套路是把更新数据库(Repository)的操作由缓存自己代理了,所以,对于应用层来说,就简单很多了。可以理解为,应用认为后端就是一个单一的存储,而存储自己维护自己的Cache。

Read Through

Read Through 套路就是在查询操作中更新缓存,也就是说,当缓存失效的时候(过期或LRU换出),Cache Aside是由调用方负责把数据加载入缓存,而Read Through则用缓存服务自己来加载,从而对应用方是透明的。

这似乎很像guave的LoadCache

Write Through

Write Through 套路和Read Through相仿,不过是在更新数据时发生。当有数据更新的时候,如果没有命中缓存,直接更新数据库,然后返回。如果命中了缓存,则更新缓存,然后再由Cache自己更新数据库(这是一个同步操作)

Write Back

在更新数据的时候,只更新缓存,不更新数据库,而我们的缓存会异步地批量更新数据库。这个设计的好处就是让数据的I/O操作飞快无比(因为直接操作内存嘛 ),因为异步,write backg还可以合并对同一个数据的多次操作,所以性能的提高是相当可观的。

在wikipedia上有一张write back的流程图,基本逻辑如下:

write back

在游戏开发中基本上都是使用这种模式

但他也有缺点:

  1. 数据不是强一致性
  2. 数据可能会丢失
  3. 逻辑比较复杂

争论

  1. 一致性问题
    这儿的一致性是说强一致性,在分布式环境下,保证强一致性促使系统复杂性增加,或者性能有所下降。所以现在一般对非强制性业务场景都使用最终一致性解决。一致性的解读可以看看《zookeeper-paxos》,在我们实践时,在删除操作时,在清理失败时也通过补偿操作去尝试清除。
  2. 到底是update cache,还是delete cache
    其实任务技术手段都是看业务场景的,不能一概而论
    • update cache
      这个在并发写时,A1写db,B1写db,B2写cache,A2写cache;这时就出现db与Cache不一致的问题
      主动更新缓存,如果cacheobject复杂,需要Db与cache的多次交互,虽然减少了一次cache miss,但却增加了系统复杂度,得不偿失
    • delete cache
      这个不会有不一致问题了,但会造成cache miss,会不会造成热key穿透?
  3. 是先操作Db,还是cache
    假设先操作cache,再操作db;A B并发操作,A1 delete cache; B1 get cache –> miss –> select db –> load cache;A2 delete db;
    此种情况就出现此key一直有效状态,如果没有设置超时时间,那会长期在缓存中。这是不是得先操作db呢?
    一个操作先update db,再delte cache时失败了;那会数据库里是新数据,而缓存里是旧数据,业务无法接受。那是不是该先操作缓存呢?

是不是已经晕头了呢?

再有db主从架构中,主从不一致的情况,是不是没法玩了

所以还是开篇讲的没有放之四海而皆准的方案,只能寻找最适合的方案

在各种业务场景下,还是需要去寻找一些最佳实践,比如关注一下缓存过期策略、设置缓存过期时间

参考资料

缓存更新的套路

A beginner’s guide to Cache synchronization strategies

此篇算是对《voliatile,synchronized,cas》理论的一种实践

全局引用场景

单例模式

不用讲,这是首先想到的方式。

饿汉式 static final field

1
2
3
4
5
6
7
8
9
public class Singleton{
//类加载时就初始化
private static final Singleton instance = new Singleton();

private Singleton(){}
public static Singleton getInstance(){
return instance;
}
}

这是最简单又安全的方式。但也有缺点:

  1. 它不是一种懒加载模式(lazy initialization)
  2. 一些场景中将无法使用:譬如 Singleton 实例的创建是依赖参数或者配置文件的,在 getInstance() 之前必须调用某个方法设置参数给它,那样这种单例写法就无法使用了。

静态内部类

1
2
3
4
5
6
7
8
9
public class Singleton {  
private static class SingletonHolder {
private static final Singleton INSTANCE = new Singleton();
}
private Singleton (){}
public static final Singleton getInstance() {
return SingletonHolder.INSTANCE;
}
}

这种写法仍然使用JVM本身机制保证了线程安全问题;由于 SingletonHolder 是私有的,除了 getInstance() 之外没有办法访问它,因此它是懒汉式的;同时读取实例的时候不会进行同步,没有性能缺陷;也不依赖 JDK 版本

双重检验锁

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class Singleton {
private volatile static Singleton instance; //声明成 volatile
private Singleton (){}
public static Singleton getSingleton() {
if (instance == null) {
synchronized (Singleton.class) {
if (instance == null) {
instance = new Singleton();
}
}
}
return instance;
}

}

这个写法得注意到volatile

主要在于instance = new Singleton()这句,这并非是一个原子操作,事实上在 JVM 中这句话大概做了下面 3 件事情。

  1. 给 instance 分配内存
  2. 调用 Singleton 的构造函数来初始化成员变量
  3. 将instance对象指向分配的内存空间(执行完这步 instance 就为非 null 了)
    但是在 JVM 的即时编译器中存在指令重排序的优化。也就是说上面的第二步和第三步的顺序是不能保证的,最终的执行顺序可能是 1-2-3 也可能是 1-3-2。如果是后者,则在 3 执行完毕、2 未执行之前,被线程二抢占了,这时 instance 已经是非 null 了(但却没有初始化),所以线程二会直接返回 instance,然后使用,然后顺理成章地报错。

声明为volatile,使用其一个特性:禁止指令重排序优化。

也就是说,在 volatile 变量的赋值操作后面会有一个内存屏障(生成的汇编代码上),读操作不会被重排序到内存屏障之前。比如上面的例子,取操作必须在执行完 1-2-3 之后或者 1-3-2 之后,不存在执行到 1-3 然后取到值的情况。从「先行发生原则」的角度理解的话,就是对于一个 volatile 变量的写操作都先行发生于后面对这个变量的读操作(这里的“后面”是时间上的先后顺序)。

volatile的更多特性,可以看一下上篇文章《voliatile,synchronized,cas》

间接被引用情景

需要创建一次的对象不是直接被全局的引用所引用,而是间接地被引用。经常有这种情况,全局维护一个并发的ConcurrentMap, Map的每个Key对应一个对象,这个对象需要只创建一次

CAS

1
2
3
4
5
6
7
8
9
10
11
12
13
14
private final ConcurrentMap<String, InstanceObject> cache
= new ConcurrentHashMap<>();

public InstanceObject get(String key) {
InstanceObject single = cache.get(key);
if (single == null) {
InstanceObject instanceObject = new InstanceObject(key);
single = cache.putIfAbsent(key, instanceObject);
if (single == null) {
single = instanceObject;
}
}
return single;
}

使用这个很可能会产生多个InstanceObject对象,但最终只有一个InstanceObject有用

但并不没有达到仅创建一个的目标

如果创建InstanceObject的成本不高,那也不用太讲究

但一旦是大对象缓存,那么这很可能就是问题了,因为缓存中的对象获取成本一般都比较高,而且通常缓存都会经常失效,那么避免重复创建对象就有价值了

影子类

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
private final ConcurrentMap<String, Future<InstanceObject>> cache1 = new ConcurrentHashMap<>();

public InstanceObject get1(final String key) {
Future<InstanceObject> future = cache1.get(key);
if (future == null) {
Callable<InstanceObject> callable = new Callable() {
@Override
public InstanceObject call() throws Exception {
return new InstanceObject(key);
}
};
FutureTask<InstanceObject> task = new FutureTask<>(callable);

future = cache1.putIfAbsent(key, task);
if (future == null) {
future = task;
task.run();
}
}

try {
return future.get();
} catch (Exception e) {
cache.remove(key);
throw new RuntimeException(e);
}
}

这儿使用Future来代替真实的对象,多次创建Future代价比创建缓存大对象小得多

自旋锁

觉得Future对象还是重了,那就使用更轻的AtomicBoolean,那其实主要使用的还是volatile的特性

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
private final ConcurrentMap<String, AtomicBoolean> spinCache = new ConcurrentHashMap<>();

public InstanceObject getAtomic(final String key) {
InstanceObject single = cache.get(key);
if (single == null) {
AtomicBoolean newBoolean = new AtomicBoolean(false);
AtomicBoolean oldBoolean = spinCache.putIfAbsent(key, newBoolean);
if (oldBoolean == null) {
cache.put(key, new InstanceObject(key));
newBoolean.set(true);
} else {
//其他线程在自旋状态上自旋,等等被释放
while (!oldBoolean.get()) {}
}
single = cache.get(key);
}
return single;
}

总结

保守写法可以使用synchronized,lock,他们的性能也不低;但为了性能极致,可以使用上面的方式。

完整的测试代码:https://github.com/zhuxingsheng/javastudy/blob/master/src/main/java/com/jack/createonlyone/CreateOnlyOneMain.java

遥远的救世主

这本小说有改编的电视剧《天道》,不论是电视剧还是原著小说都看过多次,已经不记得是何缘起结识了这部书籍。

最近有个朋友又提到这部影剧,说影响了他的人生轨迹;使他看到并大概率得到波动命运琴弦的关键及能力

实在是惭愧,读了那么多次,尽然没有如此感悟,收获。再次翻书阅读

以我的学识、阅历,要读懂可能很难,甚至短时间没有可能性。但想想,那又怎么样呢?

如肖亚文所说

认识这个人就是开了一扇窗户,就能看到不一样的东西,听到不一样的声音,能让你思考,觉悟,这已经够了

能有这样一本,让你时时回味,时时思考,有所悟,或有所得的书籍,这已经很好了

文化属性

此书中的故事 皆有“文化属性”而来,那么吸引人的也大体是这个词了。

透视社会依次有三个层面:技术、制度和文化。小到一个人,大到一个国家一个民族,任何一种命运归根到底都是那种文化属性的产物。强势文化造就强者,弱势文化造就弱者,这是规律,也可以理解为天道,不以人的意志为转移

强势文化就是遵循事物规律的文化,弱势文化就是依赖强者的道德期望破格获取的文化,也是期望救主的文化。强势文化在武学上被称为“秘笈”,而弱势文化由于易学、易懂、易用,成了流行品种。

知道又如何?怎么用呢?

无所用,无所不用;比如文化产业,文学、影视是扒拉灵魂的艺术,如果文学、影视的创作能破解更高思维空间的文化密码,那么它的功效就是启迪人的觉悟,震撼人的灵魂,这就是众生所需,就是功德、市场、名利,精神拯救的暴利与毒品麻醉的暴利完全等值,而且不必像贩毒那样耍花招,没有心理成本和法律风险

中国的传统文化是皇恩浩大的文化,它的实用是以皇天在上为先决条件。中国为什么穷?穷就穷在幼稚的思维,穷在期望救主、期望救恩的文化上,这是渗透到民族骨子里的价值判断体系

神话

芮小丹让丁元英在王庙村写个神话

这个世上原本就没有什么神话,神话不过是常人的思维所不易理解的平常事

无论做什么,市场都不是一块无限大的蛋糕。神话的实质就是强力作用的杀富济贫,这就可能产生两个问题,一是杀富是不是破坏性开采市场资源? 二是让井底的人扒着井沿看了一眼再掉下去是不是让他患上精神绝症?

后面的故事,的确如丁所说,乐圣林雨峰自杀了,三个股东之一的刘冰也自杀了。仁人志士一片讨伐之声。

但这事真的神了吗?

从整个事件里,她没有看到丁元英有任何能让人感到“神”的招式,每一件具体的事都是普通人都能做到的普通事

他的确是在公开,公平的条件下合理合法的竞争,一切都是公开的,没有任何秘密和违法可言,所谓的“神话”竟是这么平淡,简单。实事求是就是神话,说老实话,办老实事的人就是神!

神就是道,道就是规律,规律如来,容不得你思议,按规律办事的人就是神

在小说开篇,其实就有实事求是,客观规律的论述

马克思主义的道理归根到底就是一句话:客观规律不以人的意志为转移。什么是客观规律?归根到底也是一句话:一切以时间,地点和条件为转移

造血功能

整本书貌似都在讲述文化属性,以及各种人潜意识里的文化属性结成了整个故事该有的结局

不是说谁本该成为哪种人是规律,而是说谁本该成为哪种人的条件的可能,因果不虚,因果是规、是律,不可思议

但如何解决造血功能呢?这可能是作者留给读者思考的内容,也就是人人都和各自的条件可能。

但如何才能做到事实求是呢?

本质的道作者已经说了很多很次,就是要事实求是,遵循规律,有道无术,术尚可求也,有术无道,止于术

不可思议一词不是众生道里的对神秘事物的描述,而是如是、本来、就是如此,容不得你思议。也是一种告诫、提示,是告诉你不可以思议,由不得你思议。从数学逻辑上说,一加一等于二,容得了你思议吗?不容,这就是告诉你了,一加一等于二是规律,规律是不以人的意志为转移,你只能认识、遵循,不可思议

如果你生活在一个虚妄的世界里,事实求是就很难,就如三个股东一样

观念,传统观念。一是传统的“事实胜于雄辩”的观念,二是传统的疑罪从有的观念,三是传统的青天大老爷的观念。中国人一直接受简单的文化思维教育,他们相信法律是神圣的,决不冤枉一个好人,也决不放过一个坏人

也如芮小丹说讲

只要不是我觉到,悟到的,你给不了我,给了我也拿不住。只有我自己觉到,悟到的我才有可能做到,我能做到的才是我的。

你的知道是自觉,现在是让你觉他。知道这个道理的人很多,但多是呈道理和知识存在。不是自觉。道理和知识是没用的,只是有用的一个条件。用才有用。让你觉他什么?觉他的无明,觉他的道理和知识的没用

通过否定法,至少要去掉两种思维,才可能性拥有造血功能

传统观念的死结就在一个“靠”字上,在家靠父母,出门靠朋友,靠上帝、靠菩萨、靠皇恩… 总之靠什么都行,就是别靠自己。这是一个沉积了几千年的文化属性问题,非几次新文化运动就能开悟。

咱们翻开历史看看,你从哪一行哪一页能找到救世主救世的记录?没有,从来就没有,从来都是救人的被救了,被救的救了人。如果一定要讲救世主的话,那么符合和代表客观规律的文化就是救世主。扶贫的本质在一个扶字,如果你根本就没打算自己站起来,老天爷来了都没用。

看一段线上的gc日志,这是一段CMS完整步骤的日志,对于GC日志格式,不了解的可以再温习一下《GC及JVM参数》

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
2017-07-18T21:28:41.422+0800: 11941915.242: [GC (CMS Initial Mark) [1 CMS-initial-mark: 786446K(1048576K)] 789098K(1992320K), 0.2623622 secs] [Times: user=0.00 sys=0.00, real=0.26 secs] 
2017-07-18T21:28:41.684+0800: 11941915.505: Total time for which application threads were stopped: 0.2630097 seconds, Stopping threads took: 0.0000587 seconds
2017-07-18T21:28:41.685+0800: 11941915.505: [CMS-concurrent-mark-start]
2017-07-18T21:29:02.443+0800: 11941936.263: [CMS-concurrent-mark: 20.758/20.758 secs] [Times: user=3.22 sys=1.67, real=20.75 secs]
2017-07-18T21:29:02.443+0800: 11941936.263: [CMS-concurrent-preclean-start]
2017-07-18T21:29:02.502+0800: 11941936.322: [CMS-concurrent-preclean: 0.059/0.059 secs] [Times: user=0.02 sys=0.01, real=0.06 secs]
2017-07-18T21:29:02.502+0800: 11941936.322: [CMS-concurrent-abortable-preclean-start]
CMS: abort preclean due to time 2017-07-18T21:29:07.600+0800: 11941941.420: [CMS-concurrent-abortable-preclean: 0.889/5.098 secs] [Times: user=1.72 sys=0.31, real=5.10 secs]
2017-07-18T21:29:07.602+0800: 11941941.422: Application time: 25.9175914 seconds
2017-07-18T21:29:07.603+0800: 11941941.423: [GC (CMS Final Remark) [YG occupancy: 491182 K (943744 K)]
2017-07-18T21:29:07.603+0800: 11941941.423: [Rescan (parallel) , 0.0654053 secs]
2017-07-18T21:29:07.668+0800: 11941941.488: [weak refs processing, 0.6491578 secs]
2017-07-18T21:29:08.317+0800: 11941942.138: [class unloading, 4.2229435 secs]
2017-07-18T21:29:12.540+0800: 11941946.361: [scrub symbol table, 0.0536739 secs]
2017-07-18T21:29:12.594+0800: 11941946.414: [scrub string table, 0.0009992 secs][1 CMS-remark: 786446K(1048576K)] 1277629K(1992320K), 5.0003976 secs] [Times: user=0.96 sys=0.01, real=5.00 secs]
2017-07-18T21:29:12.603+0800: 11941946.423: Total time for which application threads were stopped: 5.0011973 seconds, Stopping threads took: 0.0000483 seconds

对应着七个步骤:

  1. 初始标记(CMS-initial-mark) ,会导致swt;
  2. 并发标记(CMS-concurrent-mark),与用户线程同时运行;
  3. 预清理(CMS-concurrent-preclean),与用户线程同时运行;
  4. 可被终止的预清理(CMS-concurrent-abortable-preclean) 与用户线程同时运行;
  5. 重新标记(CMS-remark) ,会导致swt;
  6. 并发清除(CMS-concurrent-sweep),与用户线程同时运行;
  7. 并发重置状态等待下次CMS的触发(CMS-concurrent-reset),与用户线程同时运行;

通过

1
[Times: user=0.96 sys=0.01, real=5.00 secs]

看出STW了5s,对于一个单台1万QPS的系统来讲,那5s就影响了上万次服务,这显示然达不到高可用的要求

通过对user,sys,real的对比,user+sys的时间远远小于real的值,这种情况说明停顿的时间并不是消耗在cpu执行上了,不是cpu那就是io导致的了

此时,可以通过sar命令查看一下

sar(System Activity Reporter系统活动情况报告)是目前 Linux 上最为全面的系统性能分析工具之一,可以从多方面对系统的活动进行报告,包括:文件的读写情况、系统调用的使用情况、磁盘I/O、CPU效率、内存使用状况、进程活动及IPC有关的活动等

sar -B

sar -B 输出说明:

输出项说明:

pgpgin/s:表示每秒从磁盘或SWAP置换到内存的字节数(KB)

pgpgout/s:表示每秒从内存置换到磁盘或SWAP的字节数(KB)

fault/s:每秒钟系统产生的缺页数,即主缺页与次缺页之和(major + minor)

majflt/s:每秒钟产生的主缺页数.

pgfree/s:每秒被放入空闲队列中的页个数

pgscank/s:每秒被kswapd扫描的页个数

pgscand/s:每秒直接被扫描的页个数

pgsteal/s:每秒钟从cache中被清除来满足内存需要的页个数

%vmeff:每秒清除的页(pgsteal)占总扫描页(pgscank+pgscand)的百分比

SWAP

可以看到大量的pgin,这儿就不得不再普及一下linux的swap

Linux divides its physical RAM (random access memory) into chucks of memory called pages. Swapping is the process whereby a page of memory is copied to the preconfigured space on the hard disk, called swap space, to free up that page of memory. The combined sizes of the physical memory and the swap space is the amount of virtual memory available.

Swap space in Linux is used when the amount of physical memory (RAM) is full. If the system needs more memory resources and the RAM is full, inactive pages in memory are moved to the swap space. While swap space can help machines with a small amount of RAM, it should not be considered a replacement for more RAM. Swap space is located on hard drives, which have a slower access time than physical memory.Swap space can be a dedicated swap partition (recommended), a swap file, or a combination of swap partitions and swap files.

Linux内核为了提高读写效率与速度,会将文件在内存中进行缓存,这部分内存就是Cache Memory(缓存内存)。即使你的程序运行结束后,Cache Memory也不会自动释放。这就会导致你在Linux系统中程序频繁读写文件后,你会发现可用物理内存变少。当系统的物理内存不够用的时候,就需要将物理内存中的一部分空间释放出来,以供当前运行的程序使用。那些被释放的空间可能来自一些很长时间没有什么操作的程序,这些被释放的空间被临时保存到Swap空间中,等到那些程序要运行时,再从Swap分区中恢复保存的数据到内存中。这样,系统总是在物理内存不够时,才进行Swap交换。

swap vs 虚拟内存

windows:虚拟内存

linux:swap分区

windows即使物理内存没有用完也会去用到虚拟内存,而Linux不一样 Linux只有当物理内存用完的时候才会去动用虚拟内存(即swap分区)

swap类似于windows的虚拟内存,不同之处在于,Windows可以设置在windows的任何盘符下面,默认是在C盘,可以和系统文件放在一个分区里。而linux则是独立占用一个分区,方便由于内存需求不够的情况下,把一部分内容放在swap分区里,待内存有空余的情况下再继续执行,也称之为交换分区,交换空间是其中的部分
windows的虚拟内存是电脑自动设置的

为什么会停顿这么长时间呢?

  1. 堆内存分配多大,当gc时,的确需要很长时间
  2. 内存不够用时,使用了swap,在gc时,需要从swap加载到内存,耗时

解决思路

对于上面的原因,可以找出对应的方案:

  1. 分配小点,通过小而快的方式达到快速gc
  2. 定期检测old gen使用情况,当快要到达临界值时候(old gen使用率大于50%)主动执行cms gc

主动Gc可能会影响服务,所以可能需要服务先下线,gc完,再上线

参考资料

CMS垃圾回收器详解

GC Algorithms: Implementations

之前写了《熔断》,以及其中使用的《计数器算法》;本来是要接着再写不通过定时器清理计数环的计数器算法,看了下我司亿级网关的计数器,百行的代码,但却是满满bug。不得穿插一下并发的基础知识

处理并发,最基本的元件就这三样

  1. synchronized 这个关键字不必讲,从开始多线程,它就进入你的视线
  2. volatile 在jdk5之后大放异彩
  3. cas 在J.U.C中大量使用,他与volatile组合是J.U.C的基石

JMM

谈到多线程,不得不说的JMM,这儿只做简单阐述

在jsr-133中是这么定义的

A memory model describes, given a program and an execution trace of that program,
whether the execution trace is a legal execution of the program. For the Java programming language,
the memory model works by examining each read in an execution trace and checking that the write observed by that read is valid according to certain rules.

也就是说一个内存模型描述了一个给定的程序和和它的执行路径是否一个合法的执行路径。对于java序言来说,内存模型通过考察在程序执行路径中每一个读操作,根据特定的规则,检查写操作对应的读操作是否能是有效的。
java内存模型只是定义了一个规范,具体的实现可以是根据实际情况自由实现的。但是实现要满足java内存模型定义的规范。

从抽象的角度来看,JMM定义了线程和主内存之间的抽象关系:线程之间的共享变量存储在主内存(main memory)中,每个线程都有一个私有的本地内存(local memory),本地内存中存储了该线程以读/写共享变量的副本。本地内存是JMM的一个抽象概念,并不真实存在。它涵盖了缓存,写缓冲区,寄存器以及其他的硬件和编译器优化。Java内存模型的抽象示意图如下:

volatile

由于java的内存模型中有工作内存和主内存之分,所以可能会有两种问题:

(1)线程可能在工作内存中更改变量的值,而没有及时写回到主内存,其他线程从主内存读取的数据仍然是老数据

(2)线程在工作内存中更改了变量的值,写回主内存了,但是其他线程之前也读取了这个变量的值,这样其他线程的工作内存中,此变量的值没有被及时更新。

为了解决这个问题,可以使用同步机制,也可以把变量声明为volatile,

JMM:对volatile字段的写入操作happens-before于每一个后续的同一个字段的读操作

如何理解呢?

(1)每次对变量的修改,都会引起处理器缓存(工作内存)写回到主内存。

(2)一个工作内存回写到主内存会导致其他线程的处理器缓存(工作内存)无效。

基于以上两点,如果一个字段被声明成volatile,java线程内存模型确保所有线程看到这个变量的值是一致的。

volatile原子性

Java内存模型要求lock, unlock, read, load, assign, use, write这个8个操作都具有原子性,但是同时又对64位的数据类型(long&double)给了一个相对宽松的规定,就是允许虚拟机将没有被volatile参数修饰的64位数据类型的读写划分为两次32位的操作来进行,即允许虚拟机将load, store, read, write这个4个操作实现为非原子的。

当线程把主存中的long/double类型的值读到线程内存中时,可能是两次32位值的写操作,显而易见,如果几个线程同时操作,那么就可能会出现高低2个32位值出错的情况发生。

java虚拟机规范(jvm spec)中,规定了声明为volatile的long和double变量的get和set操作是原子的

Writes and reads of volatile long and double values are always atomic.
Writes to and reads of references are always atomic, regardless of whether they are implemented as 32-bit or 64-bit values.

关于volatile变量的使用建议:多线程环境下需要共享的变量采用volatile声明;如果使用了同步块或者是常量,则没有必要使用volatile。

当然,需要注意的是,这儿的原子性,与i++不是一个概念

前者是单个变量写,后者是复合操作

volatile实现

volatile是如何做到可见性的呢?

来段代码看下,定义两个变量

1
2
3
private int i;

private volatile int j;

通过java -verbos XX.class 查看一下生成的编译码

发现唯一的区别就在于volatile多了ACC_VOLATILE标识

通过查看JVM源码,可以看到如下代码

这就是大名鼎鼎的“内存屏障”的抽象

内存屏障

内存屏障Memory Barriers:是一组处理器指令,用于实现对内存操作的顺序限制。

Memory barrier 能够让 CPU 或编译器在内存访问上有序。一个 Memory barrier 之前的内存访问操作必定先于其之后的完成。

内存屏障有两个能力

  1. 阻止屏障两边的指令重排序
  2. 强制把写缓冲区/高速缓存中的脏数据等写回主内存,让缓存中相应的数据失效

Memory barrier 分类

  1. 编译器 barrier
  2. CPU Memory barrier

内存屏障列表

JMM针对编译器制定的volatile重排序规则表

举例来说,第三行最后一个单元格的意思是:在程序顺序中,当第一个操作为普通变量的读或写时,如果第二个操作为volatile写,则编译器不能重排序这两个操作。

从上表我们可以看出:

当第二个操作是volatile写时,不管第一个操作是什么,都不能重排序。这个规则确保volatile写之前的操作不会被编译器重排序到volatile写之后

当第一个操作是volatile读时,不管第二个操作是什么,都不能重排序。这个规则确保volatile读之后的操作不会被编译器重排序到volatile读之前

当第一个操作是volatile写,第二个操作是volatile读时,不能重排序。

volatile的内存语义的(JVM)实现策略

  1. 在每个volatile写操作前,会插入一个StoreStore屏障;
  2. 在每个volatile写操作后,会插入一个storeload屏障;
  3. 在每个volatile读操作后,插入一个LoadLoad,一个LoadStore屏障


上图中的StoreStore屏障可以保证在volatile写之前,其前面的所有普通写操作已经对任意处理器可见了。这是因为StoreStore屏障将保障上面所有的普通写在volatile写之前刷新到主内存。

这里比较有意思的是volatile写后面的StoreLoad屏障。这个屏障的作用是避免volatile写与后面可能有的volatile读/写操作重排序。因为编译器常常无法准确判断在一个volatile写的后面,是否需要插入一个StoreLoad屏障(比如,一个volatile写之后方法立即return)。为了保证能正确实现volatile的内存语义,JMM在这里采取了保守策略:在每个volatile写的后面或在每个volatile读的前面插入一个StoreLoad屏障。从整体执行效率的角度考虑,JMM选择了在每个volatile写的后面插入一个StoreLoad屏障。因为volatile写-读内存语义的常见使用模式是:一个写线程写volatile变量,多个读线程读同一个volatile变量。当读线程的数量大大超过写线程时,选择在volatile写之后插入StoreLoad屏障将带来可观的执行效率的提升。从这里我们可以看到JMM在实现上的一个特点:首先确保正确性,然后再去追求执行效率。

上图中的LoadLoad屏障用来禁止处理器把上面的volatile读与下面的普通读重排序。LoadStore屏障用来禁止处理器把上面的volatile读与下面的普通写重排序。

重排序

在执行程序时为了提高性能,编译器和处理器常常会对指令做重排序。重排序分三种类型:

  1. 编译器优化的重排序。编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。
  2. 指令级并行的重排序。现代处理器采用了指令级并行技术(Instruction-Level Parallelism, ILP)来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。
  3. 内存系统的重排序。由于处理器使用缓存和读/写缓冲区,这使得加载和存储操作看上去可能是在乱序执行。
    从java源代码到最终实际执行的指令序列,会分别经历下面三种重排序:

上述的1属于编译器重排序,2和3属于处理器重排序。这些重排序都可能会导致多线程程序出现内存可见性问题。对于编译器,JMM的编译器重排序规则会禁止特定类型的编译器重排序(不是所有的编译器重排序都要禁止)。对于处理器重排序,JMM的处理器重排序规则会要求java编译器在生成指令序列时,插入特定类型的内存屏障(memory barriers,intel称之为memory fence)指令,通过内存屏障指令来禁止特定类型的处理器重排序(不是所有的处理器重排序都要禁止)。
JMM属于语言级的内存模型,它确保在不同的编译器和不同的处理器平台之上,通过禁止特定类型的编译器重排序和处理器重排序,为程序员提供一致的内存可见性保证。

比如现在有一段代码如下:

1
2
a = 1; //代码1
b = 1; //代码2

编译器和处理为了提高并行度,可以将代码1和2调整顺序,即先执行代码2和代码1

但是若是其他情况:

1
2
a = 1; //代码3
b = a; //代码4

这种情况因为代码3和4存在数据依赖,存在hanpens-before关系,处理器和编译器会遵守 as-if-serial原则,不会调整顺序。

as-if-serial原则:不管怎么重排序,单线程程序的执行结果不能发生改变。编译器、Runtime和处理器也是如此。这个语义相当于把单线程保护起来了,所以即使编译器和处理器对指令序列进行了重排序,我们也会认为程序指令并没有发生重排序

hanpens-before:指前一个操作对后一个操作可见,并不是前一个操作必须在后一个操作之前执行。

当存在控制依赖时,编译器和处理器会采取猜测执行机制来提高并行度,如下代码:

1
2
3
4
5
a = 1;
flag = true ;
if(flag){ //代码5
a * = 2; //代码6
}

代码5和6不存在数据依赖,可能会重排,处理器和编译器会先将代码6的执行结果放在缓冲区,等执行代码5之后,将缓冲区的结果直接赋值给a

从JSR-133开始,volatile写-读建立的happens before关系

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class VolatileExample {
int a = 0;
volatile boolean flag = false;

public void writer() {
a = 1; //1
flag = true; //2
}

public void reader() {
if (flag) { //3
int i = a; //4
……
}
}
}

假设线程A执行writer()方法之后,线程B执行reader()方法。根据happens before规则,这个过程建立的happens before 关系可以分为两类:
· 根据程序次序规则,1 happens before 2; 3 happens before 4。
· 根据volatile规则,2 happens before 3。
· 根据happens before 的传递性规则,1 happens before 4。
上述happens before 关系的图形化表现形式如下:

在上图中,每一个箭头链接的两个节点,代表了一个happens before 关系。黑色箭头表示程序顺序规则;橙色箭头表示volatile规则;蓝色箭头表示组合这些规则后提供的happens before保证。

这里A线程写一个volatile变量后,B线程读同一个volatile变量。
A线程在写volatile变量之前所有可见的共享变量,在B线程读同一个volatile变量后,将立即变得对B线程可见。

在JSR-133之前的旧Java内存模型中,虽然不允许volatile变量之间重排序,但旧的Java内存模型允许volatile变量与普通变量之间重排序。
在旧的内存模型中,VolatileExample示例程序可能被重排序成下列时序来执行:

在旧的内存模型中,当1和2之间没有数据依赖关系时,1和2之间就可能被重排序(3和4类似)。其结果就是:读线程B执行4时,不一定能看到写线程A在执行1时对共享变量的修改。

因此在旧的内存模型中 ,volatile的写-读没有监视器的释放-获取具有的内存语义。

为了提供一种比监视器锁更轻量级的线程之间通信的机制,JSR-133专家组决定增强

volatile的内存语义:严格限制编译器和处理器对volatile变量与普通变量的重排序,确保volatile的写-读和监视器的释放-获取一样,具有相同的内存语义。

从编译器重排序规则和处理器内存屏障插入策略来看,只要volatile变量与普通变量之间的重排序可能会破坏volatile 的内存语意,这种重排序就会被编译器重排序规则和处理器内存屏障插入策略禁止。

由于volatile仅仅保证对单个volatile变量的读/写具有原子性,而监视器锁的互斥执行的特性可以确保对整个临界区代码的执行具有原子性。在功能上,监视器锁比volatile更强大;在可伸缩性和执行性能上,volatile更有优势。如果读者想在程序中用volatile代替监视器锁,请一定谨慎。

volatile示例

还是不太明白,直接跑段代码,区别一下flag带与不带volatile修饰的情况,就很明显了

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
public class TestVolatile {

public static void main(String[] args) {

ThreadDemo td = new ThreadDemo();
new Thread(td).start();

while(true){
if(td.getFlag()){
System.out.println("主线程flag:" + td.getFlag());
break;
}
}
}
}

class ThreadDemo implements Runnable{
//共享变量
private volatile boolean flag = false;

public boolean getFlag() {
return flag;
}

public void setFlag(boolean flag) {
this.flag = flag;
}

@Override
public void run() {

try {
Thread.sleep(200);
} catch (Exception e) {
}

flag = true;

System.out.println("其他线程flag=" + getFlag());
}
}

synchronized

在多线程并发编程中Synchronized一直是元老级角色,很多人都会称呼它为重量级锁

Java SE1.6为了减少获得锁和释放锁所带来的性能消耗,引入了“偏向锁”和“轻量级锁”,所以在Java SE1.6里锁一共有四种状态,无锁状态,偏向锁状态,轻量级锁状态和重量级锁状态,它会随着竞争情况逐渐升级。锁可以升级但不能降级,意味着偏向锁升级成轻量级锁后不能降级成偏向锁。这种锁升级却不能降级的策略,目的是为了提高获得锁和释放锁的效率

1
2
3
4
5
6
7
8
9
10
11
12
public class SynchronizedTest {

public synchronized void test1(){

}

public void test2(){
synchronized (this){

}
}
}

能过javap -v 查看编译后的代码:

从反编译的结果来看,方法的同步并没有通过指令monitorenter和monitorexit来完成(理论上其实也可以通过这两条指令来实现),不过相对于普通方法,其常量池中多了ACC_SYNCHRONIZED标示符。JVM就是根据该标示符来实现方法的同步的:当方法调用时,调用指令将会检查方法的 ACC_SYNCHRONIZED 访问标志是否被设置,如果设置了,执行线程将先获取monitor,获取成功之后才能执行方法体,方法执行完后再释放monitor。在方法执行期间,其他任何线程都无法再获得同一个monitor对象。 其实本质上没有区别,只是方法的同步是一种隐式的方式来实现,无需通过字节码来完成。

关于这两条指令的作用,我们直接参考JVM规范中描述:

monitorenter

Each object is associated with a monitor. A monitor is locked if and only if it has an owner. The thread that executes monitorenter attempts to gain ownership of the monitor associated with objectref, as follows:
• If the entry count of the monitor associated with objectref is zero, the thread enters the monitor and sets its entry count to one. The thread is then the owner of the monitor.
• If the thread already owns the monitor associated with objectref, it reenters the monitor, incrementing its entry count.
• If another thread already owns the monitor associated with objectref, the thread blocks until the monitor’s entry count is zero, then tries again to gain ownership.

这段话的大概意思为:

每个对象有一个监视器锁(monitor)。当monitor被占用时就会处于锁定状态,线程执行monitorenter指令时尝试获取monitor的所有权,过程如下:

1、如果monitor的进入数为0,则该线程进入monitor,然后将进入数设置为1,该线程即为monitor的所有者。

2、如果线程已经占有该monitor,只是重新进入,则进入monitor的进入数加1.

3.如果其他线程已经占用了monitor,则该线程进入阻塞状态,直到monitor的进入数为0,再重新尝试获取monitor的所有权。

monitorexit:

The thread that executes monitorexit must be the owner of the monitor associated with the instance referenced by objectref.
The thread decrements the entry count of the monitor associated with objectref. If as a result the value of the entry count is zero, the thread exits the monitor and is no longer its owner. Other threads that are blocking to enter the monitor are allowed to attempt to do so.

这段话的大概意思为:

执行monitorexit的线程必须是objectref所对应的monitor的所有者。

指令执行时,monitor的进入数减1,如果减1后进入数为0,那线程退出monitor,不再是这个monitor的所有者。其他被这个monitor阻塞的线程可以尝试去获取这个 monitor 的所有权。

通过这两段描述,我们应该能很清楚的看出Synchronized的实现原理,Synchronized的语义底层是通过一个monitor的对象来完成,其实wait/notify等方法也依赖于monitor对象,这就是为什么只有在同步的块或者方法中才能调用wait/notify等方法,否则会抛出java.lang.IllegalMonitorStateException的异常的原因。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class SynchronizedExample {
int a = 0;
boolean flag = false;

public synchronized void writer() {
a = 1;
flag = true;
}

public synchronized void reader() {
if (flag) {
int i = a;
……
}
}
}

上面示例代码中,假设A线程执行writer()方法后,B线程执行reader()方法。这是一个正确同步的多线程程序。

cas

Java在JDK1.5之前都是靠 synchronized关键字保证同步的,这种通过使用一致的锁定协议来协调对共享状态的访问,可以确保无论哪个线程持有共享变量的锁,都采用独占的方式来访问这些变量。这就是一种独占锁,独占锁其实就是一种悲观锁,所以可以说 synchronized 是悲观锁。

悲观锁机制存在以下问题:  

  1. 在多线程竞争下,加锁、释放锁会导致比较多的上下文切换和调度延时,引起性能问题。
  2. 一个线程持有锁会导致其它所有需要此锁的线程挂起。
  3. 如果一个优先级高的线程等待一个优先级低的线程释放锁会导致优先级倒置,引起性能风险。

对比于悲观锁的这些问题,另一个更加有效的锁就是乐观锁。

其实乐观锁就是:每次不加锁而是假设没有并发冲突而去完成某项操作,如果因为并发冲突失败就重试,直到成功为止。

CAS是项乐观锁技术,当多个线程尝试使用CAS同时更新同一个变量时,只有其中一个线程能更新变量的值,而其它线程都失败,失败的线程并不会被挂起,而是被告知这次竞争中失败,并可以再次尝试。

CAS 操作包含三个操作数 —— 内存位置(V)、预期原值(A)和新值(B)。如果内存位置的值与预期原值相匹配,那么处理器会自动将该位置值更新为新值。否则,处理器不做任何操作。无论哪种情况,它都会在 CAS 指令之前返回该位置的值。
(在 CAS 的一些特殊情况下将仅返回 CAS 是否成功,而不提取当前值。)
CAS 有效地说明了“我认为位置 V 应该包含值 A;如果包含该值,则将 B 放到这个位置;
否则,不要更改该位置,只告诉我这个位置现在的值即可。”这其实和乐观锁的冲突检查+数据更新的原理是一样的。

1
2
3
public final boolean compareAndSet(int expect, int update) {   
return unsafe.compareAndSwapInt(this, valueOffset, expect, update);
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
unsafe.compareAndSwapInt(this, valueOffset, expect, update);

类似:

if (this == expect) {

this = update

return true;

} else {

return false;

}

CAS原理

CAS通过调用JNI的代码实现的。JNI:Java Native Interface为JAVA本地调用,允许java调用其他语言。

而compareAndSwapInt就是借助C来调用CPU底层指令实现的。

下面是sun.misc.Unsafe类的compareAndSwapInt()方法的源代码:

1
2
3
public final native boolean compareAndSwapInt(Object o, long offset,
int expected,
int x);

可以看到这是个本地方法调用。这个本地方法在openjdk中依次调用的c++代码为:unsafe.cpp,atomic.cpp和atomicwindowsx86.inline.hpp。这个本地方法的最终实现在openjdk的如下位置:openjdk-7-fcs-src-b147-27jun2011\openjdk\hotspot\src\oscpu\windowsx86\vm\ atomicwindowsx86.inline.hpp(对应于windows操作系统,X86处理器)。下面是对应于intel x86处理器的源代码的片段:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// Adding a lock prefix to an instruction on MP machine
// VC++ doesn't like the lock prefix to be on a single line
// so we can't insert a label after the lock prefix.
// By emitting a lock prefix, we can define a label after it.
#define LOCK_IF_MP(mp) __asm cmp mp, 0 \
__asm je L0 \
__asm _emit 0xF0 \
__asm L0:

inline jint Atomic::cmpxchg (jint exchange_value, volatile jint* dest, jint compare_value) {
// alternative for InterlockedCompareExchange
int mp = os::is_MP();
__asm {
mov edx, dest
mov ecx, exchange_value
mov eax, compare_value
LOCK_IF_MP(mp)
cmpxchg dword ptr [edx], ecx
}
}

如上面源代码所示,程序会根据当前处理器的类型来决定是否为cmpxchg指令添加lock前缀。如果程序是在多处理器上运行,就为cmpxchg指令加上lock前缀(lock cmpxchg)。反之,如果程序是在单处理器上运行,就省略lock前缀(单处理器自身会维护单处理器内的顺序一致性,不需要lock前缀提供的内存屏障效果)。

intel的手册对lock前缀的说明如下:

  1. 确保对内存的读-改-写操作原子执行。在Pentium及Pentium之前的处理器中,带有lock前缀的指令在执行期间会锁住总线,使得其他处理器暂时无法通过总线访问内存。很显然,这会带来昂贵的开销。从Pentium 4,Intel Xeon及P6处理器开始,intel在原有总线锁的基础上做了一个很有意义的优化:如果要访问的内存区域(area of memory)在lock前缀指令执行期间已经在处理器内部的缓存中被锁定(即包含该内存区域的缓存行当前处于独占或以修改状态),并且该内存区域被完全包含在单个缓存行(cache line)中,那么处理器将直接执行该指令。由于在指令执行期间该缓存行会一直被锁定,其它处理器无法读/写该指令要访问的内存区域,因此能保证指令执行的原子性。这个操作过程叫做缓存锁定(cache locking),缓存锁定将大大降低lock前缀指令的执行开销,但是当多处理器之间的竞争程度很高或者指令访问的内存地址未对齐时,仍然会锁住总线。
  2. 禁止该指令与之前和之后的读和写指令重排序。
  3. 把写缓冲区中的所有数据刷新到内存中。

CAS缺点

CAS虽然很高效的解决原子操作,但是CAS仍然存在三大问题。ABA问题,循环时间长开销大和只能保证一个共享变量的原子操作

  1. ABA问题。因为CAS需要在操作值的时候检查下值有没有发生变化,如果没有发生变化则更新,但是如果一个值原来是A,变成了B,又变成了A,那么使用CAS进行检查时会发现它的值没有发生变化,但是实际上却变化了。ABA问题的解决思路就是使用版本号。在变量前面追加上版本号,每次变量更新的时候把版本号加一,那么A-B-A 就会变成1A-2B-3A
    从Java1.5开始JDK的atomic包里提供了一个类AtomicStampedReference来解决ABA问题。这个类的compareAndSet方法作用是首先检查当前引用是否等于预期引用,并且当前标志是否等于预期标志,如果全部相等,则以原子方式将该引用和该标志的值设置为给定的更新值。
  2. 循环时间长开销大。自旋CAS如果长时间不成功,会给CPU带来非常大的执行开销。如果JVM能支持处理器提供的pause指令那么效率会有一定的提升,pause指令有两个作用,第一它可以延迟流水线执行指令(de-pipeline),使CPU不会消耗过多的执行资源,延迟的时间取决于具体实现的版本,在一些处理器上延迟时间是零。第二它可以避免在退出循环的时候因内存顺序冲突(memory order violation)而引起CPU流水线被清空(CPU pipeline flush),从而提高CPU的执行效率。
  3. 只能保证一个共享变量的原子操作。当对一个共享变量执行操作时,我们可以使用循环CAS的方式来保证原子操作,但是对多个共享变量操作时,循环CAS就无法保证操作的原子性,这个时候就可以用锁,或者有一个取巧的办法,就是把多个共享变量合并成一个共享变量来操作。比如有两个共享变量i=2,j=a,合并一下ij=2a,然后用CAS来操作ij。从Java1.5开始JDK提供了AtomicReference类来保证引用对象之间的原子性,你可以把多个变量放在一个对象里来进行CAS操作。

concurrent包的实现

由于java的CAS同时具有 volatile 读和volatile写的内存语义,因此Java线程之间的通信现在有了下面四种方式:

  1. A线程写volatile变量,随后B线程读这个volatile变量。
  2. A线程写volatile变量,随后B线程用CAS更新这个volatile变量。
  3. A线程用CAS更新一个volatile变量,随后B线程用CAS更新这个volatile变量。
  4. A线程用CAS更新一个volatile变量,随后B线程读这个volatile变量。

Java的CAS会使用现代处理器上提供的高效机器级别原子指令,这些原子指令以原子方式对内存执行读-改-写操作,这是在多处理器中实现同步的关键(从本质上来说,能够支持原子性读-改-写指令的计算机器,是顺序计算图灵机的异步等价机器,因此任何现代的多处理器都会去支持某种能对内存执行原子性读-改-写操作的原子指令)。同时,volatile变量的读/写和CAS可以实现线程之间的通信。把这些特性整合在一起,就形成了整个concurrent包得以实现的基石。如果我们仔细分析concurrent包的源代码实现,会发现一个通用化的实现模式:

  • 首先,声明共享变量为volatile;
  • 然后,使用CAS的原子条件更新来实现线程之间的同步;
  • 同时,配合以volatile的读/写和CAS所具有的volatile读和写的内存语义来实现线程之间的通信。

volatile vs synchronized

1.volatile本质是在告诉jvm当前变量在寄存器(工作内存)中的值是不确定的,需要从主存中读取;synchronized则是锁定当前变量,只有当前线程可以访问该变量,其他线程被阻塞住。
2.volatile仅能使用在变量级别;synchronized则可以使用在变量、方法、和类级别的。
3.volatile仅能实现变量的修改可见性,不能保证原子性(线程A修改了变量还没结束时,另外的线程B可以看到已修改的值,而且可以修改这个变量,而不用等待A释放锁,因为Volatile 变量没上锁);而synchronized则可以保证变量的修改可见性和原子性。
4.volatile不会造成线程的阻塞;synchronized可能会造成线程的阻塞和上下文切换。
5.volatile标记的变量不会被编译器优化;synchronized标记的变量可以被编译器优化。

6.在使用volatile关键字时要慎重,并不是只要简单类型变量使用volatile修饰,对这个变量的所有操作都是原子操作。当变量的值由自身决定时,如n=n+1、n++ 等,volatile关键字将失效。只有当变量的值和自身无关时对该变量的操作才是原子级别的,如n = m + 1,这个就是原级别的。所以在使用volatile关键时一定要谨慎,如果自己没有把握,可以使用synchronized来代替volatile。

7.“锁是昂贵的”,谨慎使用锁机制。

参考资料

Java Language Specification

volatile的底层源码分析

volatile

内存屏障(Memory barrier)

深入理解Java内存模型(四)——volatile

背景

由于微服务间通过RPC来进行数据交换,所以我们可以做一个假设:在IO型服务中,假设服务A依赖服务B和服务C,而B服务和C服务有可能继续依赖其他的服务,继续下去会使得调用链路过长,技术上称1->N扇出

1->N扇出

问题

如果在A的链路上某个或几个被调用的子服务不可用或延迟较高,则会导致调用A服务的请求被堵住,堵住的请求会消耗占用掉系统的线程、io等资源,当该类请求越来越多,占用的计算机资源越来越多的时候,会导致系统瓶颈出现,造成其他的请求同样不可用,最终导致业务系统崩溃

  1. 服务器失败影响服务质量
  2. 超负荷导致整个服务失败
  3. 服务失败造成的雪崩效应

微服务服务依赖调用

超负荷导致整个服务失败

服务失败造成的雪崩效应

熔断

熔断模式:这种模式主要是参考电路熔断,如果一条线路电压过高,保险丝会熔断,防止火灾。放到我们的系统中,如果某个目标服务调用慢或者有大量超时,此时,熔断该服务的调用,对于后续调用请求,不在继续调用目标服务,直接返回,快速释放资源。如果目标服务情况好转则恢复调用。

定义里面有几个量化的地方

  1. 目标服务调用慢或者超时:开启熔断的阀值量化

可以通过两个维度:时间与请求数

时间
多长时间内的超时请求达到多少,触发熔断

请求数
从服务启动,超时请求数达到多少,触发

这两个维度都需要记录超时请求数和统计总请求数

  1. 情况好转,恢复调用

如何量化情况好转:多长时间之后超时请求数低于多少关闭熔断

熔断状态

熔断状态

三种状态的切换

开 – 半开 – 关

:使用快速失败返回,调用链结束

半开:当熔断开启一段时间后,尝试阶段

:调用正常

实现机制

可以使用一段伪代码表示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
//正常request
if( request is open) {
//fastfail
} else if( request is halfopen) {
if ( request success count > recoverySampleVolume) {
//state --> close
}
}


//失败request
if( request is failcount > requestVolumeThreshold && errorPercentage > threshold) {
//close --> open
}

请求熔断开启时,直接快速失败

是halfopen状态,如果成功处理次数是否大于恢复配置,就关闭熔断

如果失败次数超过阀值,开启熔断

而对于open–>halfopen的转换,可以通过定时器主动触发

具体实现

现在有很多开源的

failsafe:https://github.com/jhalterman/failsafe

Hystrix

个案实现

在没有熔断时,请求链路:

client –> request –> balance – > handler

一个请求过来,通过负载均衡找到具体的server,再执行

加入熔断后:

client –> request –> circuitBreakerfilter –> balance – > handler

CircuitBreakerFilter过滤掉被熔断的server,在负载均衡时,不再被选中

  1. getAllServers() 获取所有服务器列表
  2. 根据requestService,requestMethod获取熔断的servers
    • 从allserverList中剔除这些server

熔断服务列表怎么维护呢?

正常状态 –> 熔断状态
1
2
3
4
5
6
7
1. 收到失败请求(e.g.超时,系统异常)
2. 判断此service是否配置了熔断策略 map<serviceName,circuitBreakerpolicy>
- 根据serviceName,method,serverInfo获取CircuitBreakerCounter
- counter对失败次数+1
- 此server是否在half open状态 HalfOpenServersMap<serverName+method,serverList>
- 在:如果失败次数超过RecoverySampleVolume,openserversmap<servername+method,serverlist>进行put操作、并从HalfOpenServersMap中remove
- 不在:请求数大于等于10笔(requestVolumeThreshold),且错误率达到60%(errorPercentage),openserversmap<servername+method,serverlist>进行put操作
熔断状态 –> 正常状态
1
2
3
4
5
1. 收到请求
2. 判断此service是否配置了熔断策略 map<serviceName,circuitBreakerpolicy>
- 根据serviceName,method,serverInfo获取CircuitBreakerCounter
- counter调用次数+1
- 若half-open 状态下的服务instance被调用次数超过取样的sample数,从HalfOpenServersMap中remove
疑问
  1. 错误率怎么计算?
  2. counter的实现
  3. 上面是close与open的转换,怎么转换到halfopen?

错误率= 错误次数/请求次数

halfopen状态

在上面的提到,被熔断的服务,如果情况好转就会关闭熔断!“情况好转”:什么时候去判断情况好转,怎么判断情况好转两方面

  1. 在加入到openserversmap时,同时开启延迟时间窗口后的定时任务
    • 从openserversmap中移除,加入到halfOpenServersMap

counter实现

  1. 简单点:AtomicLong,如当是halfopen时,使用这种简单的计数器叠加
  2. 滑动时间窗口实现

VS 降级

提到熔断,不得不起一下降级。两者的区别

有时语言真是乏力,不容易表达清楚,罗列一下

熔断是框架提供,不管业务什么样,防止系统雪崩,都需要提供一下基本功能;而降级与业务有关,自动或手动。比如支付,有很多种支付方式,储蓄卡,信用卡,支付宝,微信。若发现某一支付通道不稳定,或压力过大,手动先关闭,这就是一种降级

由此可看出:

  1. 触发原因不太一样,服务熔断一般是某个服务(下游服务)故障引起,而服务降级一般是从整体负荷考虑;
  2. 管理目标的层次不太一样,熔断其实是一个框架级的处理,每个微服务都需要(无层级之分),而降级一般需要对业务有层级之分(比如降级一般是从最外围服务开始)
  3. 实现方式不一样

参考

微服务熔断与隔离

CircuitBreaker

在《常识五配置中心》文章中,少了一节关于zookeeper内容,现在补全

此篇也作为《从Paxos到zookeeper分布式一致性原理与实践》的读书笔记

image

这本书很早就出版了,现在才知道,惭愧。好书总是发现的晚,Better late than never!

IT界日异月新,如果你还没有使用过ZK,那也可以跳过了,虽然现在大多数互联网架构都使用,但它也是个古老物件了。随着CoreOS和Kubernetes等项目在开源社区日益火热,etcd已是跃然而上,我司新一代配置中心架构也开始使用etcd代替zk

但功不唐捐,还是要努力抓住它的尾巴,回味一下错失的年华

问题提出

分布式系统对于数据的复制需求一般都来自于以下两个原因

  1. 为了增加系统的可用性,以防止单点故障引起的系统不可用。
  2. 提高系统的整体性能,通过负载均衡技术,能够让分布在不同地方的数据副本都能够为用户提供服务。

数据复制在可用性和性能方面给分布式系统带来的巨大好处是不言而喻的,然而数据复制所带来的一致性挑战,也是每一个系统研发人员不得不面对的。

一致性

  • 强一致性
  • 弱一致性
    1. 会话一致性
    2. 用户一致性
  • 最终一致性

分布式架构

通过消息传递进行通信和协调的系统

  • 分布性
  • 对等性
  • 并发性
  • 缺乏全局时钟
  • 故障总是会发生

问题

通信异常

网络是不可靠的

网络分区

俗称“脑裂”

三态

成功,失败,超时

节点故障

每个节点随时都有可能发生故障

ACID && CAP && BASE

image

ACID

这个集中式架构中,数据库就能保证

ACID是Atomic(原子性)、Consistency(一致性)、Isolation(隔离性)和Durability(持久性)的英文缩写。

原子性:指整个数据库事务是不可分割的工作单位。只有使据库中所有的操作执行成功,才算整个事务成功;事务中任何一个SQL语句执行失败,那么已经执行成功的SQL语句也必须撤销,数据库状态应该退回到执行事务前的状态。

一致性:指数据库事务不能破坏关系数据的完整性以及业务逻辑上的一致性。例如对银行转帐事务,不管事务成功还是失败,应该保证事务结束后ACCOUNTS表中Tom和Jack的存款总额为2000元。

隔离性:指的是在并发环境中,当不同的事务同时操纵相同的数据时,每个事务都有各自的完整数据空间。由并发事务所做的修改必须与任何其他并发事务所做的修改隔离。事务查看数据更新时,数据所处的状态要么是另一事务修改它之前的状态,要么是另一事务修改它之后的状态,事务不会查看到中间状态的数据。

持久性:指的是只要事务成功结束,它对数据库所做的更新就必须永久保存下来。即使发生系统崩溃,重新启动数据库系统后,数据库还能恢复到事务成功结束时的状态。

CAP

CAP原则又称CAP定理,指的是在一个分布式系统中,Consistency(一致性)、 Availability(可用性)、Partition tolerance(分区容错性),三者不可得兼

  • 一致性(C):在分布式系统中的所有数据备份,在同一时刻是否同样的值。(等同于所有节点访问同一份最新的数据副本)
  • 可用性(A):在集群中一部分节点故障后,集群整体是否还能响应客户端的读写请求。(对数据更新具备高可用性)
  • 分区容错性(P):以实际效果而言,分区相当于对通信的时限要求。系统如果不能在时限内达成数据一致性,就意味着发生了分区的情况,必须就当前操作在C和A之间做出选择。

设想一下,当一个分布式系统发生了部分隔离:

image

节点被分隔到了两个区域:写入区域(1)的数据无法复制到区域(2),而访问区域(2)的请求也不能被转发到区域(1)。

这时候,如果让左边的写入成功——优先保证可用性,则访问区域(2)的请求读不到最新一致的数据,违反了一致性。

反之,如果让写入失败(阻塞),或者彻底阻止外部请求访问区域(2)——则保证了数据一致性,但是损失了可用性。

因此,要同时保证一致性和可用性,区域(1)和区域(2)必须能够互相通讯。

BASE, 最终一致性

这个理论由 Basically Available, Soft state, Eventual consistency 组成。核心的概念是 Eventual consistency ——最终一致性。它局部的放弃了 CAP 理论中的“完全”一致性,提供了更好的可用性和分区容忍度。

Basically Available

基本可用, 或者说部分可用。由于分布式系统的节点故障是常见的,业务必须接受这种不可用,并且做出选择:是访问另一个节点忍受数据的临时不一致,还是等待节点恢复并忍受业务上的部分不可用。

Soft state

把所有节点的数据 (数据 = 状态) 都看作是缓存(Cache)。适当的调整业务,使业务可以忍受数据的临时不一致,并保证这种不一致是无害的,可以被最终用户理解。

Eventual consistency

放弃在任何时刻、从任何节点都能读到完全一致的数据。允许数据的临时不一致,并通过异步复制、重试和合并消除数据的临时不一致。

注意 在分布式系统中,写入和读取可能发生在不同的节点上。最终一致带来的问题是,业务在写入后立即读取,很可能读不到刚刚写入的数据

在实际工程实践中,最终一致性存在以下五类主要变种。

  1. 因果一致性(Causal consistency)
    因果一致性是指,如果进程A在更新完某个数据项后通知了进程B,那么进程B之后对该数据项的访问都应该能够获取到进程A更新后的最新值,并且如果进程B要对该数据项进行更新操作的话,务必基于进程A更新后的最新值,即不能发生丢失更新情况。与此同时,与进程A无因果关系的进程C的数据访问则没有这样的限制。
  2. 读己之所写(Read your writes)
    读己之所写是指,进程A更新一个数据项之后,它自己总是能够访问到更新过的最新值,而不会看到旧值。也就是说,对于单个数据获取者来说,其读取到的数据,一定不会比自己上次写入的值旧。因此,读己之所写也可以看作是一种特殊的因果一致性。
  3. 会话一致性(Session consistency)
    会话一致性将对系统数据的访问过程框定在了一个会话当中:系统能保证在同一个有效的会话中实现“读己之所写”的一致性,也就是说,执行更能操作之后,客户端能够在同一个会话中始终读取到该数据项的最新值。
  4. 单调读一致性(Monotonic read consistency)
    单调读一致性是指如果一个进程从系统中读取出一个数据项的某个值后,那么系统对于该进程后续的任何数据访问都不应该返回更旧的值。
  5. 单调写一致性(Monotonic write consistency)
    单调写一致性是指,一个系统需要能够保证来自同一个进程的写操作被顺序地执行。

一致性协议

2PC&&3PC

2PC/3PC全称:Two/Three Phase Commit,中文名叫叫两阶段/三阶段提交;为了使基于分布式系统架构下的所有节点在进行事务处理的过程中能够ACID特性而设计的一种算法,需要引入一个作为协调者的组件来统一掌控所有节点(称作参与者)的操作结果并最终指示这些节点是否要把操作结果进行真正的提交

2PC

2pc
第一阶段:提交事务阶段(投票阶段)

  1. 事务询问:协调者会问所有的参与者结点,是否可以执行提交操作
  2. 执行事务:各个参与者执行事务操作 如:资源上锁,将Undo和Redo信息记入事务日志中
  3. 参与者向协调者反馈事务询问的响应:如果参与者成功执行了事务操作,反馈给协调者Yes响应,否则No响应

第二阶段:执行事务提交(执行阶段)

假如协调者从所有的参与者获得的反馈都是Yes响应,那么就会执行事务提交

  1. 发送提交请求:协调者向参与者发送Commit请求
  2. 事务提交:参与者接受到Commit请求后,会正式执行事务提交操作,并在完成提交之后释放事务资源
  3. 反馈事务提交结果:参与者在完成事务提交之后,向协调者发送Ack消息
  4. 完成事务:协调者接受到所有参与者反馈的Ack消息后,完成事务

假如任何一个参与者向协调者反馈了No响应,或者在等待超时之后,协调者尚无接收到所有参与者的反馈信息,那么就会中断事务

  1. 发送回滚请求:协调者向参与者发送Rollback请求
  2. 事务回滚:参与者利用Undo信息来执行事务回滚,并释放事务资源
  3. 反馈事务回滚结果:参与者在完成事务回滚之后,向协调者发送Ack消息
  4. 中断事务:协调者接收到所有参与者反馈的Ack消息之后,中断事务

网上看来的西方教堂结婚一个桥段很好的描述了2PC协议:
1.牧师分别问新郎和新娘:你是否愿意……不管生老病死……(投票阶段)
2.当新郎和新娘都回答愿意后(锁定一生的资源),牧师就会说:我宣布你们……(执行阶段)

优缺点

  • 二阶段提交协议的优点:原理简单,实现方便。
  • 二阶段提交协议的缺点:同步阻塞、单点问题、脑裂、太过保守
同步阻塞

二阶段提交协议存在的最明显也是最大的一个问题就是同步阻塞,这会极大地限制分布式系统的性能。在二阶段提交的执行过程中,所有参与该事务操作的逻辑都处于阻塞状态,也就是说,各个参与者在等待其他参与者响应的过程中,将无法进行其他任何操作。

单点问题

协调者的角色在整个二阶段提交协议中起到了非常重要的作用。一旦协调者出现问题,那么整个二阶段提交流程将无法运转,更为严重的是,如果协调者是在阶段二中出现问题的话,那么其他参与者将会一直处于锁定事务资源的状态中,而无法继续完成事务操作。

数据不一致

在二阶段提交协议的阶段二,即执行事务提交的时候,当协调者向所有的参与者发送Commit请求之后,发生了局部网络异常或者是协调者在尚未发送完Commit请求之前自身发生了崩溃,导致最终只有部分参与者收到了Commit请求。于是,这部分收到了Commit请求的参与者就会进行事务的提交,而其他没有收到Commit请求的参与者则无法进行事务提交,于是整个分布式系统便出现了数据不一致性现象。

太过保守

如果在协调者指示参与者进行事务提交询问的过程中,参与者出现故障而导致协调者始终无法获取到所有参与者的响应信息的话,这时协调者只能依靠其自身的超时机制来判断是否需要中断事务,这样的策略显得比较保守。换句话说,二阶段提交协议没有设计较为完善的容错机制,任意一个节点的失败都会导致整个事务的失败。

在异步环境(asynchronous)并且没有节点宕机(fail-stop)的模型下,2PC可以满足全认同、值合法、可结束,是解决一致性问题的一种协议。但如果再加上节点宕机(fail-recover)的考虑,2PC是否还能解决一致性问题呢?

coordinator如果在发起提议后宕机,那么participant将进入阻塞(block)状态、一直等待coordinator回应以完成该次决议。这时需要另一角色把系统从不可结束的状态中带出来,我们把新增的这一角色叫协调者备份(coordinator watchdog)。coordinator宕机一定时间后,watchdog接替原coordinator工作,通过问询(query) 各participant的状态,决定阶段2是提交还是中止。这也要求 coordinator/participant 记录(logging)历史状态,以备coordinator宕机后watchdog对participant查询、coordinator宕机恢复后重新找回状态。

从coordinator接收到一次事务请求、发起提议到事务完成,经过2PC协议后增加了2次RTT(propose+commit),带来的时延(latency)增加相对较少。

3PC

3PC是2PC的改进版本,将2PC的第一阶段:提交事务阶段一分为二,形成了CanCommit、PreCommit和doCommit三个阶段组成的事务处理协议

在2PC中一个participant的状态只有它自己和coordinator知晓,假如coordinator提议后自身宕机,在watchdog启用前一个participant又宕机,其他participant就会进入既不能回滚、又不能强制commit的阻塞状态,直到participant宕机恢复。这引出两个疑问:

  1. 能不能去掉阻塞,使系统可以在commit/abort前回滚(rollback)到决议发起前的初始状态
  2. 当次决议中,participant间能不能相互知道对方的状态,又或者participant间根本不依赖对方的状态

具体看一张流程图

3pc

阶段一:CanCommit

  1. 事务询问。
    协调者向所有的参与者发送一个包含事务内容的canCommit请求,询问是否可以执行事务提交操作,并开始等待各参与者的响应。
  2. 各参与者向协调者反馈事务询问的响应。
    参与者在接收到来自协调者的canCommit请求后,正常情况下,如果其自身认为可以顺利执行事务,那么会反馈Yes响应,并进入预备状态,否则反馈No响应。

阶段二:PreCommit

在阶段二中,协调者会根据各参与者的反馈情况来决定是否可以进行事务的PreCommit操作,正常情况下,包含两种可能。

  • 执行事务预提交

假如协调者从所有的参与者获得的反馈都是Yes响应,那么就会执行事务预提交。

  1. 发送预提交请求。
    协调者向所有参与者节点发出preCommit的请求,并进入Prepared阶段。
  2. 事务预提交。
    参与者接收到preCommit请求后,会执行事务操作,并将Undo和Redo信息记录到事务日志中。
  3. 各参与者向协调者反馈事务执行的响应。
    如果参与者成功执行了事务操作,那么就会反馈给协调者Ack响应,同时等待最终的指令:提交(commit)或中止(abort)。
  • 中断事务
    假如任何一个参与者向协调者反馈了No响应,或者在等待超时之后,协调者尚无法接收到所有参与者的反馈响应,那么就会中断事务。
  1. 发送中断请求。
    协调者向所有参与者节点发出abort请求。
  2. 中断事务。
    无论是收到来自协调者的abort请求,或者是在等待协调者请求过程中出现超时,参与者都会中断事务。

阶段三:doCommit

该阶段将进行真正的事务提交,会存在以下两种可能的情况。

  • 执行提交
  1. 发送提交请求。
    进入这一阶段,假设协调者处于正常工作状态,并且它接收到了来自所有参与者的Ack响应,那么它将从“预提交”状态转换到“提交”状态,并向所有的参与者发送doCommit请求。
  2. 事务提交。
    参与者接收到doCommit请求后,会正式执行事务提交操作,并在完成提交之后释放在整个事务执行期间占用的事务资源。
  3. 反馈事务提交结果。
    参与者在完成事务提交之后,向协调者发送Ack消息。
  4. 完成事务。
    协调者接收到所有参与者反馈的Ack消息后,完成事务。
  • 中断事务
    进入这一阶段,假设协调者处于正常工作状态,并且有任意一个参与者向协调者反馈了No响应,或者在等待超时之后,协调者尚无法接收到所有参与者的反馈响应,那么就会中断事务。
  1. 发送中断请求。
    协调者向所有的参与者节点发送abort请求。
  2. 事务回滚。
    参与者接收到abort请求后,会利用其在阶段二中记录的Undo信息来执行事务回滚操作,并在完成回滚之后释放在整个事务执行期间占用的资源。
  3. 反馈事务回滚结果。
    参与者在完成事务回滚之后,向协调者发送Ack消息。
  4. 中断事务。
    协调者接收到所有参与者反馈的Ack消息后,中断事务。

需要注意的是,一旦进入阶段三,可能会存在以下两种故障。

  1. 协调者出现问题
  2. 协调者和参与者之间的网络出现故障。

无论出现哪种情况,最终都会导致参与者无法及时接收到来自协调者的doCommit或是abort请求,针对这样的异常情况,参与者都会在等待超时之后,继续进行事务提交。

优缺点

三阶段提交协议的优点:

相较于二阶段提交协议,三阶段提交协议最大的优点就是降低了参与者的阻塞范围,并且能够在出现单点故障后继续达成一致。

三阶段提交协议的缺点:

三阶段提交协议在去除阻塞的同时也引入了新的问题,那就是在参与者接收到preCommit消息后,如果网络出现分区,此时协调者所在的节点和参与者无法进行正常的网络通信,在这种情况下,该参与者依然会进行事务的提交,这必然出现数据的不一致性。

coordinator接收完participant的反馈(vote)之后,进入阶段2,给各个participant发送准备提交(prepare to commit)指令。participant接到准备提交指令后可以锁资源,但要求相关操作必须可回滚。coordinator接收完确认(ACK)后进入阶段3、进行commit/abort,3PC的阶段3与2PC的阶段2无异。协调者备份(coordinator watchdog)、状态记录(logging)同样应用在3PC。

participant如果在不同阶段宕机,我们来看看3PC如何应对:

  • 阶段1: coordinator或watchdog未收到宕机participant的vote,直接中止事务;宕机的participant恢复后,读取logging发现未发出赞成vote,自行中止该次事务
  • 阶段2: coordinator未收到宕机participant的precommit ACK,但因为之前已经收到了宕机participant的赞成反馈(不然也不会进入到阶段2),coordinator进行commit;watchdog可以通过问询其他participant获得这些信息,过程同理;宕机的participant恢复后发现收到precommit或已经发出赞成vote,则自行commit该次事务
  • 阶段3: 即便coordinator或watchdog未收到宕机participant的commit ACK,也结束该次事务;宕机的participant恢复后发现收到commit或者precommit,也将自行commit该次事务

因为有了准备提交(prepare to commit)阶段,3PC的事务处理延时也增加了1个RTT,变为3个RTT(propose+precommit+commit),但是它防止participant宕机后整个系统进入阻塞态,增强了系统的可用性,对一些现实业务场景是非常值得的。

paxos

2PC:同步阻塞、单点问题、脑裂、太过保守

3PC:主要解决的单点故障问题,并减少阻塞,但依然存在数据不一致以及太过保守问题

2PC协议用于保证属于多个数据分片上的操作的原子性。这些数据分片可能分布在不同的服务器上,2PC协议保证多台服务器上的操作要么全部成功,要么全部失败。

Paxos协议用于保证同一个数据分片的多个副本之间的数据一致性。

Paxos算法要解决的问题就是如何在可能发生几起宕机或网络异常的分布式系统中,快速且正确地在集群内部对某个数据的值达成一致,并且保证不论发生以上任何异常,都不会破坏整个系统的一致性

复制策略

很多资料介绍paxos时,很学术,上来就是提案者,接受者~ 我也是云里雾里,只能不明觉历。

罗列一些问题:

一致性是什么?以前怎么处理一致性问题?

没有paxos时,以前的解决方案有哪些问题?

paxos怎么演变而来?

paxos怎么解决问题的?

理论背景的缺失,让人难以理解!

看到了可靠分布式系统基础 Paxos 的直观解释,感觉有点明白了。引用一下!

对于一致性,现在一些方案大都是走复制模式,如主从及进化的主从

主从异步复制

如Mysql的binlog复制

  1. 主接到写请求
  2. 主写入本磁盘
  3. 主应答‘OK’
  4. 主复制数据到从库

如果磁盘在复制前损坏: 数据丢失

image

主从同步复制

  1. 主接到写请求
  2. 主复制日志到从库
  3. 从库这时可能阻塞
  4. 客户端一直等待应答OK,直到所有从库返回

一个失联节点造成整个系统不可用
image

主从半同步复制

  1. 主接到写请求
  2. 主复制日志到从库
  3. 从库可能阻塞
  4. 如果1<=x<=n个从库返回OK,刚返回客户端OK

高可靠、高可用、可能任何从库都不完整

多数派写

  1. 客户端写入W >=N/2+1个节点,不需要主
  2. 多数派读:W+R>N;R>=N/2+1
  3. 容忍最多(N-1)/2个节点损坏
  4. 最后1次覆盖先前写入
  5. 所有写入操作需要有1个全局顺序:时间戳

一致性:最终一致性
事务性:非原子更新、脏读、更新丢失问题

多数派读写的不足

一个假想存储服务

  1. 一个有3个存储节点的存储服务集群
  2. 使用多数派读写策略
  3. “i”的每次更新对应有多个版本i1,i2,i3…
  4. 这个存储系统支持3个命令 get读最新的i,set 设置下个版本i的值为n,inc 对i加n

命令实现:

“set” → 直接对应多数派写.

“inc” → (最简单的事务型操作):

  1. 通过多数派读,读取最新的 “i”: i1
  2. Let i2 = i1 + n
  3. set i2

image

并发问题

image

我们期待最终X可以读到i3=5, 这需要Y能知道X已经写入了i2。如何实现这个机制?

在X和Y的2次“inc”操作后,为了得到正确的i3:整个系统里对i的某个版本(i2),只能有1次成功写入.

推广为:在存储系统中,一个值(1个变量的1个版本)在被认为确定(客户端接到OK)之后,就不允许被修改().

如何定义“被确定的”?

如何避免修改“被确定的”值

如何确定一个值

方案:每次写入一个值前,先运行一次多数派读,来确认是否这个值(可能)已经被写过了

image

但是,X和Y可能同时以为还没有值被写入过,然后同时开始写

image

方案改进:让存储节点记住谁最后1次做过“写前读取”,并拒绝之前其他的“写前读取”的写入操作

image

paxos

paxos就是以上的解决方案

将所有节点都写入同一个值,且被写入后不再更改。

两个操作

  1. Proposal Value:提议的值;
  2. Proposal Number:提议编号,可理解为提议版本号,要求不能冲突;

三个角色

  1. Proposer:提议发起者。Proposer 可以有多个,Proposer 提出议案(value)。所谓 value,可以是任何操作,比如“设置某个变量的值为value”。不同的 Proposer 可以提出不同的 value,例如某个Proposer 提议“将变量 X 设置为 1”,另一个 Proposer 提议“将变量 X 设置为 2”,但对同一轮 Paxos过程,最多只有一个 value 被批准。
  2. Acceptor:提议接受者;Acceptor 有 N 个,Proposer 提出的 value 必须获得超过半数(N/2+1)的 Acceptor批准后才能通过。Acceptor 之间完全对等独立。
  3. Learner:提议学习者。上面提到只要超过半数accpetor通过即可获得通过,那么learner角色的目的就是把通过的确定性取值同步给其他未确定的Acceptor。

协议过程

proposer将发起提案(value)给所有accpetor,超过半数accpetor获得批准后,proposer将提案写入accpetor内,最终所有accpetor获得一致性的确定性取值,且后续不允许再修改。

协议分为两大阶段,每个阶段又分为A/B两小步骤:

  1. 准备阶段(占坑阶段)
    • 第一阶段A:Proposer选择一个提议编号n,向所有的Acceptor广播Prepare(n)请求。
    • 第一阶段B:Acceptor接收到Prepare(n)请求,若提议编号n比之前接收的Prepare请求都要大,则承诺将不会接收提议编号比n小(<=)的提议,并且带上之前Accept的提议中编号小于n(<)的最大的提议,否则不予理会。
  2. 接受阶段(提交阶段)
    • 第二阶段A:整个协议最为关键的点:Proposer得到了Acceptor响应
    • 如果未超过半数accpetor响应,直接转为提议失败;
    • 如果超过多数Acceptor的承诺,又分为不同情况:
    1. 如果所有Acceptor都未接收过值(都为null),那么向所有的Acceptor发起自己的值和提议编号n,记住,一定是所有Acceptor都没接受过值;
    2. 如果有部分Acceptor接收过值,那么从所有接受过的值中选择对应的提议编号最大的作为提议的值,提议编号仍然为n。但此时Proposer就不能提议自己的值,只能信任Acceptor通过的值,维护一但获得确定性取值就不能更改原则;
    • 第二阶段B:Acceptor接收到提议后,如果该提议版本号不等于自身保存记录的版本号(第一阶段记录的),不接受该请求,相等则写入本地。

整个paxos协议过程看似复杂难懂,但只要把握和理解这两点就基本理解了paxos的精髓:

  1. 理解第一阶段accpetor的处理流程:如果本地已经写入了,不再接受和同意后面的所有请求,并返回本地写入的值;如果本地未写入,则本地记录该请求的版本号,并不再接受其他版本号的请求,简单来说只信任最后一次提交的版本号的请求,使其他版本号写入失效;
  2. 理解第二阶段proposer的处理流程:未超过半数accpetor响应,提议失败;超过半数的accpetor值都为空才提交自身要写入的值,否则选择非空值里版本号最大的值提交,最大的区别在于是提交的值是自身的还是使用以前提交的

简单讲,在prepare阶段,以编号大的为准;在accept阶段以值为准

参考资料

漫谈事务与分布式事务(3)- 分布式困境

分布式系统理论基础 - 一致性、2PC和3PC

可靠分布式系统基础 Paxos 的直观解释

如何浅显易懂地解说 Paxos 的算法

Paxos算法的理解

以两军问题为背景来演绎Basic Paxos

Basic Paxos算法