0%

远程过程调用(Remote Procedure Call,简称RPC),在微服务大行其道的今天,得到了广泛的应用。因此,在分布式系统服务群中开发应用,了解RPC一些原理和实现架构,还是很有必要的。本文,将从大的框架层面来聊聊RPC原理和实现。

前言

远程过程调用RPC,就是客户端基于某种传输协议通过网络向服务提供端请求服务处理,然后获取返回数据(对于ONE WAY模式则不返还响应结果);而这种调用对于客户端而言,和调用本地服务一样方便,开发人员不需要了解具体底层网络传输协议。简单讲,就是本地调用的逻辑处理的过程放在的远程的机器上,而不是本地服务代理来处理。

目前,Java界的RPC中间件百家争鸣,国内开源的就有阿里的Dubbo(当当二次开发的DubboX),新浪Motan;国外跨语言的有Facebook的Thrift, Google的gRpc等。

LPC & IPC

既然存在RPC这种远程过程调用,必然会有与之对应的本地过程调用了。本地过程调用在不同的操作系统中,叫法不同,使用方式也不太一样。在Windows编程中,称为LPC;在linux编程中,更习惯称之为IPC,即进程间通信。

但是,不管如何,其本质上就是本地机器上的不同进程之间通信协作的调用方式。

服务端开发,一般我们基于Linux,所以这里简单介绍下Linux环境下 IPC实现方式:

  • 管道
  • 共享内存
  • 信号量
  • Socket套接字

除此之外,还有消息队列和信号两种实现进程间通信的方式。

信号很容易理解,比如我们在控制台输入的CTRL + C来向执行的进程发送kill信号来结束该进程。对于信号,一般我们再终端交互窗口中使用比较多,在服务端开发中很少涉及。

Linux提供的消息队列和各种分布式MQ不同,它是在内核中使用链表结构来保持消息的队列,然后其他进程从内核的消息队列中获取消息。目前,Linux官方不太推荐使用,将渐渐被淘汰。

管道

管道命令,在我们的linux shell中经常使用,一般,我们使用|操作符来保证两个命令之间的数据通信。比如,使用命令:

1
ps -ef | grep java | xargs echo

管道命令,其实内部实现就是使用的linux管道接口,每个命令其实是一个进程,各个进程的标准输出STDOUT,作为下一个进程的标准输入STDIN。

Linux管道包含:匿名管道和命名管道。

  • 匿名管道:只能父子进程间通信。使用pipe()方法来创建:

    1
    2
    #include <unistd.h>
    int pipe(int filedis[2]);

    参数filedis返回两个文件描述符:filedes[0]为读而打开,filedes[1]为写而打开。filedes[1]的输出是filedes[0]的输入

  • 命名管道:可以在单台机器内的任何一组进程间进行通信。一般我们使用mkfifo()来创建命名管道:

    1
    2
    3
    #include <sys/types.h>
    #include <sys/stat.h>
    int mkfifo(const char * pathname,mode_t mode)

    成功返回0,失败返回-1。成功返回之后,pathname其实就可以看着一个管道文件操作(当然并没有真实文件在磁盘存在),对于文件操作的方法例如open,read,write都适用于fifo命名通道。

信号量Semaphore

Linux中的信号量和Java中的信号量一样,其主要用处是同步协作。

信号量其实就是一个比较特殊的变量,然后对它的操作都是原子进行的,并且一般只提供两种方法:P和V操作(在java中为wait()和notify())。

  • P(sv):如果sv的值大于零,就给它减1;如果它的值为零,就挂起该进程的执行;
  • V(sv):如果有其他进程因等待sv而被挂起,就让它恢复运行,如果没有进程因等待sv而挂起,就给它加1。

linux对外提供的API接口方法如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
struct sem {
short sempid;/* pid of last operaton */
ushort semval;/* current value */
ushort semncnt;/* num procs awaiting increase in semval */
ushort semzcnt;/* num procs awaiting semval = 0 */
}

   #include <sys/types.h>
   #include <sys/ipc.h>
   #include <sys/sem.h>
//首先获取一个信号量,只有该方法可以才能直接使用key,其他方法必须先semget然后才能使用信号量
   int semget(key_t key, int nsems, int flag);
//对信号量进行操作,直接控制信号量信息,比如删除信号量
int semctl(int semid, int semnum, int cmd, union semun arg);
//改变信号量的值,P,V操作都是通过该方法
int semop(int sem_id, struct sembuf *sem_opa, size_t num_sem_ops);

信号量的主要作用就是同步,所以我们一般是使用共享内存方式完成进程间通信,而在此过程中通过信号量来完成多进程间的同步协调机制。

共享内存

由于同一台机器的硬件设备一般对于同一个系统来说,都是共享的。所以使用内存来完成进程间通信开发的思路,必然是很容易想到的,但是未必容易做到。

众所周知,进程和线程最大的区别就是一些资源是否隔离。也就是说,不同的进程,其内存资源使用是隔离独立的,每个进程有自己的一套内存地址映射逻辑,也即是系统是无法直接从不同进程的相同虚拟内存地址找到共同的物理内存地址的,这样,就无法像线程一样,简单把数据对象设置为static然后线程间就可以共享获取了。

因此,Linux对外提供了共享内存的方法来完成进程间通信。

共享内存是最有效的进程间通信方式。其对外提供的API如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
 #include <sys/types.h>
  #include <sys/ipc.h>
   #include <sys/shm.h>

//创建共享内存空间,大小为size
int shmget(key_t key, size_t size, int shmflg);
//所有需要使用共享内存通信的进程,映射到自身的内存地址空间中
void *shmat(int shmid, void *addr, int flag);
//从当前进程地址空间中分离该共享内存
int shmdt(const void *shmaddr);
//控制共享内存的,比如删除该共享内存空间等
int shmctl(int shm_id, int command, struct shmid_ds *buf);

从上面的方法可以很显然的看出,进程间的内存地址空间是独立隔离的(内核地址空间由于虚拟地址和物理地址是一致的,所以在进程间这块地址空间也是一致的,不过我们操作的都是用户空间的内存,所以不考虑这块)。当我们想要共享操作,必须要把物理内存分别绑定到对应进程的地址空间,才能共享操作。

使用的时候,很简单。shmat方法返回一个void *就可以强转某个指定的struct,然后直接操作该对象结构体即可。由于共享,所以需要考虑多线程同步安全问题。

Socket套接字

从上面的几个API方法可以看到都是利用单机同用一套资源,然后各自进程的资源之间通过内核方式或者内存方式协作完成单机多进程间通信。

此外,还有一种方式来完成进程间通信,就是套接字socket。Socket一般情况下是用在不同的两台机器的不同进程之间通信的,当Socket创建时的类型为 AF_LOCAL或AF_UNIX时,则是本地进程通信了(当然你也可以直接使用网络套接字,如果你觉得走下网络更酷,或者以后便于服务分离)。

关于Socket的API介绍,这里就省略了。服务端/客户端模式的介绍和示例相对很常见,也很容易开发和理解。

从使用网络套接字Socket来实现进程间通信这个角度来说,其和RPC并没有什么不同了,所以有些文献分类时,说广义来讲RPC也应该包括LPC(IPC),因为从大的来讲,单机进程通信其实算是远程过程调用的一种特殊简化的方式而已。

当然,本文还是觉得还是区别开比较通用,也便于理解。

如在Socket介绍的那样,本地过程调用很多情况下都是依赖操作系统对外提供的API来协调操作某个共享资源来完成进程间的数据交换。

如果不依赖单机共享资源,就只有Socket接口。因此,如果要扩展到分布式环境下的进程间通信,那就只能使用网络套接字来完成。

说完单机的服务调用,在互联网时代,自然要讲web服务(Web Service)了。

Web Service技术

Web Service一般有两种定义:

  1. 特指 W3C组织制定的web service规范技术。其包括SOAP(一个基于XML的可扩展消息信封格式,需同时绑定一个网络传输协议。这个协议通常是HTTP或HTTPS,但也可能是SMTP或XMPP)、WSDL(一个XML格式文档,用以描述服务端口访问方式和使用协议的细节。通常用来辅助生成服务器和客户端代码及配置信息)和UDDI(一个用来发布和搜索WEB服务的协议,应用程序可借由此协议在设计或运行时找到目标WEB服务)。从上面三个定义就可以看出,这种规范技术是一个重量级的协议。
  2. 泛指网络系统对外提供web服务所使用的技术。这里,我们主要是基于该定义来理解。

一般而言,技术体系,必然是服务于架构体系的。不同的架构,所约定的技术结构设计还是有些区别的。

因此,要了解web服务技术,必然要先了解其服务于哪个架构体系;也就是说,先去了解技术产生的架构背景。

SOA & 微服务

在分布式网络服务架构体系中,最火的莫过于 SOA(面向服务架构,Service-Oriented Architecture)和微服务。

嗯,一般将服务化架构,必然会扯到全家桶设计升级的故事。

简化版是这样子的:

  • 在很久很久以前,网络应用也是单机部署的,所有的业务代码全部都在一个大项目内,然后更改一个逻辑就需要重启部署应用,停止对外服务。
  • 然后,这样子肯定不行的,就有了多机部署,通过Nginx或者其他代理/均衡软件来分发请求到相同服务的不同机器上,当其中一台机器停机部署时,请求全部打到其他机器上去。但是这个时候,所有机器上的代码还是一套。
  • 后来,机器不断升级,但是业务不断变多,项目代码越来越大,更改一个地方编译打包部署时间非常长,于是,我们就把一些独立隔离开的业务代码分成多个项目。但是,实现业务逻辑的时候,必然有一些功能和数据是多个业务都会用到的,简单以前的代码copy过来,数据就直接操作数据库。但是,当有个公用的功能需要更改时,就发现所有相关业务都需要更改,并且数据库上的操作,还会带来其他同步兼容等等问题。
  • 于是,就出现了SOA,也就是基于服务的架构设计理念。SOA的设计理念,就是把所有的服务都对外以HTTP或者其他协议方式对外暴露,绝对不允许相同的服务在不同的业务系统独立一套,然后共用底层数据库。服务化的设计系统,所有拆分的业务,彼此之间都通过暴露的服务接口通信,操作对方的数据。这样,各个业务系统之间开始独立自主的向着美好的方向发展了。
  • 再后来,单个业务发展的越来越好,提供的功能也越来越多,这样一个业务系统的代码也变得很大了,开发人员也越来越多。于是乎,单个业务系统内部就存在问题了,当然,我们也可以拆分成不同的业务系统来开发发展。但是,单个业务系统,很多的公用逻辑都是一些业务细节,并不好独立成业务系统;此外,单个业务系统开发人员都很容易交流,因此,对于内部业务系统的架构设计,就出现了微服务Micro-Service了。我们把单个业务系统中一些功能细节的结构封装成服务,大的对外业务系统,组装各个微服务的接口数据,然后提供SOA服务。

因此,SOA其实和微服务,从我的视角来看,其实就是 业务外部和内部服务的不同架构设计而已,其技术框架很大程度上都可以通用。其区别如下图:

SOA和微服务

从上面发展历程可以看到,SOA一般使用SOAP或者REST方式来提供服务,这样外部业务系统可以使用通用网络协议来处理请求和响应,而微服务,还可以有一些私有的协议方式来提供服务,例如基于自定义协议的RPC框架。RPC使得调用服务简单,但是需要一些其他耗时间的交流协调工作,这适合微服务的场景,但是不一定适合SOA场景了。

web服务技术结构

先给出一个web服务的技术体系结构图:

web服务技术体系

web service被W3C设立规范之初,SOAP方案就被提出来。但是,随着服务化技术和架构的发展,SOAP多少有点过于复杂,因此就出现了简化版的REST方案。此后,由于分布式服务应用越来越大,对性能和易用性上面要求越来越大,因此就出现了RPC框架(很多时候,RPC并不被当做一种web service方案。在绝大部分博客中,介绍web service 只会讨论 SOAP和REST,主要是其基本上都是基于SOA来介绍服务方案)。

SOAP

SOAP,全称为 Simple Object Access Protocol,也就是 简单对象访问协议。跟着web service一起出来的,说明历史悠久,不过感觉现在也慢慢要淘汰了。

SOAP,是基于XML数据格式来交换数据的;其内部定义了一套复杂完善的XML标签,标签中包含了调用的远程过程、参数、返回值和出错信息等等,通信双方根据这套标签来解析数据或者请求服务。与SOAP相关的配套协议是WSDL (Web Service Description Language),用来描述哪个服务器提供什么服务,怎样找到它,以及该服务使用怎样的接口规范,类似我们现在聊服务治理中的服务发现功能。

因此,SOAP服务整体流程是:首先,获得该服务的WSDL描述,根据WSDL构造一条格式化的SOAP请求发送给服务器,然后接收一条同样SOAP格式的应答,最后根据先前的WSDL解码数据。绝大多数情况下,请求和应答使用HTTP协议传输,那么发送请求就使用HTTP的POST方法。

REST

REST,全称 REpresentational State Transfort,也就是 表示性状态转移。由于SOAP方案过于庞大复杂,在很多简单的web服务应用场景中,轻量级的REST就出现替代SOAP方案了。

和SOAP相比,REST只是对URI做了一些规范,数据才有JSON格式,底层传输使用HTTP/HTTPS来通信,因此,所有web服务器都可以快速支持该方案;开发人员也可以快速学习和使用。

SOAP & REST

从命名来看,SOAP是一种协议,而REST只是一种方案。协议的设计很多时候,从上而下一整套都是新的,需要设计开发专门的工具支持;而方案相对就是基于目前以后的工具来做一些设计和约束,这就是为什么REST快速替换了SOAP的地位。

REST特点:

  • 由于数据返回格式是自定义的,绝大部分使用JSON,这种数据结构节省带宽,并且前端JavaScript能天生支持。
  • 无状态,基于HTTP协议,所以只能适应无状态场景。

SOAP特点:

  • 协议有安全性的一些规范。
  • 基于xml的标签约束,而且也不要去底层是HTTP传输,所以支持有状态的场景。

RPC家族

RPC家族中,RMI是Java制定的远程通信协议。而后,基本上RPC框架都或多或少有RMI的影子(当然,其实主要是RPC本身的实现方式就是这样子了-_-)。RMI既然是Java的标准RPC组件,那必然其他编程语言就无法使用了;因此,Thrift这种基于IDL来跨语言的RPC组件就出现了。Thrift的使用者,只需要按照Thrift官方规定的方式来写API结构,然后生成对应语言的API接口,继而就可以跨语言完成远程过程调用了。但是,作为服务化的组件,如果没有服务治理来完成大规模应用集群中服务调用管理工作,则运维工作则是非常繁重的,因此类似dubbo这种包含服务治理的RPC组件出现了。

下面,就来介绍RPC组件。

RPC介绍

RMI作为Java自带的官方RPC组件,单独介绍;然后我们来看看通用RPC实现结构。

RMI介绍

RMI,全称是Remote Method Invocation,也就是远程方法调用。在JDK 1.2的时候,引入到Java体系的。当应用比较小,性能要求不高的情况下,使用RMI还是挺方便快捷的。

下面先看看RMI的调用流程。

RMI服务调用流程

其中,有些概念需要说明:

stub(桩):stub实际上就是远程过程在客户端上面的一个代理proxy。当我们的客户端代码调用API接口提供的方法的时候,RMI生成的stub代码块会将请求数据序列化,交给远程服务端处理,然后将结果反序列化之后返回给客户端的代码。这些处理过程,对于客户端来说,基本是透明无感知的。

remote:这层就是底层网络处理了,RMI对用户来说,屏蔽了这层细节。stub通过remote来和远程服务端进行通信。

skeleton(骨架):和stub相似,skeleton则是服务端生成的一个代理proxy。当客户端通过stub发送请求到服务端,则交给skeleton来处理,其会根据指定的服务方法来反序列化请求,然后调用具体方法执行,最后将结果返回给客户端。

registry(服务发现):rmi服务,在服务端实现之后需要注册到rmi server上,然后客户端从指定的rmi地址上lookup服务,调用该服务对应的方法即可完成远程方法调用。registry是个很重要的功能,当服务端开发完服务之后,要对外暴露,如果没有服务注册,则客户端是无从调用的,即使服务端的服务就在那里。

下面给出一个简单的Java示例来show code下。

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
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
/**
* 接口必须继承RMI的Remote
*/
public interface RmiService extends Remote {

/**
* 必须有RemoteException,才是RMI方法
*/
String hello(String name) throws RemoteException;
}

/**
* UnicastRemoteObject会生成一个代理proxy
*/
public class RmiServiceImpl extends UnicastRemoteObject implements RmiService {

public RmiServiceImpl() throws RemoteException {
}

public String hello(String name) throws RemoteException {
return "Hello " + name;
}
}

/**
* 服务端server启动
*/
public class RmiServer {

public static void main(String[] args) {
try {
RmiService service = new RmiServiceImpl();
//在本地创建和暴露一个注册服务实例,端口为9999
LocateRegistry.createRegistry(9999);
//注册service服务到上面创建的注册实例上
Naming.rebind("rmi://127.0.0.1:9999/service1",service);
}catch (Exception e){
e.printStackTrace();
}
System.out.println("------------server start-----------------");
}
}


/**
* 客户端调用rmi服务
*/
public class RmiClient {
public static void main(String[] args) {
try {
// 根据注册的服务地址来查找服务,然后就可以调用API对应的方法了
RmiService service = (RmiService)Naming.lookup("rmi://localhost:9999/service1");
System.out.println(service.hello("RMI"));
}catch (Exception e){
e.printStackTrace();
}
}
}

上面一些核心的代码已经在注释中给了说明。

通用RPC架构

一般,远程过程调用RPC就是本地动态代理隐藏通信细节,通过组件序列化请求,走网络到服务端,执行真正的服务代码,然后将结果返回给客户端,反序列化数据给调用方法的过程。

RPC具体调用流程如下所示: RPC调用流程

通用的RPC组件一般包括以下一些模块:

  1. serviceClient:这个模块主要是封装服务端对外提供的API,让客户端像使用本地API接口一样调用远程服务。一般,我们使用动态代理机制,当客户端调用api的方法时,serviceClient会走代理逻辑,去远程服务器请求真正的执行方法,然后将响应结果作为本地的api方法执行结果返回给客户端应用。类似RMI的stub模块。
  2. processor:在服务端存在很多方法,当客户端请求过来,服务端需要定位到具体对象的具体方法,然后执行该方法,这个功能就由processor模块来完成。一般这个操作需要使用反射机制来获取用来执行真实处理逻辑的方法,当然,有的RPC直接在server初始化的时候,将一定规则写进Map映射中,这样直接获取对象即可。类似RMI的skeleton模块。
  3. protocol:协议层,这是每个RPC组件的核心技术所在。一般,协议层包括编码/解码,或者说序列化和反序列化工作;当然,有的时候编解码不仅仅是对象序列化的工作,还有一些通信相关的字节流的额外解析部分。序列化工具有:hessian,protobuf,avro,thrift,json系,xml系等等。在RMI中这块是直接使用JDK自身的序列化组件。
  4. transport:传输层,主要是服务端和客户端网络通信相关的功能。这里和下面的IO层区分开,主要是因为传输层处理server/client的网络通信交互,而不涉及具体底层处理连接请求和响应相关的逻辑。
  5. I/O:这个模块主要是为了提高性能可能采用不同的IO模型和线程模型,当然,一般我们可能和上面的transport层联系的比较紧密,统一称为remote模块。

此外,还有业务代码自己去实现的client和server层。client当需要远程调用服务时,会首先初始化一个API接口代理对象,然后调用某个代理方法。server在对外暴露服务时,需要首先实现对应API接口内部的方法,当请求过来时,通过反射找到对应的实例对象,执行对应的业务代码。

简单RPC组件实现

介绍完RPC相关结构和概念之后,给一个简单的RPC组件示例来对各个模块进行code级别的说明。

以下代码仅仅是了解RPC各个模块功能的示例,对性能和异常等情况未考虑全面,生产环境不适用。

protocol模块代码

协议层主要包括编解码和序列化部分。编解码就是我们对传输通信的远程调用请求接口和方法参数等数据按照我们规定的格式进行组装编码,然后在接收的一方负责把数据解码成原始的对象,然后找到需要执行的接口和方法。序列化/反序列化,则是将数据对象,按照一定的映射关系转换成字节流,供网络传输,接收的一方首先将流映射为对象数据。

有的时候,序列化/反序列化组件会包含编解码部分。此外,编解码和序列化工作先后关系也不一定。一般高性能RPC,序列化工具十分强大和通用,所以编解码部分会放在序列化之后,主要是解码的时候,可以不完成反序列化就对流进行一些处理工作,比如映射、分发等。

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
/**
* 很明显,这里使用JSON来序列化和反序列化RPC调用传递的数据
*/
public class ServiceProtocol {

public static final ServiceProtocol protocol = new ServiceProtocol();

/**
* 将对象序列化为字符串字节
*/
public byte[] encode(Object o) {
return JsonUtils.encode(o).getBytes();
}

/**
* 反序列化成字符串
*/
public <T> T decode(byte[] data, Class<T> clazz) {
return JsonUtils.decode(new String(data), clazz);
}

/**
* 编解码模型
*/
public static class ProtocolModel {
private String clazz;
private String method;
private String[] argTypes;
private Object[] args;

// setter getter方法省略
}
}

示例中的代码使用JSON来序列化/反序列化工作。由于JSON序列化组件比较弱,所以这边需要将执行调用方法相关的请求数据进行编码成ProtocolModel对象。

remote模块代码

remote模块是提供服务端和客户端通信的功能。因此,在服务端需要起一个端口来监听外部的请求,在客户端则负责发送请求,接收响应数据。

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
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
/**
* 客户端通信组件,客户端和外部服务端数据交互时使用
*/
public class ClientRemoter {

public static final ClientRemoter client = new ClientRemoter();

public byte[] getDataRemote(byte[] requestData) {

try (Socket socket = new Socket()) {
socket.connect(new InetSocketAddress("127.0.0.1", 9999));
socket.getOutputStream().write(requestData);
socket.getOutputStream().flush();

byte[] data = new byte[10240];
int len = socket.getInputStream().read(data);

return Arrays.copyOfRange(data, 0, len);
} catch (Exception e) {
e.printStackTrace();
return null;
}
}
}


/**
* 服务端起一个端口监听服务,绑定到相关processor处理器上。
*/
public class ServerRemoter {

private static final ExecutorService executor =
Executors.newFixedThreadPool(Runtime.getRuntime().availableProcessors());

public void startServer(int port) throws Exception {

final ServerSocket server = new ServerSocket();
server.bind(new InetSocketAddress(port));
System.out.println("-----------start server----------------");
try {
while (true) {
final Socket socket = server.accept();
executor.execute(new MyRunnable(socket));
}
} finally {
server.close();
}
}

class MyRunnable implements Runnable {

private Socket socket;

public MyRunnable(Socket socket) {
this.socket = socket;
}

public void run() {

try (InputStream is = socket.getInputStream(); OutputStream os = socket.getOutputStream()) {

byte[] data = new byte[10240];
int len = is.read(data);

ServiceProtocol.ProtocolModel model = ServiceProtocol.protocol
.decode(Arrays.copyOfRange(data, 0, len), ServiceProtocol.ProtocolModel.class);
Object object = ServiceProcessor.processor.process(model);
os.write(ServiceProtocol.protocol.encode(object));
os.flush();
} catch (IOException e) {
e.printStackTrace();
} finally {
// close socket...
} } }
}

简单处理,直接让网络一次获取所有的数据,假设一次请求和响应的数据大小小于10K。

在server端的remote中,启动服务之前是需要绑定对外提供的服务的,也就是服务server启动,其内部需要指定序列化、服务处理器等逻辑。

通用RPC的通信层,是非常复杂的,其需要考虑各种网络环境导致的数据半包,分包和粘包情况,需要考虑高性能NIO组件,多线程处理超时,连接复用等等。

processor模块代码

服务端接口方法定位处理器。作为一个组件,显然不应该在业务代码中嵌入一些非业务逻辑。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
public class ServiceProcessor {

public static final ServiceProcessor processor = new ServiceProcessor();

private static final ConcurrentMap<String, Object> PROCESSOR_INSTANCE_MAP = new ConcurrentHashMap<String, Object>();


public boolean publish(Class clazz, Object obj) {
return PROCESSOR_INSTANCE_MAP.putIfAbsent(clazz.getName(), obj) != null;
}

public Object process(ServiceProtocol.ProtocolModel model) {
try {
Class clazz = Class.forName(model.getClazz());

Class[] types = new Class[model.getArgTypes().length];
for (int i = 0; i < types.length; i++) {
types[i] = Class.forName(model.getArgTypes()[i]);
}

Method method = clazz.getMethod(model.getMethod(), types);

Object obj = PROCESSOR_INSTANCE_MAP.get(model.getClazz());
if (obj == null) {
return null;
}

return method.invoke(obj, model.getArgs());
} catch (Exception e) {
e.printStackTrace();
return null;
}
}
}

PROCESSOR_INSTANCE_MAP publish这个逻辑,在Spring环境中,一般通过xml配置自动注入进来,然后从context中获取对应的实例。但是,不管怎样,底层其实都是一个map来维护映射关系。

如上文介绍的那样,经过解码获取到的调用对象,然后通过java反射机制,执行指定的方法获取结果。

serviceClient模块代码

其实,这块叫做serviceProxyClient比较直接点。

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

public static <T> T getInstance(Class<T> clazz) {
return (T) Proxy.newProxyInstance(clazz.getClassLoader(), new Class[] {clazz}, new ServiceProxy(clazz));
}

public static class ServiceProxy implements InvocationHandler {

private Class clazz;

public ServiceProxy(Class clazz) {
this.clazz = clazz;
}

public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {

ServiceProtocol.ProtocolModel model = new ServiceProtocol.ProtocolModel();
model.setClazz(clazz.getName());
model.setMethod(method.getName());
model.setArgs(args);

String[] argType = new String[method.getParameterTypes().length];
for (int i = 0; i < argType.length; i++) {
argType[i] = method.getParameterTypes()[i].getName();
}
model.setArgTypes(argType);

byte[] req = ServiceProtocol.protocol.encode(model);
byte[] rsp = ClientRemoter.client.getDataRemote(req);
return ServiceProtocol.protocol.decode(rsp, method.getReturnType());
}
}
}

ProxyClient就是对客户端调用API时透明化底层序列化和网络操作相关细节。所以,在proxyClient内部,我们可以看到它封装代理了这块调用逻辑,业务代码直接使用getInstance方法就可以获取对象实例,然后按照正常使用api方法来执行调用逻辑,获取结果。

如果使用spring框架的话,可以进一步封装成一个bean,然后客户端业务代码只需要在xml中配置一下,就可以通过注解annotation等方式注入进来。

server业务接口实现代码

这里给出接口对外发布和测试

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
public interface RpcService {
String sayHi(String name);
}
public class RpcServiceImpl implements RpcService {
public String sayHi(String name) {
return "Hello," + name;
}
}

/**
* 服务端测试main执行代码
*/
public class ServerDemo {

public static void main(String[] args) throws Exception {

// 发布接口
ServiceProcessor.processor.publish(RpcService.class,new RpcServiceImpl());

// 启动server
ServerRemoter remoter = new ServerRemoter();
remoter.startServer(9999);

}
}

如上,我们构造了一个RpcService接口对外提供sayHi的服务。在main方法中,我们首先需要对外发布这个接口和对应的实现类对象。在一些框架中,这些对外暴露的接口,都是通过xml配置或者annotation来发布的。然后,我们就可以启动server服务,对外提供RPC服务。

6. client调用测试代码

1
2
3
4
5
6
7
8
9
public class ClientDemo {

public static void main(String[] args) {
System.out.println("----------start invoke----------------");
RpcService service = ServiceProxyClient.getInstance(RpcService.class);
System.out.println(service.sayHi("RPC World"));
System.out.println("----------end invoke----------------");
}
}

看我们的测试代码非常简单,当要远程调用某个接口方法时,只需要getInstance该接口类代理对象,然后就像调用本地方法一直执行方法执行和结果处理。

RPC技术深入

上文简单的介绍了RPC模块各个部分,并且实现了一个简单的RPC组件。这一部分,我们要介绍在生产环节下RPC需要使用的一些技术点。

RPC序列化

将RPC序列化和编解码分开,是因为个人觉得,虽然在很多时候,编解码其实就是序列化操作,但是有的时候,我们会自定义一些数据结构来封装业务数据对象,然后再序列化成二进制流。此外,在协议层,我们可能也会对普通序列化完了之后,还会对传输头进行编码工作。因此,为了更好的说明,这里分开来。

序列化,说的简单,就是将对象转换成二进制流,也就是byte[],而反序列化就是讲二进制流转换成对象。使用序列化/反序列化,主要是我们想把内存对象数据,持久化到文件fd或者通过网络传输到其他地方,而这只能使用二进制流来呈现。此外,由于RPC是通过网络通信的,所以序列化工具的性能和二进制流的大小,都是直接影响整体处理能力的关键因素。

目前基于Java的序列化工具,主要有:

  • JDK Serializable工具
  • Hessian工具
  • Kryo工具
  • JSON工具

JDK内置序列化工具

JDK自带的序列化工作不需要引入任何第三方包就可以直接使用,我们仅仅只需要实现java.io.Serializable接口。然后,我们在需要序列化/反序列化的时候,直接使用ObjectInputStream/ObjectOutStream来readObject将流反序列化成对象或者writeObject将对象序列化成流。

很多时候,我们并不使用原生的JDK序列化工具进行序列化,主要原因是因为其序列化后的二进制流太大,并且序列化耗时也比较长。但是,其最大的优点就是原生支持,快速使用,引入成本低,此外,其支持java所有类型,所以在有些RPC组件中,其作为默认序列化工具。

使用JDK自带的序列化工具,尤其需要注意serialVersionUID这个静态变量,在反序列化的时候,会根据这个变量来判断两个类是否一样,如果修改了该变量,那么将无法兼容来的二进制数据的反序列化操作。

此外,你可以通过在类中增加writeObject 和 readObject 方法可以实现自定义序列化。

测试代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
public class JdkSerialiable {
public static void serial(Blog blog) throws Exception {
ByteArrayOutputStream baos = new ByteArrayOutputStream();
ObjectOutputStream os = new ObjectOutputStream(baos);
os.writeObject(blog);
os.close();

ObjectInputStream is = new ObjectInputStream(new ByteArrayInputStream(baos.toByteArray()));
Blog blog1 = (Blog) is.readObject();
is.close();
System.out.println(blog1);
}
}

Hessian工具

Hessian,其实是一个开源的轻量级RPC组件。从上面分析RPC通用结构,可以看到很多RPC为了性能会自己实现序列化/反序列化工具,比如Thrift,而hessian也是如此。hessian2的性能相对JDK来说,提高了很多,而且序列化完了之后的流也小了很多。由于hessian已经生产实践了很长时间,所以其还是很值得使用的。

hessian在处理序列化的时候,会根据对象的数据类型采用不同的序列化策略,比如有些直接使用JavaSerializer,有些事自己来实现对应类型的序列化方法,其实就是如上面所介绍的那样,实现对应类型的writeObjectreadObject方法。

我们只是使用hessian工具来完成序列化和反序列化工作,如果你需要自己实现一个自定义序列化工具,那么可以参考hessian的实现方式。

测试代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class HessianSerialibale {

public static void serial(Blog blog) throws Exception{

ByteArrayOutputStream baos = new ByteArrayOutputStream();
Hessian2Output os = new Hessian2Output(baos);
os.writeObject(blog);
os.close();
Hessian2Input is = new Hessian2Input(new ByteArrayInputStream(baos.toByteArray()));
Blog blog1 = (Blog) is.readObject();
is.close();
System.out.println(blog1);
}
}

尤其需要说明,在上面的测试代码中,如果不将os close掉,则一直会报错,告诉java.io.EOFException: readObject: unexpected end of file.

此外,处理性能上的优势,hessian还可以在serialVersionUID被后期更改的时候,反序列化也没有问题。这是因为,hessian不依赖UID来匹配类型,而且hessian在序列化完了之后的二进制流里面,会保留每个field对应的一些属性信息,虽然这些信息会增加一点流大小,但是对反序列化工作很有帮助。

Kryo工具

关于Kryo的性能对比,可以参考各种 Java 的序列化库的性能比较测试结果

Kryo是一个快速高效的Java对象序列化框架,其在java的序列化上的性能指标甚至优于google著名的序列化框架protobuf,已经在Twitter、Groupon、Yahoo以及多个著名开源项目(如Hive、Storm)中广泛的使用。总之,Kryo性能非常霸道。

测试代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public class KryoSerializable {

public static void serial(Blog blog)throws Exception{
Kryo kryo = new Kryo();
ByteArrayOutputStream baos = new ByteArrayOutputStream();
Output output = new Output(baos);
kryo.writeClassAndObject(output, blog);
output.close();

ByteArrayInputStream bais = new ByteArrayInputStream(baos.toByteArray());
Input input = new Input(bais);
Blog blog1 = (Blog) kryo.readClassAndObject(input);
input.close();
System.out.println(blog1);
}
}

由于Kryo工具生成的字节码中是不包含field元数据信息的,这样的话,在兼容性上就很难处理了。比如我现在对一个对象增加一个字段属性,但是这样子的话,老的所有序列化二进制流就无法被正常反序列化成对象了。在很多场景下,这都是无法容忍的。

JSON工具

JSON工具进行序列化和反序列化在上文已经进行了说明,并且RPC示例代码就是使用这种方式。其性能上跟hessian差不多,并且反序列化兼容会很,但是其有个比较大的缺点,就是很多类型,可能JSON工具无法支持,并且其是基于String然后再转成二进制流的,所以流的大小,可能并没有想象的那么好。

RPC协议编解码

除了序列化,在编码的上/下游还需要对二进制流或者对象做一些额外的处理,而这些处理本身和二进制流化没有太大关系。

比如dubbo给出的处理流程,可以清晰的看出序列化和编码之间的区别(个人觉得广义的编码应该包括序列化那部分)如下:

dubbo线程处理流程

每个RPC组件,基本上都是直接基于Socket来开发通信层功能,但是在网络传输的数据由于网络链路和协议的问题,会出现半包,分包和粘包情况。这样就需要设计编解码协议头来解码网络流,如上dubbo视图。

下面我们来看下dubbo的协议编码格式(具体参考:远程通讯细节):

dubbo协议头

Dubbo协议头分析:

协议头固定长度16个字节,也就是128位。这样,当我们解码流的时候,会首先提取前16byte来解析。

先来看看MAGIC设计:

1
2
3
4
// magic header.
protected static final short MAGIC = (short) 0xdabb;
protected static final byte MAGIC_HIGH = Bytes.short2bytes(MAGIC)[0];
protected static final byte MAGIC_LOW = Bytes.short2bytes(MAGIC)[1];

SerializationID表示序列化类型ID,Dubbo支持多种序列化工具,比如hessian,jdk,fastjson等,所以需要在协议头里面指定序列化方式,这样在解码完了之后才能知道内容使用哪种工具反序列化。

event表示事件,比如这个请求是heartbeattwo way表示请求是否是需要交互返回数据的请求。req/res表示该数据是请求还是响应。status表示状态位,当响应数据的时候,根据该字段判断是否成功。

id表示请求id。这个ID真的真的很重要!!!这个id是请求客户端生成的唯一id,保证在服务运行期内id不会重复。此外,在阿里内部的RPC组件HSF最开始是将id放在data数据内,这样只有在反序列化的时候,才能拿到ReqId,但是有些时候ReqId对应的RPC请求可能由于超时或者已经被处理,导致客户端对于这种case直接丢弃就可以。因此,将id放在head里面,则直接解码的时候就可以拿到ReqId去check,而不需要额外反序列化工作。

data length则表示正文内容的长度。解码是通过该字段来判断消息正文字节流的整个完整包,这样反序列化就可以进行正确的转换对象了。

RPC路由和负载均衡

路由策略,是完成单个机器对于服务方调用链路的选择策略,然后把客户端的服务请求传输到具体的某台服务端的机器上。负载均衡是完成路由的一种实现方式,其将前端请求根据一定算法策略来分发到不同机器上,使得集群中机器资源得到充分均衡的利用,此外还可以将不可用机器剔出请求列表。但是,显然路由除了负载均衡之外,还有其他方式。

我们知道,现在的服务后台都是多台机器部署的服务集群,在这些集群在请求的入口,一般会有负责负载均衡的机器部署,来完成请求的合理分发。RPC的结构也是客户端和服务端模式,但是其结构中我们发现是没有中间代理server层的,所以对于客户端在集群中的远程服务调用,就需要客户端自己来完成负载均衡的逻辑了。

除负载均衡之外,我们还会存在其他路由加强方式。比如,我们有多个机房都部署服务的时候,我需要优先选择同机房内的服务调用。

一般定义类似如下的接口,然后根据自己的需求实现自己的负载均衡/路由策略:

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

/**
* 从众多连接池子中选择其中一个池子.
*
* @param invokeConns 客户端维持的和各个服务端维持的连接池对象列表
* @param invocation 本次客户端调用服务端相关的信息
* @return 返回和其中一个服务端维持链接的连接池对象
*/
public InvokeConn select(List<InvokeConn> invokeConns, Invocation invocation);
}

一般RPC组件中,会实现两个通用的负载均衡策略。随机和轮询。具体实现可以参考:https://github.com/ketao1989/ourea/tree/master/ourea-core/src/main/java/com/taocoder/ourea/core/loadbalance

再谈谈维护可用服务列表:

一般我们会在客户端和服务端之间维持长连接,然后通过心跳机制来确保服务端是否在线提供服务。此外,对一些没有维护长连接或者可选择不建立长连接的RPC组件来说,只能通过注册服务机制来监听服务端是否下线。

如果调用比较频繁的服务来说,客户端可以在服务连接未成功的情况下,将该机器从服务连接列表中剔除,放在暂时不可用机器列表,然后起一个定时任务,当机器暂存5s后,再放到可用列表尝试请求服务调用。

关于心跳请求的定时任务,可以参考使用Netty提供的HashedWheelTimernetty源码解读之时间轮算法实现-HashedWheelTimer,其提供了在不要求高精度触发定时任务的场景下,性能非常高。

最后,再聊聊服务调用路由:

服务路由,这里特指除负载均衡之外的一些服务寻址策略。和负载均衡不同的是,这里的路由策略是单个机器根据自身特点做出的服务方选择策略,而负载均衡策略则是基于整个集群中所有机器的普适策略。如上所言,我们的多机房部署,再拿到集群机器列表之后,我们还需要维持一个本机房的机器列表(一般,对服务集群列表进行按IP前缀规则来过滤),这样当我们选择调用机器的时候,会优先从本机房获取连接,如果没有才会按照负载均衡来获取服务调用连接。

此外,对于一些完善的RPC框架,可能还会支持动态可配置路由规则。比如,我们可以按照机器ip来配置,某些客户端调用只能路由到某些服务端机器上。对于线上测试问题跟踪而言,可以很好的根据服务调用链路,来查看日志解决问题。

RPC超时管理

作为一个健康的服务,一定需要超时机制。相当多的服务不可用问题,都是因为客户端没有超时机制,导致服务端抖动的一段时间内,客户端一直处于占用连接等待响应的阶段,耗尽服务端资源,最后导致服务端集群雪崩。所以在请求网络服务的时候,增加超时设置是多么重要(当然,连接使用现在最大连接数的连接池也非常重要)。

RPC的调用实现,一般会有一个IO线程池来处理RPC调用,也就是我们的业务线程会将调用请求交给RPC线程来处理,返回一个future对象。远程调用处理完成之后,RPC线程会将结果填充到futrue对象内部,然后告知调用方调用完成,可以使用futrue.get来获取返回数据。如下所示:

RPC客户端调用处理

从上图可以看出,超时1我们可以直接使用futrue.get特性来设置和处理超时问题。超时2指的是服务端执行的超时,比如我们客户端调用的时间是1s,但是服务端可能会超过1s,而这个时候客户端其实已经超时丢弃这次请求,但是服务端还一直执行直到完成返回,这个时候服务端需要序列化对象然后传输到客户端,但是这个流程其实可以简化的。

因此,服务端的超时管理,是当服务端业务逻辑执行完成之后(这期间实现超时中断比较难),比较执行时间和客户端设置的超时时间,如果接近,则打包服务端超时错误信息返回给客户端即可。这样可以节省序列化数据时间(直接使用序列化好了的数据返回),已经减少网络传输时间。

RPC 服务发现

在对外http服务里,我们有一个配套的支撑基础组件叫做DNS,其根据域名找到某几个外网ip地址。然后,请求打到网站内部,一般首先到nginx群,nginx也会根据url规则找到配置好的一组ip地址,此外,nginx根据healthcheck来检查http服务是否可用。但是,使用nginx时,我们通常需要把ip地址离线配置到nginx上。

我们提供的RPC服务都是集群部署,所以我们需要在客户端维持一个服务调用地址列表。所以,我们也需要类似DNS功能的服务。 但是,我们不想我们的RPC服务集群有机器迁移或者增加时,所需要离线给客户端配置,这就是说,我们还需要实时更新集群机器列表的功能。

这,就是RPC服务发现模块需要解决的问题。

一般,服务发现主要包括2部分:

  1. 服务地址存储;
  2. 服务状态感知。

服务地址存储

服务地址存储,首先需要一个组件来存放服务机器列表等RPC服务数据,提供存储服务的组件有很多,比如:zookeeper,redis,mysql等等。然后,在服务端正常启动可以提供服务之后,需要将自己的服务地址,比如ip,port,以及服务信息,比如接口,版本号等信息,提交到存储服务机器上。然后,客户端在启动的时候,从存储服务的机器上,根据接口,版本等服务信息来拿到提供对应服务的RPC地址列表,客户端根据这个列表就可以开始调用远程服务了。

此外,为了服务治理,比如我们需要知道哪些客户端调用了我们对外提供的服务,就需要客户端在启动的时候,把自己的地址数据和调用的服务信息提交到存储服务上去。

对于提供比较完善的服务治理功能,还可以提供后台操作界面,让某些服务端机器手动操作上/下线,这样让通过RPC调用的客户端不将流量打到下线的服务器上。

简单的服务发现,RPC方和存储组件之间的交互如下:

RPC服务发现结果

服务状态感知

这里的服务感知,包括客户端感知服务端状态,以及存储服务感知RPC参与方的状态。

正常情况下,我们从存储组件那里拿到服务端地址后,自己来处理路由策略,然后选择一个服务端建立连接,执行远程调用。在执行的过程中,如果有服务不可用,我们可以从我们的服务列表中,将它剔除。但是,如果服务增加机器或者服务机器迁移了呢?这就需要我们及时了解服务端集群的整体机器状态。两种方式:

  1. 客户端其一个定时调度任务,周期去存储组件处拉取最新的服务集群地址列表,但是这个周期粒度比较难控制。
  2. 客户端和存储组件建立一个长连接,当存储组件发现有服务集群状态发生变更,推送给客户端。但是,这又要求存储组件具有推送功能。

目前有这个功能的存储组件,主要有zookeeper和redis,此外,也可以自己实现一个简单可靠的服务发现中间件,对外提供推送存储服务。

我们在服务启动的时候,会告知存储组件我们对外提供服务的地址信息和客户端的地址信息;在服务已知操作的服务下线的时候,会将存储组件中存储的服务相关信息清除掉。但是,显然,在服务下线或者客户端下线的时候,都存在没有清除存储信息就宕机的情况,这个时候就需要存储组件需要有感知各个参与方的状态了。

一般,我们会让RPC两方都和存储组件保持连接,然后通过心跳等方式来探测对方是否下线。

目前提供这个功能的存储组件,主要有zookeeper和redis。当然,你也可以实现一个,可以和所有注册服务和查找服务的server保持长连接。由于,可能有大量的机器建立长连接,所以服务器性能一定要高。

基于zookeeper实现服务发现功能的代码,可以参考:https://github.com/ketao1989/ourea

RPC 多线程IO模型

最后

RPC其实是一个说简单简单,说复杂复杂的组件。就如上文写的一个简单的RPC示例,其本身就是一个具备RPC功能的组件。但是,在深入篇中,可以看到每一个模块都可以深入优化,以及支持模块化插件话设计开发。

本文从单机到集群,从本地调用到远程调用的渐进过度。然后再从一个满足RPC结构图的简单示例开始,代码介绍每个模块,进而深入成熟RPC框架所需要考虑和优化的各个技术点。

本文的目的,旨在对RPC整体结构和各个模块进行介绍和深入,然后根据这些点,可以去分析开源的RPC框架或者自己写一个RPC组件。

在本文中,很多点都是一边学习,一边总结,所以知识有限,如有问题,欢迎交流。

参考文献

  1. linux内存管理浅析

  2. 微服务、SOA 和 API:是敌是友?

  3. 序列化和反序列化


本文转载自

深入浅出RPC原理

遵循CC 4.0 BY-SA版权协议


CAS的由来

在JDK 5之前Java语言是靠synchronized关键字保证同步的,有锁机制存在以下问题:

(1)在多线程竞争下,加锁、释放锁会导致比较多的上下文切换和调度延时,引起性能问题。

(2)一个线程持有锁会导致其它所有需要此锁的线程挂起。

(3)如果一个优先级高的线程等待一个优先级低的线程释放锁会导致优先级倒置,引起性能风险。

volatile是不错的机制,但是volatile不能保证原子性。因此对于同步最终还是要回到锁机制上来。

独占锁是一种悲观锁,synchronized就是一种独占锁,会导致其它所有需要锁的线程挂起,等待持有锁的线程释放锁。而另一个更加有效的锁就是乐观锁。所谓乐观锁就是,每次不加锁而是假设没有冲突而去完成某项操作,如果因为冲突失败就重试,直到成功为止。乐观锁用到的机制就是CAS,Compare and Swap。

什么是CAS

CAS,compare and swap的缩写,中文翻译成比较并交换。

我们都知道,在java语言之前,并发就已经广泛存在并在服务器领域得到了大量的应用。所以硬件厂商老早就在芯片中加入了大量直至并发操作的原语,从而在硬件层面提升效率。在intel的CPU中,使用cmpxchg指令。

在Java发展初期,java语言是不能够利用硬件提供的这些便利来提升系统的性能的。而随着java不断的发展,Java本地方法(JNI)的出现,使得java程序越过JVM直接调用本地方法提供了一种便捷的方式,因而java在并发的手段上也多了起来。而在Doug Lea提供的cucurenct包中,CAS理论是它实现整个java包的基石。

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

通常将 CAS 用于同步的方式是从地址 V 读取值 A,执行多步计算来获得新 值 B,然后使用 CAS 将 V 的值从 A 改为 B。如果 V 处的值尚未同时更改,则 CAS 操作成功。

类似于 CAS 的指令允许算法执行读-修改-写操作,而无需害怕其他线程同时 修改变量,因为如果其他线程修改变量,那么 CAS 会检测它(并失败),算法 可以对该操作重新计算。

CAS的目的

利用CPU的CAS指令,同时借助JNI来完成Java的非阻塞算法。其它原子操作都是利用类似的特性完成的。而整个J.U.C都是建立在CAS之上的,因此对于synchronized阻塞算法,J.U.C在性能上有了很大的提升。

CAS的问题

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

ABA问题

因为CAS需要在操作值的时候检查下值有没有发生变化,如果没有发生变化则更新,但是如果一个值原来是A,变成了B,又变成了A,那么使用CAS进行检查时会发现它的值没有发生变化,但是实际上却变化了。ABA问题的解决思路就是使用版本号。在变量前面追加上版本号,每次变量更新的时候把版本号加一,那么A-B-A 就会变成1A-2B-3A。

从Java1.5开始JDK的atomic包里提供了一个类AtomicStampedReference来解决ABA问题。这个类的compareAndSet方法作用是首先检查当前引用是否等于预期引用,并且当前标志是否等于预期标志,如果全部相等,则以原子方式将该引用和该标志的值设置为给定的更新值。

AtomicStampedReference 实例使用Demo示例代码:
https://www.cnblogs.com/java20130722/p/3206742.html

循环时间长开销大

自旋CAS如果长时间不成功,会给CPU带来非常大的执行开销。如果JVM能支持处理器提供的pause指令那么效率会有一定的提升,pause指令有两个作用,第一它可以延迟流水线执行指令(de-pipeline),使CPU不会消耗过多的执行资源,延迟的时间取决于具体实现的版本,在一些处理器上延迟时间是零。第二它可以避免在退出循环的时候因内存顺序冲突(memory order violation)而引起CPU流水线被清空(CPU pipeline flush),从而提高CPU的执行效率。

只能保证一个共享变量的原子操作

当对一个共享变量执行操作时,我们可以使用循环CAS的方式来保证原子操作,但是对多个共享变量操作时,循环CAS就无法保证操作的原子性,这个时候就可以用锁,或者有一个取巧的办法,就是把多个共享变量合并成一个共享变量来操作。比如有两个共享变量i=2,j=a,合并一下ij=2a,然后用CAS来操作ij。从Java1.5开始JDK提供了AtomicReference类来保证引用对象之间的原子性,你可以把多个变量放在一个对象里来进行CAS操作。

转载自CAS存在的问题以及解决方案

转载自看图学HTTPS

前言

之前说到HTTPS,在我的概念中就是更安全,需要服务器配置证书,但是到底什么是HTTPS,为什么会更安全,整套流程又是如何实现的,在脑子里没有具体的概念。所以,我花了几天的时间,通过参考一些文章,学习了HTTPS整套机制的实现,想要通过一篇文章把我学习到的东西总结出来,让更多之前不清楚HTTPS到底是什么的同学有一个入门的理解。

我看过的很多文章都是通过大量的文字和协议图来解释,但往往会让人感觉有点枯燥,这篇文章我会通过一幅幅流程图,形象的说明从HTTP到HTTPS的演变过程,让大家可以更容易理解一些。当然,这个只是入门级,如果想要学习更深入的HTTPS的知识,还是要深入到一个个协议里面,看一些大部头,才可以达到完全理解的效果。

本文也会同步到我的个人网站

正文

HTTP是什么样的?

HTTP是属于应用层的协议,它是基于TCP/IP的,所以它只是规定一些要传输的内容,以及头部信息,然后通过TCP协议进行传输,依靠IP协议进行寻址,通过一幅最简单的图来描述:

http-1

客户端发出请求,服务端进行响应,就是这么简单。在整个过程中,没有任何加密的东西,所以它是不安全的,中间人可以进行拦截,获取传输和响应的数据,造成数据泄露。

加个密呢?

因为上图中数据是明文传输的,我们能想到最简单的提高安全性的方法就是在传输前对数据进行加密,如下图:

http-2

这种加密方式叫做:对称加密。 加密和解密用同一个秘钥的加密方式叫做对称加密。

好了,我们对数据进行加密了,问题解决了吗?

多个客户端怎么办?

这是一个客户端,但是在WWW上,是成千上万的客户端,情况会怎样呢?

http-3

为所有的客户端都应用同一个秘钥A,这种方式很显然是不合理的,破解了一个用户,所有的用户信息都会被盗取。

想一想,是不是还有别的办法呢?

相信大家都可以想到,如果对每一个客户端都用不同的秘钥进行传输是不是就解决这个问题了:

http-4

对称加密秘钥如何传输?

我们对每个客户端应用不同的对称加密秘钥,那么这个秘钥客户端或者服务端是如何知道的呢,只能是在一端生成一个秘钥,然后通过HTTP传输给另一端:

http-5

那么这个传输秘钥的过程,又如何保证加密?如果被中间人拦截,秘钥也会被获取。也许你会说,对秘钥再进行加密,那又如何保证对秘钥加密的过程,是加密的呢?

好像我们走入了 while(1),出不来了。

非对称加密

在对称加密的路上走不通了,我们换个思路,还有一种加密方式叫非对称加密,比如RSA。 非对称加密会有一对秘钥:公钥私钥。 公钥加密的内容,只有私钥可以解开,私钥加密的内容,所有的公钥都可以解开(当然是指和秘钥是一对的公钥)。

http-6

私钥只保存在服务器端,公钥可以发送给所有的客户端。

在传输公钥的过程中,肯定也会有被中间人获取的风险,但在目前的情况下,至少可以保证客户端通过公钥加密的内容,中间人是无法破解的,因为私钥只保存在服务器端,只有私钥可以破解公钥加密的内容。

现在我们还存在一个问题,如果公钥被中间人拿到篡改呢:

MITM:Man-in-the-MiddleAttack

http-7

客户端拿到的公钥是假的,如何解决这个问题?

第三方认证

公钥被掉包,是因为客户端无法分辨传回公钥的到底是中间人,还是服务器,这也是密码学中的身份验证问题。

在HTTPS中,使用 证书 + 数字签名 来解决这个问题。

http-9

这里假设加密方式是MD5,将网站的信息加密后通过第三方机构的私钥再次进行加密,生成数字签名。

数字证书 = 网站信息 + 数字签名

假如中间人拦截后把服务器的公钥替换为自己的公钥,因为数字签名的存在,会导致客户端验证签名不匹配,这样就防止了中间人替换公钥的问题。

http-10

浏览器安装后会内置一些权威第三方认证机构的公钥,比如VeriSign、Symantec以及GlobalSign等等,验证签名的时候直接就从本地拿到相应第三方机构的公钥,对私钥加密后的数字签名进行解密得到真正的签名,然后客户端利用签名生成规则进行签名生成,看两个签名是否匹配,如果匹配认证通过,不匹配则获取证书失败。

为什么要有签名?

大家可以想一下,为什么要有数字签名这个东西呢?

第三方认证机构是一个开放的平台,我们可以去申请,中间人也可以去申请呀:

http-11

如果没有签名,只对网站信息进行第三方机构私钥加密的话,会存在下面的问题:

http-12

因为没有认证,所以中间人也向第三方认证机构进行申请,然后拦截后把所有的信息都替换成自己的,客户端仍然可以解密,并且无法判断这是服务器的还是中间人的,最后造成数据泄露。

对称加密

在安全的拿到服务器的公钥之后,客户端会随机生成一个对称秘钥,使用服务器公钥加密,传输给服务端,此后,相关的 Application Data 就通过这个随机生成的对称秘钥进行加密/解密,服务器也通过该对称秘钥进行解密/加密:

http-13

整体流程图

HTTPS = HTTP + TLS/SSL

http-15

HTTPS中具体的内容还有很多,可以通过下图做一个参考:

http-14

总结

HTTPS就是使用SSL/TLS协议进行加密传输,让客户端拿到服务器的公钥,然后客户端随机生成一个对称加密的秘钥,使用公钥加密,传输给服务端,后续的所有信息都通过该对称秘钥进行加密解密,完成整个HTTPS的流程。

转载自DDOS攻击的防范教程

一、DDOS 是什么?

首先,我来解释一下,DDOS 是什么。

举例来说,我开了一家餐厅,正常情况下,最多可以容纳30个人同时进餐。你直接走进餐厅,找一张桌子坐下点餐,马上就可以吃到东西。

img

很不幸,我得罪了一个流氓。他派出300个人同时涌进餐厅。这些人看上去跟正常的顾客一样,每个都说”赶快上餐”。但是,餐厅的容量只有30个人,根本不可能同时满足这么多的点餐需求,加上他们把门口都堵死了,里三层外三层,正常用餐的客人根本进不来,实际上就把餐厅瘫痪了。

img

这就是 DDOS 攻击,它在短时间内发起大量请求,耗尽服务器的资源,无法响应正常的访问,造成网站实质下线。

DDOS 里面的 DOS 是 denial of service(停止服务)的缩写,表示这种攻击的目的,就是使得服务中断。最前面的那个 D 是 distributed (分布式),表示攻击不是来自一个地方,而是来自四面八方,因此更难防。你关了前门,他从后门进来;你关了后门,他从窗口跳起来。

二、DDOS 的种类

DDOS 不是一种攻击,而是一大类攻击的总称。它有几十种类型,新的攻击方法还在不断发明出来。网站运行的各个环节,都可以是攻击目标。只要把一个环节攻破,使得整个流程跑不起来,就达到了瘫痪服务的目的。

其中,比较常见的一种攻击是 cc 攻击。它就是简单粗暴地送来大量正常的请求,超出服务器的最大承受量,导致宕机。我遭遇的就是 cc 攻击,最多的时候全世界大概20多个 IP 地址轮流发出请求,每个地址的请求量在每秒200次~300次。我看访问日志的时候,就觉得那些请求像洪水一样涌来,一眨眼就是一大堆,几分钟的时间,日志文件的体积就大了100MB。说实话,这只能算小攻击,但是我的个人网站没有任何防护,服务器还是跟其他人共享的,这种流量一来立刻就下线了。

本文以下的内容都是针对 cc 攻击。

三、备份网站

防范 DDOS 的第一步,就是你要有一个备份网站,或者最低限度有一个临时主页。生产服务器万一下线了,可以立刻切换到备份网站,不至于毫无办法。

备份网站不一定是全功能的,如果能做到全静态浏览,就能满足需求。最低限度应该可以显示公告,告诉用户,网站出了问题,正在全力抢修。我的个人网站下线的时候,我就做了一个临时主页,很简单的几行 HTML 代码

这种临时主页建议放到 Github Pages 或者 Netlify,它们的带宽大,可以应对攻击,而且都支持绑定域名,还能从源码自动构建。

四、HTTP 请求的拦截

如果恶意请求有特征,对付起来很简单:直接拦截它就行了。

HTTP 请求的特征一般有两种:IP 地址和 User Agent 字段。比如,恶意请求都是从某个 IP 段发出的,那么把这个 IP 段封掉就行了。或者,它们的 User Agent 字段有特征(包含某个特定的词语),那就把带有这个词语的请求拦截。

拦截可以在三个层次做。

(1)专用硬件

Web 服务器的前面可以架设硬件防火墙,专门过滤请求。这种效果最好,但是价格也最贵。

(2)本机防火墙

操作系统都带有软件防火墙,Linux 服务器一般使用 iptables。比如,拦截 IP 地址1.2.3.4的请求,可以执行下面的命令

1
$ iptables -A INPUT -s 1.2.3.4 -j DROP

iptables 比较复杂,我也不太会用。它对服务器性能有一定影响,也防不住大型攻击。

(3)Web 服务器

Web 服务器也可以过滤请求。拦截 IP 地址1.2.3.4,nginx 的写法如下。

1
2
3
location / {
deny 1.2.3.4;
}

Apache 的写法是在.htaccess文件里面,加上下面一段。

1
2
3
4
<RequireAll>
Require all granted
Require not ip 1.2.3.4
</RequireAll>

如果想要更精确的控制(比如自动识别并拦截那些频繁请求的 IP 地址),就要用到 WAF。这里就不详细介绍了,nginx 这方面的设置可以参考这里这里

Web 服务器的拦截非常消耗性能,尤其是 Apache。稍微大一点的攻击,这种方法就没用了。

五、带宽扩容

上一节的 HTTP 拦截有一个前提,就是请求必须有特征。但是,真正的 DDOS 攻击是没有特征的,它的请求看上去跟正常请求一样,而且来自不同的 IP 地址,所以没法拦截。这就是为什么 DDOS 特别难防的原因。

当然,这样的 DDOS 攻击的成本不低,普通的网站不会有这种待遇。不过,真要遇到了该怎么办呢,有没有根本性的防范方法呢?

答案很简单,就是设法把这些请求都消化掉。30个人的餐厅来了300人,那就想办法把餐厅扩大(比如临时再租一个门面,并请一些厨师),让300个人都能坐下,那么就不影响正常的用户了。对于网站来说,就是在短时间内急剧扩容,提供几倍或几十倍的带宽,顶住大流量的请求。这就是为什么云服务商可以提供防护产品,因为他们有大量冗余带宽,可以用来消化 DDOS 攻击。

一个朋友传授了一个方法,给我留下深刻印象。某云服务商承诺,每个主机保 5G 流量以下的攻击,他们就一口气买了5个。网站架设在其中一个主机上面,但是不暴露给用户,其他主机都是镜像,用来面对用户,DNS 会把访问量均匀分配到这四台镜像服务器。一旦出现攻击,这种架构就可以防住 20G 的流量,如果有更大的攻击,那就买更多的临时主机,不断扩容镜像。

六、CDN

CDN 指的是网站的静态内容分发到多个服务器,用户就近访问,提高速度。因此,CDN 也是带宽扩容的一种方法,可以用来防御 DDOS 攻击。

网站内容存放在源服务器,CDN 上面是内容的缓存。用户只允许访问 CDN,如果内容不在 CDN 上,CDN 再向源服务器发出请求。这样的话,只要 CDN 够大,就可以抵御很大的攻击。不过,这种方法有一个前提,网站的大部分内容必须可以静态缓存。对于动态内容为主的网站(比如论坛),就要想别的办法,尽量减少用户对动态数据的请求。

上一节提到的镜像服务器,本质就是自己搭建一个微型 CDN。各大云服务商提供的高防 IP,背后也是这样做的:网站域名指向高防 IP,它提供一个缓冲层,清洗流量,并对源服务器的内容进行缓存。

这里有一个关键点,一旦上了 CDN,千万不要泄露源服务器的 IP 地址,否则攻击者可以绕过 CDN 直接攻击源服务器,前面的努力都白费。搜一下”[绕过 CDN 获取真实 IP 地址](https://www.baidu.com/s?wd=cdn 真实ip)”,你就会知道国内的黑产行业有多猖獗。

cloudflare 是一个免费 CDN 服务,并提供防火墙,高度推荐。

转载整理自

SQL Server 和 Oracle 以及 MySQL 有哪些区别?

Mysql与Oracle的区别

一、并发性

并发性是oltp数据库最重要的特性,但并发涉及到资源的获取、共享与锁定。

mysql:
mysql以表级锁为主,对资源锁定的粒度很大,如果一个session对一个表加锁时间过长,会让其他session无法更新此表中的数据。
虽然InnoDB引擎的表可以用行级锁,但这个行级锁的机制依赖于表的索引,如果表没有索引,或者sql语句没有使用索引,那么仍然使用表级锁。

oracle:
oracle使用行级锁,对资源锁定的粒度要小很多,只是锁定sql需要的资源,并且加锁是在数据库中的数据行上,不依赖与索引。所以oracle对并发性的支持要好很多。

二、一致性

oracle:
oracle支持serializable的隔离级别,可以实现最高级别的读一致性。每个session提交后其他session才能看到提交的更改。oracle通过在undo表空间中构造多版本数据块来实现读一致性,
每个session查询时,如果对应的数据块发生变化,oracle会在undo表空间中为这个session构造它查询时的旧的数据块。

mysql:
mysql没有类似oracle的构造多版本数据块的机制,只支持read commited的隔离级别。一个session读取数据时,其他session不能更改数据,但可以在表最后插入数据。
session更新数据时,要加上排它锁,其他session无法访问数据。

三、事务

oracle很早就完全支持事务。

mysql在innodb存储引擎的行级锁的情况下才支持事务。

四、数据持久性

oracle
保证提交的数据均可恢复,因为oracle把提交的sql操作线写入了在线联机日志文件中,保持到了磁盘上,
如果出现数据库或主机异常重启,重启后oracle可以考联机在线日志恢复客户提交的数据。
mysql:
默认提交sql语句,但如果更新过程中出现db或主机重启的问题,也许会丢失数据。

五、提交方式

oracle默认不自动提交,需要用户手动提交。
mysql默认是自动提交。

六、逻辑备份

oracle逻辑备份时不锁定数据,且备份的数据是一致的。

mysql逻辑备份时要锁定数据,才能保证备份的数据是一致的,影响业务正常的dml使用。

七、热备份

oracle有成熟的热备工具rman,热备时,不影响用户使用数据库。即使备份的数据库不一致,也可以在恢复时通过归档日志和联机重做日志进行一致的回复。
mysql:
myisam的引擎,用mysql自带的mysqlhostcopy热备时,需要给表加读锁,影响dml操作。
innodb的引擎,它会备份innodb的表和索引,但是不会备份.frm文件。用ibbackup备份时,会有一个日志文件记录备份期间的数据变化,因此可以不用锁表,不影响其他用户使用数据库。但此工具是收费的。
innobackup是结合ibbackup使用的一个脚本,他会协助对.frm文件的备份。

八、sql语句的扩展和灵活性

mysql对sql语句有很多非常实用而方便的扩展,比如limit功能,insert可以一次插入多行数据,select某些管理数据可以不加from。
oracle在这方面感觉更加稳重传统一些。

九、复制

oracle:既有推或拉式的传统数据复制,也有dataguard的双机或多机容灾机制,主库出现问题是,可以自动切换备库到主库,但配置管理较复杂。
mysql:复制服务器配置简单,但主库出问题时,丛库有可能丢失一定的数据。且需要手工切换丛库到主库。

十、性能诊断

oracle有各种成熟的性能诊断调优工具,能实现很多自动分析、诊断功能。比如awr、addm、sqltrace、tkproof等
mysql的诊断调优方法较少,主要有慢查询日志。

十一、权限与安全

mysql的用户与主机有关,感觉没有什么意义,另外更容易被仿冒主机及ip有可乘之机。
oracle的权限与安全概念比较传统,中规中矩。

十二、分区表和分区索引

oracle的分区表和分区索引功能很成熟,可以提高用户访问db的体验。
mysql的分区表还不太成熟稳定。

十三、管理工具

oracle有多种成熟的命令行、图形界面、web管理工具,还有很多第三方的管理工具,管理极其方便高效。
mysql管理工具较少,在linux下的管理工具的安装有时要安装额外的包(phpmyadmin, etc),有一定复杂性。

市场份额

preview

典型应用场景

关于“大型数据库”,并没有严格的界定,有说以数据量为准,有说以恢复时间为准。如果综合数据库应用场景来说,大型数据库应用有以下特点:海量数据、高吞吐量;复杂逻辑、高计算量,以及高可用性。从这点上来说,Oracle,DB2就是比较典型的大型数据库,Sybase SQL Server也算是吧。下面分别说明之前三种数据库的应用场景。

  1. Oracle

    Oracle的应用,主要在传统行业的数据化业务中,比如:银行、金融这样的对可用性、健壮性、安全性、实时性要求极高的业务;零售、物流这样对海量数据存储分析要求很高的业务。此外,高新制造业如芯片厂也基本都离不开Oracle;电商也有很多使用者,如京东(正在投奔Oracle)、阿里巴巴(计划去Oracle化)。而且由于Oracle对复杂计算、统计分析的强大支持,在互联网数据分析、数据挖掘方面的应用也越来越多。一个典型场景是这样的:
    某电信公司(非国内)下属某分公司的数据中心,有4台Oracle Sun的大型服务器用来安装Solaris操作系统和Oracle并提供计算服务,3台Sun Storage磁盘阵列来提供Oracle数据存储,12台IBM小型机,一台Oracle Exadata服务器,一台500T的磁带机用来存储历史数据,San连接内网,使用Tuxedo中间件来保证扩展性和无损迁移。建立支持高并发的Oracle数据库,通过OLTP系统用来对海量数据实时处理、操作,建立高运算量的Oracle数据仓库,用OLAP系统用来分析营收数据及提供自动报表。总预算约750万美金。

  2. MySQL

    MySQL基本是生于互联网,长于互联网。其应用实例也大都集中于互联网方向,MySQL的高并发存取能力并不比大型数据库差,同时价格便宜,安装使用简便快捷,深受广大互联网公司的喜爱。并且由于MySQL的开源特性,针对一些对数据库有特别要求的应用,可以通过修改代码来实现定向优化,例如SNS、LBS等互联网业务。一个典型的应用场景是:
    某互联网公司,成立之初,仅有PC数台,通过LAMP架构迅速搭起网站框架。随着业务扩张、市场扩大,迅速发展成为6台Dell小型机的中型网站。现在花了三年,终于成为垂直领域的最大网站,计划中的数据中心,拥有Dell机架式服务器40台,总预算20万美金。

  3. MS SQL Server

    windows生态系统的产品,好处坏处都很分明。好处就是,高度集成化,微软也提供了整套的软件方案,基本上一套win系统装下来就齐活了。因此,不那么缺钱,但很缺IT人才的中小企业,会偏爱 MS SQL Server 。例如,自建ERP系统、商业智能、垂直领域零售商、餐饮、事业单位等等。
    1996年,Bill Gates亲自出手,从Borland挖来了大牛Anders,搞定了C#语言。微软02年搞定了http://ASP.NET。成熟的.NET、Silverlight技术,为 MS SQL Server赢得了部分互联网市场,其中就有曾经的全球最大社交网站MySpace,其发展历程很有代表性,可作为一个比较特别的例子【3】。其巅峰时有超过1.5亿的注册用户及每月400亿的访问量。应该算是MS SQL Server支撑的最大的数据应用了。

架构

其实要说执行的区别,主要还是架构的区别。正是架构导致了相同SQL在执行过程中的解释、优化、效率的差异。这里只做粗略说明,就不细说了:

  1. Oracle: 数据文件包括:控制文件、数据文件、重做日志文件、参数文件、归档文件、密码文件。这是根据文件功能行进行划分,并且所有文件都是二进制编码后的文件,对数据库算法效率有极大的提高。由于Oracle文件管理的统一性,就可以对SQL执行过程中的解析和优化,指定统一的标准:
    RBO(基于规则的优化器)、CBO(基于成本的优化器)
    通过优化器的选择,以及无敌的HINT规则,给与了SQL优化极大的自由,对CPU、内存、IO资源进行方方面面的优化。
  2. MySQL:最大的一个特色,就是自由选择存储引擎。每个表都是一个文件,都可以选择合适的存储引擎。常见的引擎有 InnoDB、 MyISAM、 NDBCluster等。但由于这种开放插件式的存储引擎,比如要求数据库与引擎之间的松耦合关系。从而导致文件的一致性大大降低。在SQL执行优化方面,也就有着一些不可避免的瓶颈。在多表关联、子查询优化、统计函数等方面是软肋,而且只支持极简单的HINT。
  3. SQL Server :数据架构基本是纵向划分,分为:Protocol Layer(协议层), Relational Engine(关系引擎), Storage Engine(存储引擎), SQLOS。SQL执行过程就是逐层解析的过程,其中Relational Engine中的优化器,是基于成本的(CBO),其工作过程跟Oracle是非常相似的。在成本之上也是支持很丰富的HINT,包括:连接提示、查询提示、表提示。

转载自Java并发之显式锁和隐式锁的区别

img

在Java并发编程中,锁有两种实现:使用隐式锁和使用显示锁分别是什么?两者的区别是什么?所谓的显式锁和隐式锁的区别也就是说说Synchronized(下文简称:sync)和lock(下文就用ReentrantLock来代之lock)的区别。

本文主要内容:将通过七个方面详细介绍sync和lock的区别。

Java中隐式锁:synchronized;显式锁:lock

sync和lock的区别

一:出身不同

从sync和lock的出身(原始的构成)来看看两者的不同。

  • Sync:Java中的关键字,是由JVM来维护的。是JVM层面的锁。

  • Lock:是JDK5以后才出现的具体的类。使用lock是调用对应的API。是API层面的锁

sync是底层是通过monitorenter进行加锁(底层是通过monitor对象来完成的,其中的wait/notify等方法也是依赖于monitor对象的。只有在同步块或者是同步方法中才可以调用wait/notify等方法的。因为只有在同步块或者是同步方法中,JVM才会调用monitory对象的);通过monitorexit来退出锁的。

而lock是通过调用对应的API方法来获取锁和释放锁的。

我们通过Javap命令来查看调用sync和lock的汇编指令:

img

从编译后的汇编指令,我们也能够清晰的看出sync关键字和lock的区别。

二:使用方式

  • Sync是隐式锁。

  • Lock是显示锁

所谓的显示和隐式就是在使用的时候,使用者要不要手动写代码去获取锁和释放锁的操作。

我们大家都知道,在使用sync关键字的时候,我们使用者根本不用写其他的代码,然后程序就能够获取锁和释放锁了。那是因为当sync代码块执行完成之后,系统会自动的让程序释放占用的锁。Sync是由系统维护的,如果非逻辑问题的话话,是不会出现死锁的。

在使用lock的时候,我们使用者需要手动的获取和释放锁。如果没有释放锁,就有可能导致出现死锁的现象。手动获取锁方法:lock.lock()。释放锁:unlock方法。需要配合tyr/finaly语句块来完成。

两者用法对比如下:

img

三:等待是否可中断

Sync是不可中断的。除非抛出异常或者正常运行完成

Lock可以中断的。中断方式:

1:调用设置超时方法tryLock(long timeout ,timeUnit unit)

2:调用lockInterruptibly()放到代码块中,然后调用interrupt()方法可以中断

四:加锁的时候是否可以公平

  • Sync;非公平锁

  • lock:两者都可以的。默认是非公平锁。在其构造方法的时候可以传入Boolean值。true:公平锁 false:非公平锁

Lock的公平锁和非公平锁:

img

五:锁能否绑定多个条件condition

Sync:没有。要么随机唤醒一个线程;要么是唤醒所有等待的线程。

Lock:用来实现分组唤醒需要唤醒的线程,可以精确的唤醒,而不是像sync那样,不能精确唤醒线程。

六:性能

img

七: 使用锁的方式

img

转载自显示锁(Lock)及Condition的学习与使用

synchronized是不错,但它并不完美。它有一些功能性的限制,比如

  • 它无法中断一个正在等候获得锁的线程,也无法通过投票得到锁。多线程竞争一个锁时,其余未得到锁的线程只能不停的尝试获得锁,而不能中断。
    高并发的情况下会导致性能下降。
  • synchronized上是非公平的,新来的线程有可能立即获得监视器,而在等待区中等候已久的线程可能再次等待。

而Lock的一些实现类则很好的解决了这些问题。

可重入锁ReentrantLock

java.util.concurrent.lock 中的Lock 框架是锁定的一个抽象,它允许把锁定的实现作为 Java 类,而不是作为语言的特性来实现。这就为Lock 的多种实现留下了空间,各种实现可能有不同的调度算法、性能特性或者锁定语义。

ReentrantLock 类实现了Lock ,它拥有与synchronized 相同的并发性和内存语义,但是添加了类似锁投票、定时锁等候和可中断锁等候的一些特性。此外,它还提供了在激烈争用情况下更佳的性能。(换句话说,当许多线程都想访问共享资源时,JVM 可以花更少的时候来调度线程,把更多时间用在执行线程上。)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class LockStudy {     

private Lock lock = new ReentrantLock();// 锁对象

public void output(String name) {

lock.lock(); // 得到锁
try {
//doSomething
} finally {
lock.unlock();// 释放锁
}
}
}

需要注意的是,用synchronized修饰的方法或者语句块在代码执行完之后锁自动释放,而是用Lock需要我们手动释放锁,所以为了保证锁最终被释放(发生异常情况),要把互斥区放在try内,释放锁放在finally内。

Condition

ReentrantLock里有个函数newCondition(),该函数得到一个锁上的”条件”,用于实现线程间的通信,条件变量很大一个程度上是为了解决Object.wait/notify/notifyAll难以使用的问题。

Condition拥有await(),signal(),signalAll(),await对应于Object.waitsignal对应于Object.notifysignalAll对应于Object.notifyAll。特别说明的是Condition的接口改变名称就是为了避免与Object中的wait/notify/notifyAll的语义和使用上混淆,因为Condition同样有wait/notify/notifyAll方法()因为任何类都拥有这些方法。

每一个Lock可以有任意数据的Condition对象,Condition是与Lock绑定的,所以就有Lock的公平性特性:如果是公平锁,线程为按照FIFO的顺序从Condition.await中释放,如果是非公平锁,那么后续的锁竞争就不保证FIFO顺序了。下面是一个用Lock和Condition实现的一个生产者消费者的模式:

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
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
public class ProductQueue<T> {

private final T[] items;
private final Lock lock = new ReentrantLock();
private Condition notFull = lock.newCondition();
private Condition notEmpty = lock.newCondition();
private int head, tail, count;

public ProductQueue(int maxSize) {
items = T[] new Object[maxSize];
}

public ProductQueue() {
this(10);
}

public void put(T t) throws InterruptedException {
lock.lock();
try{
while(count == getCapacity()) {
notFull.await();
}
items[tail] = t;
if(++tail==getCapacity()){
tail = 0;
}
++count;
notEmpty.signalAll();
} finally {
lock.unlock();
}
}

public T take() throws InterruptedException {
lock.lock();
try {
while(count == 0) {
notEmpty.await();
}
T ret = items[head];
items[head] = null;//GC
if (++head == getCapacity()) {
head = 0;
}
--count;
notFull.signalAll();
return ret;
} finally {
lock.unlock();
}
}

public int getCapacity(){
return items.length;
}

public int size() {
lock.lock();
try{
return count;
} finally{
lock.unlock();
}
}

}

这就是多个Condition的强大之处,假设缓存队列中已经存满,那么阻塞的肯定是写线程,唤醒的肯定是读线程,相反,阻塞的肯定是读线程,唤醒的肯定是写线程,那么假设只有一个Condition会有什么效果呢,缓存队列中已经存满,这个Lock不知道唤醒的是读线程还是写线程了,如果唤醒的是读线程,皆大欢喜,如果唤醒的是写线程,那么线程刚被唤醒,又被阻塞了,这时又去唤醒,这样就浪费了很多时间。

ReentrantLock与synchronized的对比

ReentrantLock同样是一个可重入锁,但与目前的 synchronized 实现相比,争用下的 ReentrantLock 实现更具可伸缩性。除了synchronized的功能,多了三个高级功能.

等待可中断,公平锁,绑定多个Condition。

1.等待可中断

在持有锁的线程长时间不释放锁的时候,等待的线程可以选择放弃等待.

1
tryLock(long timeout, TimeUnit unit);

2.公平锁

按照申请锁的顺序来一次获得锁称为公平锁.synchronized的是非公平锁,ReentrantLock可以通过构造函数实现公平锁.

1
new RenentrantLock(boolean fair);

3.绑定多个Condition

通过多次newCondition可以获得多个Condition对象,可以简单的实现比较复杂的线程同步的功能。通过await(),signal()等方法实现。

Lock的其他实现类

如ReadWriteLock。ReentrantReadWriteLock实现了ReadWriteLock接口,构造器提供了公平锁和非公平锁两种创建方式。读-写锁定允许对共享数据进行更高级别的并发访问。虽然一次只有一个线程(writer 线程)可以修改共享数据,但在许多情况下,任何数量的线程可以同时读取共享数据(reader 线程)。读写锁适用于读多写少的情况,可以实现更好的并发性。

转载整理自

哲学家就餐问题

哲学家进餐-多线程同步经典问题

死锁的必要条件

死锁的四个必要条件:
(1) 互斥条件:一个资源每次只能被一个进程使用。
(2) 请求并保持条件:一个进程因请求资源而阻塞时,对已获得的资源保持不放。
(3) 不可剥夺条件: 进程已获得的资源,在末使用完之前,不能强行剥夺。
(4) 循环等待条件: 若干进程之间形成一种头尾相接的循环等待资源关系

先写一个会造成死锁的哲学家问题。当所有哲学家同时决定进餐,拿起左边筷子时候,就发生了死锁。

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
42
43
44
45
46
47
48
49
50
51
52
public class Test {
public static void main(String[] args) {
ExecutorService exec = Executors.newCachedThreadPool();
int sum = 5;
Chopstick[] chopsticks = new Chopstick[sum];
for (int i = 0; i < sum; i++) {
chopsticks[i] = new Chopstick();
}
for (int i = 0; i < sum; i++) {
exec.execute(new Philosopher(chopsticks[i], chopsticks[(i + 1) % sum], i));
}
}
}

// 筷子
class Chopstick {
public Chopstick() {
}
}

class Philosopher implements Runnable {

private Chopstick left;
private Chopstick right;
int name;

public Philosopher(Chopstick left, Chopstick right, int name) {
this.left = left;
this.right = right;
this.name = name;
}

@Override
public void run() {
try {
while (true) {
Thread.sleep(1000);//思考一段时间
synchronized (left) {
Thread.sleep(500);//这样更容易发生死锁
System.out.println(name + " get left");
synchronized (right) {
System.out.println(name + " eat");
Thread.sleep(1000);//进餐一段时间
}
}
}
} catch (InterruptedException e) {
e.printStackTrace();
}
}

}

破坏死锁的循环等待条件

解决方案一:破坏死锁的循环等待条件
不再按左手右手顺序拿起筷子。选择一个固定的全局顺序获取,此处给筷子添加id,根据id先获取小的再获取大的,(不用关心编号的具体规则,只要保证编号全局唯一并且可排序),不会出现死锁情况。

该方法适合获取锁的代码写的比较集中的情况,有利于维护这个全局顺序;若规模较大的程序,使用锁的地方比较零散,各处都遵守这个顺序就变得不太实际。

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
42
43
44
45
46
47
48
49
50
51
52
53
public class Test {
public static void main(String[] args) {
ExecutorService exec = Executors.newCachedThreadPool();
int sum = 5;
Chopstick[] chopsticks = new Chopstick[sum];
for (int i = 0; i < sum; i++) {
chopsticks[i] = new Chopstick(i);
}
for (int i = 0; i < sum; i++) {
exec.execute(new Philosopher(chopsticks[i], chopsticks[(i + 1) % sum], i));
}
}
}

// 筷子
class Chopstick {
int id;

public Chopstick(int id) {
this.id = id;
}
}

class Philosopher implements Runnable {

private Chopstick left;
private Chopstick right;
int name;

public Philosopher(Chopstick left, Chopstick right, int name) {
this.left = left.id < right.id ? left : right;
this.right = left.id > right.id ? left : right;
this.name = name;
}

@Override
public void run() {
try {
while (true) {
Thread.sleep(1000);//思考一段时间
synchronized (left) {
System.out.println(name + " get left");
synchronized (right) {
System.out.println(name + " eat");
Thread.sleep(1000);//进餐一段时间
}
}
}
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}

破坏死锁的请求与保持条件

方法二:破坏死锁的请求与保持条件,使用lock的特性,为获取锁操作设置超时时间,当一段时间获取不到所有的资源时,就释放已获得的资源,重新开始请求资源。这样不会死锁(至少不会一直死锁)

该方法避免了无尽地死锁,但也不是很好的方案,因为该方案并不能避免死锁,它只是提供了从死锁中恢复的手段,并且受到活锁现象的影响,如果所有死锁线程同时超时,它们极有可能再次陷入死锁,虽然死锁没有永远持续下去,但对资源的争夺状态却没有得到任何改善(为每个线程设置不同的超时时间可以稍好的处理这种情况)。

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
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
public class Test {
public static void main(String[] args) {
ExecutorService exec = Executors.newCachedThreadPool();
int sum = 50;
Chopstick[] chopsticks = new Chopstick[sum];
for (int i = 0; i < sum; i++) {
chopsticks[i] = new Chopstick();
}
for (int i = 0; i < sum; i++) {
exec.execute(new Philosopher(chopsticks[i], chopsticks[(i + 1) % sum], i));
}
}
}

// 筷子
class Chopstick extends ReentrantLock {

}

class Philosopher implements Runnable {

private ReentrantLock left, right;
int name;

public Philosopher(ReentrantLock left, ReentrantLock right, int name) {
this.left = left;
this.right = right;
this.name = name;
}

@Override
public void run() {
try {
while (true) {
Thread.sleep(1000);//思考一段时间
left.lock();
System.out.println(name + " get left");
try {
if (right.tryLock(1000, TimeUnit.MILLISECONDS)) {
try {
System.out.println(name + " eat");
Thread.sleep(1000);//进餐一段时间
} finally {
right.unlock();
}
} else {
//没有获取到右手的筷子,放弃并继续思考
System.out.println(name + " has not get right chopstick,give up");
}
} finally {
left.unlock();
}
}
} catch (InterruptedException e) {
}
}
}

使用条件变量Condition

方法三:设置一个条件变量与锁关联。该方法只用一把锁,没有Chopstick类,将竞争从对筷子的争夺转换成了对状态的判断。仅当左右邻座都没有进餐时才可以进餐。提升了并发度。前面的方法出现情况是:只有一个哲学家进餐,其他人持有一根筷子在等待另外一根。这个方案中,当一个哲学家理论上可以进餐(邻座没有进餐)时,他就开始进餐。

思路是只使用一把锁,将竞争从对筷子的争夺转换成了对状态的判断,仅当哲学家的左右邻座都没有进餐时,才可以进餐。当一个哲学家饥饿时,首先锁住餐桌table,这样其他哲学家无法改变table状态,然后查看左右邻居是否正在进餐,如果没有,那么该哲学家开始进餐并解锁餐桌,否则调用await()以暂时解锁餐桌,等待条件满足后,再次尝试锁住餐桌table后开始进餐;当一个哲学家进餐结束并开始思考时,首先锁住餐桌将eating改为false,然后通知左右邻座可以进餐,最后解锁餐桌。如果他的左右邻居目前正在等待,那么他们将被唤醒,重新锁住餐桌,并判断是否开始进餐。

通过多次newCondition()可以获得多个Condition对象,可以通过await(),signal()等方法实现比较复杂的线程同步的功能。在这个解决方法中,当一个哲学家理论上可以进餐时,肯定就可以进餐,并发度显著提升。

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
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
public class Test {
public static void main(String[] args) {
ExecutorService exec = Executors.newCachedThreadPool();
int sum = 5;
Philosopher[] philosophers = new Philosopher[sum];
ReentrantLock table = new ReentrantLock();
for (int i = 0; i < sum; i++) {
philosophers[i] = new Philosopher(table, i);
}
for (int i = 0; i < sum; i++) {
philosophers[i].setLeft(philosophers[(i - 1 + sum) % sum]);
philosophers[i].setRight(philosophers[(i + 1) % sum]);
}
for (int i = 0; i < sum; i++) {
exec.execute(philosophers[i]);
}
}
}

class Philosopher extends Thread {
private boolean eating;
private Philosopher left;
private Philosopher right;
private ReentrantLock table;
private Condition condition;
int name;

public Philosopher(ReentrantLock table, int name) {
eating = false;
this.table = table;
condition = table.newCondition();
this.name = name;
}

public void setLeft(Philosopher left) {
this.left = left;
}

public void setRight(Philosopher right) {
this.right = right;
}

public void think() throws InterruptedException {
table.lock();
try {
eating = false;
System.out.println(name + " 开始思考");
left.condition.signal();
right.condition.signal();
} finally {
table.unlock();
}
Thread.sleep(1000);
}

public void eat() throws InterruptedException {
table.lock();
try {
while (left.eating || right.eating)
condition.await();

System.out.println(name + " 开始吃饭");
eating = true;
} finally {
table.unlock();
}
Thread.sleep(1000);
}

public void run() {
try {
while (true) {
think();
eat();
}
} catch (InterruptedException e) {
}
}
}

总结

通过这一经典的问题,学习多线程并发模型的三种解决方案:

  1. 多把锁时,对锁设置全局唯一的顺序,按序使用锁;(破坏循环等待条件)
  2. 设置线程获取锁的超时时间,防止无限制的死锁;(破坏请求与保持条件)
  3. 使用条件变量
1
2
3
4
5
6
7
8
9
10
ReentrantLock lock = new ReentrantLock();
Condition Condition = lock.newCondition();
lock.lock();
try {
while(!《条件为真》)
condition.await();
《使用共享资源》
} finally {
lock.unlock();
}

一个条件变量需要与一把锁关联,线程在开始等待条件之前必须获取这把锁,获取锁后,线程检查所等待的条件是否已经为真,如果为真,线程将继续执行, 执行完毕后并解锁。条件变量的方法会使哲学家进餐问题的并发度显著提升。

定义

ThreadLocal 提供线程局部变量;一个线程局部变量在多个线程中分别有独立的值(副本)

产生背景

用于多线程场景,避免一致性问题

一致性问题:

  1. 发生在多个主体对同一份数据无法达成共识。
  2. 包括:分布式一致性问题、并发问题等。
  3. 特点:场景多、问题复杂、难以察觉—需要严密的思考甚至数学论证。

一致性问题解决办法:

  1. 排队(例如:锁synchronized、互斥量、管程、屏障等)
  2. 投票(例如:Paxos,Raft等)
  3. 避免(例如:ThreadLocal等 空间换时间的方式)

实现细节

ThreadLocal模型:
image.png

使用场景

线程资源持有

在一个用户一个线程的情况下,用户数据使用ThreadLocal存储,其他程序模块可以方便地拿到分配给当前线程的用户的数据,全局获取,减少编程难度.
image.png

线程资源一致性

以JDBC为例,一个事务分为多个Part,它们在同一个线程中(如Spring响应池中分配的一个线程)请求获取一个数据库连接,将会得到同一个JDBC连接.这样的好处是一个JDBC连接维护了事务的状态,相同事务多次获取连接可以拿到同一个JDBC连接.ThreadLocal帮助需要保持线程一致的资源(如数据库事务)维护一致性,降低编程难度.
image.png

线程安全

在以前C语言中常用setLastError()getLastError(),多线程下就需要ThreadLocal保证一致性.ThreadLocal帮助只考虑了单线程的程序库,无缝向多线程场景迁移.
image.png

分布式计算

将计算任务分给不同的线程,用ThreadLocal存储本线程的计算结果,然后再汇总.

image.png

负载均衡算法

常用的6种负载均衡算法:

1、轮询法

将请求按顺序轮流地分配到后端服务器上,它均衡地对待后端的每一台服务器,而不关心服务器实际的连接数和当前的系统负载。

2、加权轮询法

不同的后端服务器可能机器的配置和当前系统的负载并不相同,因此它们的抗压能力也不相同。

给配置高、负载低的机器配置更高的权重,让其处理更多的请;而配置低、负载高的机器,给其分配较低的权重,降低其系统负载,加权轮询能很好地处理这一问题,并将请求顺序且按照权重分配到后端。

3、随机法

通过系统的随机算法,根据后端服务器的列表大小值来随机选取其中的一台服务器进行访问。由概率统计理论可以得知,随着客户端调用服务端的次数增多,其实际效果越来越接近于平均分配调用量到后端的每一台服务器,也就是轮询的结果。

4、加权随机法

与加权轮询法一样,加权随机法也根据后端机器的配置,系统的负载分配不同的权重。不同的是,它是按照权重随机请求后端服务器,而非顺序。

5、源IP地址哈希法

源IP地址哈希的思想是根据获取客户端的IP地址,通过哈希函数计算得到的一个数值,用该数值对服务器列表的大小进行取模运算,得到的结果便是客服端要访问服务器的序号。采用源地址哈希法进行负载均衡,同一IP地址的客户端,当后端服务器列表不变时,它每次都会映射到同一台后端服务器进行访问(若后端服务器列表改变,需要一致性哈希算法来优化,见下文)。

6、最小连接数法

最小连接数算法比较灵活和智能,由于后端服务器的配置不尽相同,对于请求的处理有快有慢,它是根据后端服务器当前的连接情况,动态地选取其中当前积压连接数最少的一台服务器来处理当前的请求,尽可能地提高后端服务的利用效率,将负责合理地分流到每一台服务器。

一致性哈希(Consistent Hashing)

在上面的源地址hash算法中,存在以下的2个问题

  1. 当一台服务器宕机了或者新添加一台机器之后,这个时候hashCode % servers.size()需要重新计算hash值, 如果在缓存的环境中,所有的请求都会涌向数据库服务器,给数据库服务器带来巨大的压力,可能导致整个系统不可用,形成雪崩效应.

  2. 当新增了一台性能强的机器后,利用上述的hash算法无法让新增的性能强的服务器多承担压力.

基于上面的2个问题,提出了hash算法的改进,即Consistent Hashing算法.Consistent Hashing也是一种 hash 算法,简单的说,在移除 / 添加操作,它能够尽可能小的改变已存在 key 映射关系.

Consistent Hashing算法的原理是它将hash函数的值域组织成一个环形,整个空间按照顺时针的方式进行组织,将对应的服务器节点进行hash,将他们映射到hash环上,假设有四台机器node1-4,hash之后如图所示:

img

接下来使用相同的hash函数,计算出对应的key值和hash值,按照顺时针的方式,分布在node1和node2的key,访问时被定位在node2,分布在node2和node4的key被定位在node4上,以此类推.假设现在新增一个node5,假设hash之后在node2和node4之间,如图所示:

img

那么受影响的节点只有node2和node5,他们将会从新hash,而其他的key的映射将不会变化.

当然,上面描绘了一种很理想的情况,即各个节点在环上分布的十分均匀.正常情况下,当节点数量少的时候,节点分布并不均匀,这时需要引入虚拟节点机制.

部分转载自常见的一些负载均衡算法总结