Spring MVC学习笔记

1.Spring MVC流程图

2.注解驱动控制器
请求映射到控制器可以通过URL,请求参数,请求方法,请求头四个方面信息项;

@RequestMapping支持Ant风格(?,*)和{XX}占位符的URL;
请求方法参数:@RequstParam,@CookieValue,@RequetHeader,Servlet API对象:HttpServletRequet,HttpServletResponse,I/O对象:OutputStream,以及java.util.Locale,java.security.Principal;

使用HttpMessageConverter<T>:DispatchServlet默认已经安装了RequestMappingHandlerAdapter作为HandlerAdapter的组件实现类,HttpMessageConverter即有RequestMappingHandlerAdapter使用,将请求信息转换为对象,或将对象转换为相应信息;

处理模型的数据:ModelAndView,@ModelAttribute,Map或Model,@SessionAttribute;
SpringMVC在调用方法前会创建一个隐藏的模型对象,如果处理方法的入参为Map或Model类型,则SpringMVC会将隐含模型的引用传递给这些参数,同时也可以添加新的属性数据;

3.处理方法的数据绑定
SpringMVC根据请求方法签名不同,将请求参数中的信息以一定的方式转换并绑定到请求方法的入参中,并进行数据转换,数据格式化,数据校验等;
3.1 流程解析

3.2 ConversionService
ConversionService是Spring类型转换体系的核心接口,位于org.springframework.core.convert包中,也是该包中唯一个接口。主要定义了四个方法:

使用FactoryBean产生;

Elasticsearch集群配置详解

前言

在我们es系列文章开篇介绍中,已经提到,elasticsearch是天生支持集群的,他不需要依赖其他的服务发现和注册的组件,如zookeeper这些,因为他内置了一个名字叫ZenDiscovery的模块,是elasticsearch自己实现的一套用于节点发现和选主等功能的组件,所以elasticsearch做起集群来非常简单,不需要太多额外的配置和安装额外的第三方组件。

数据分片

我们在前面的篇章中介绍过elasticsearch的一些重要的基本概念了,但是为了循序渐进的学习elasticsearch,我们并没有把所有概念都一次性列出来,而是在学习的过程中不断的补充,那现在我们需要学习elasticsearch的集群了,就需要补充一个基本概念,叫做“分片(shard)”,分片在集群中起到了非常关键的作用,那接下来,我们来看看分片是什么。

分片的概念和作用

分片是一个底层的工作单元 ,它仅保存了全部数据中的一部分,一个分片是一个 Lucene 的实例,它本身就是一个完整的搜索引擎,我们的文档被存储和索引到分片内。

每个分片又有一个副本分片,副本分片就是主分片的copy,对于分布式搜索引擎来说, 分片及副本的分配将是高可用及快速搜索响应的设计核心.主分片与副本都能处理查询请求,它们的唯一区别在于只有主分片才能处理更新请求,这就有点像我们常见的“主从”的概念了。

Elasticsearch 是利用分片将数据分发到集群内各处的。分片是数据的容器,文档保存在分片内,分片又被分配到集群内的各个节点里。当你的集群规模扩大或者缩小时, Elasticsearch 会自动的在各节点中迁移分片,使得数据仍然均匀分布在集群里。所以集群和分片关系非常密切,学习集群之前,需要了解分片的概念和作用。

ES集群节点类型

通过前面对elasticsearch的基本概念了解到,在一个elasticsearch集群(cluster)中,有多个节点(node),并且这些节点有不同的类型:我们部署一个elasticsearch集群常用的节点类型有:主节点,数据节点,候选主节点,客户端节点。

这些节点的类型是通过在elasticsearch主配置文件path/to/config/elasticsearch.yml文件指定的,配置属性如下:

node.master: true/false #该节点有机会成为master节点
node.data: true/false #该节点可以存储数据

通过以上两行配置的组合,我们可以指定出elasticsearch的节点类型。

每种节点的作用如下:

1、主节点:

主节点负责创建索引、删除索引、分配分片、追踪集群中的节点状态等工作。Elasticsearch中的主节点的工作量相对较轻,用户的请求可以发往集群中任何一个节点,由该节点负责分发和返回结果,而不需要经过主节点转发。而主节点是由候选主节点通过ZenDiscovery机制选举出来的,所以要想成为主节点,首先要先成为候选主节点。

2、候选主节点

在elasticsearch集群初始化或者主节点宕机的情况下,由候选主节点中选举其中一个作为主节点。指定候选主节点的配置为:node.master: true。

但是在elasticsearch集群中,会有偶尔出现这样的一种现象,就是当主节点负载压力过大,或者集中环境中的网络问题,导致其他节点与主节点通讯的时候,主节点没来的及响应,这样的话,某些节点就认为主节点宕机,重新选择新的主节点,这样的话整个集群的工作就有问题了,比如我们集群中有10个节点,其中7个候选主节点,1个候选主节点成为了主节点,这种情况是正常的情况。但是如果现在出现了我们上面所说的主节点响应不及时,导致其他某些节点认为主节点宕机而重选主节点,那就有问题了,这剩下的6个候选主节点可能有3个候选主节点去重选主节点,最后集群中就出现了两个主节点的情况,这种情况官方成为“脑裂现象”。

如果避免这种问题呢?主要从以下几个方面来避免:

(1)尽量不要让候选主节点同时作为数据节点,因为数据节点是需要承担存储和搜索的工作的,压力会很大。所以如果该节点同时作为候选主节点和数据节点,那么一旦选上它作为主节点了,这时主节点的工作压力将会非常大,出现脑裂现象的概率就增加了。

(2)配置主节点的响应时间,在默认情况下,主节点3秒没有响应,其他节点就认为主节点宕机了,那我们可以把该时间设置的长一点,该配置是:

discovery.zen.ping_timeout: 3

(3)配置候选主节点最小数量,从脑裂现象出现的原因中,我们看到了,如果集群中有部分候选主节点重新选择主节点,那集群中的候选主节点就会被分成两部分,导致集群的可用节点个数和实际的节点个数不一致,那这样的话,我们就可用通过配置的方式,指定如果候选主节点个数达到某个值时,才能进行主节点选举,而该配置属性如下:

discovery.zen.minimum_master_nodes

该属性默认值是1,官方的推荐值是(N/2)+1,其中N是候选主节点的数量,那这样的话,比如我们集群有7个候选主节点,那么通过官方推荐值,我们应该设置为4,这样的话,至少有4个候选主节点都认为需要重选主节点的情况下才进行选举。

3、数据节点

数据节点负责数据的存储和相关具体操作,比如CRUD、搜索、聚合。所以,数据节点对机器配置要求比较高,首先需要有足够的磁盘空间来存储数据,其次数据操作对系统CPU、Memory和IO的性能消耗都很大。通常随着集群的扩大,需要增加更多的数据节点来提高可用性。指定数据节点的配置:

node.data: true。

elasticsearch是允许一个节点既做候选主节点也做数据节点的,但是数据节点的负载较重,所以需要考虑将二者分离开,设置专用的候选主节点和数据节点,避免因数据节点负载重导致主节点不响应。

4、客户端节点

按照官方的介绍,客户端节点就是既不做候选主节点也不做数据节点的节点,只负责请求的分发、汇总等等,但是这样的工作,其实任何一个节点都可以完成,因为在elasticsearch中一个集群内的节点都可以执行任何请求,其会负责将请求转发给对应的节点进行处理。所以单独增加这样的节点更多是为了负载均衡。指定该节点的配置为:

node.master: false 
node.data: false
Elasticsearch集群配置详解

集群配置

接下来,我们做一个集群测试一下,我这里使用4个节点做测试,分别为:

es1 192.168.85.133:9300
es2 192,168.85.133:9500
es3 192.168.85.135:9300
es4 192.168.85.135:9500

1、es1 既是候选主节点也是数据节点,elasticsearch.yml配置如下:

cluster.name: elasticsearch #集群的名称,同一个集群该值必须设置成相同的
node.name: es1 #该节点的名字
node.master: true #该节点有机会成为master节点
node.data: true #该节点可以存储数据
network.bind_host: 0.0.0.0 #设置绑定的IP地址,可以是IPV4或者IPV6
network.publish_host: 192.168.85.133 #设置其他节点与该节点交互的IP地址
network.host: 192.168.85.133 #该参数用于同时设置bind_host和publish_host
transport.tcp.port: 9300 #设置节点之间交互的端口号
transport.tcp.compress: true #设置是否压缩tcp上交互传输的数据
http.port: 9200 #设置对外服务的http端口号
http.max_content_length: 100mb #设置http内容的最大大小
http.enabled: true #是否开启http服务对外提供服务
discovery.zen.minimum_master_nodes: 2 #设置这个参数来保证集群中的节点可以知道其它N个有master资格的节点。官方推荐(N/2)+1
discovery.zen.ping_timeout: 120s #设置集群中自动发现其他节点时ping连接的超时时间
discovery.zen.ping.unicast.hosts: ["192.168.85.133:9300","192.168.85.133:9500","192.168.85.135:9300"] #设置集群中的Master节点的初始列表,可以通过这些节点来自动发现其他新加入集群的节点
http.cors.enabled: true #跨域连接相关设置
http.cors.allow-origin: "*" #跨域连接相关设置

2、es2 既是候选主节点也是数据节点,elasticsearch.yml配置如下:

cluster.name: elasticsearch #集群的名称,同一个集群该值必须设置成相同的
node.name: es2 #该节点的名字
node.master: true #该节点有机会成为master节点
node.data: true #该节点可以存储数据
network.bind_host: 0.0.0.0 #设置绑定的IP地址,可以是IPV4或者IPV6
network.publish_host: 192.168.85.133 #设置其他节点与该节点交互的IP地址
network.host: 192.168.85.133 #该参数用于同时设置bind_host和publish_host
transport.tcp.port: 9500 #设置节点之间交互的端口号
transport.tcp.compress: true #设置是否压缩tcp上交互传输的数据
http.port: 9400 #设置对外服务的http端口号
http.max_content_length: 100mb #设置http内容的最大大小
http.enabled: true #是否开启http服务对外提供服务
discovery.zen.minimum_master_nodes: 2 #设置这个参数来保证集群中的节点可以知道其它N个有master资格的节点。官方推荐(N/2)+1
discovery.zen.ping_timeout: 120s #设置集群中自动发现其他节点时ping连接的超时时间
discovery.zen.ping.unicast.hosts: ["192.168.85.133:9300","192.168.85.133:9500","192.168.85.135:9300"] #设置集群中的Master节点的初始列表,可以通过这些节点来自动发现其他新加入集群的节点
http.cors.enabled: true #跨域连接相关设置
http.cors.allow-origin: "*" #跨域连接相关设置

3、es3 是候选主节点,elasticsearch.yml配置如下:

cluster.name: elasticsearch #集群的名称,同一个集群该值必须设置成相同的
node.name: es3 #该节点的名字
node.master: true #该节点有机会成为master节点
node.data: false #该节点可以存储数据
network.bind_host: 0.0.0.0 #设置绑定的IP地址,可以是IPV4或者IPV6
network.publish_host: 192.168.85.135 #设置其他节点与该节点交互的IP地址
network.host: 192.168.85.135 #该参数用于同时设置bind_host和publish_host
transport.tcp.port: 9300 #设置节点之间交互的端口号
transport.tcp.compress: true #设置是否压缩tcp上交互传输的数据
http.port: 9200 #设置对外服务的http端口号
http.max_content_length: 100mb #设置http内容的最大大小
http.enabled: true #是否开启http服务对外提供服务
discovery.zen.minimum_master_nodes: 2 #设置这个参数来保证集群中的节点可以知道其它N个有master资格的节点。官方推荐(N/2)+1
discovery.zen.ping_timeout: 120s #设置集群中自动发现其他节点时ping连接的超时时间
discovery.zen.ping.unicast.hosts: ["192.168.85.133:9300","192.168.85.133:9500","192.168.85.135:9300"] #设置集群中的Master节点的初始列表,可以通过这些节点来自动发现其他新加入集群的节点
http.cors.enabled: true #跨域连接相关设置
http.cors.allow-origin: "*" #跨域连接相关设置

4、es4 是数据节点,elasticsearch.yml配置如下:

cluster.name: elasticsearch #集群的名称,同一个集群该值必须设置成相同的
node.name: es4 #该节点的名字
node.master: false #该节点有机会成为master节点
node.data: true #该节点可以存储数据
network.bind_host: 0.0.0.0 #设置绑定的IP地址,可以是IPV4或者IPV6
network.publish_host: 192.168.85.135 #设置其他节点与该节点交互的IP地址
network.host: 192.168.85.135 #该参数用于同时设置bind_host和publish_host
transport.tcp.port: 9500 #设置节点之间交互的端口号
transport.tcp.compress: true #设置是否压缩tcp上交互传输的数据
http.port: 9400 #设置对外服务的http端口号
http.max_content_length: 100mb #设置http内容的最大大小
http.enabled: true #是否开启http服务对外提供服务
discovery.zen.minimum_master_nodes: 2 #设置这个参数来保证集群中的节点可以知道其它N个有master资格的节点。官方推荐(N/2)+1
discovery.zen.ping_timeout: 120s #设置集群中自动发现其他节点时ping连接的超时时间
discovery.zen.ping.unicast.hosts: ["192.168.85.133:9300","192.168.85.133:9500","192.168.85.135:9300"] #设置集群中的Master节点的初始列表,可以通过这些节点来自动发现其他新加入集群的节点
http.cors.enabled: true #跨域连接相关设置
http.cors.allow-origin: "*" #跨域连接相关设置

以上配置就是我们集群中4个elasticsearch节点的配置,我们没有两个既是候选主节点也是数据节点,一个候选主节点,一个数据节点,没有配置客户端节点,因为客户端节点仅起到负载均衡的作用。

验证群状态

查看集群状态的REST API

我们逐一的把集群中的节点启动起来,然后使用REST API查看集群状态,访问集群中任意一个节点即可:

GET ip:port/_cluster/health

响应的结果:

{
"cluster_name" : "elasticsearch",
"status" : "green",
"timed_out" : false,
"number_of_nodes" : 4,
"number_of_data_nodes" : 3,
"active_primary_shards" : 46,
"active_shards" : 92,
"relocating_shards" : 0,
"initializing_shards" : 0,
"unassigned_shards" : 0,
"delayed_unassigned_shards" : 0,
"number_of_pending_tasks" : 0,
"number_of_in_flight_fetch" : 0,
"task_max_waiting_in_queue_millis" : 0,
"active_shards_percent_as_number" : 100.0
}

我们来看看返回结果中的几个关键的属性:

  • 集群状态有3种(status):
  • red,表示有主分片没有分配,某些数据不可用。
  • yellow,表示主分片都已分配,数据都可用,但是有复制分片没有分配。
  • green,表示主分片和复制分片都已分配,一切正常。
  • 那么通过以上返回的结果中的status属性,我们可以知道,当前的elasticsearch集群是正常的。
  • number_of_nodes属性说明我们集群中有4个节点。
  • number_of_data_nodes说明我们集群中有3个为数据节点,所有数据分片储存在这3个数据节点中。
  • active_primary_shards属性说明主分片有46个(我们有10个索引,其中9个索引有5个主分片,1个索引有1个主分片,合计46个主分片)
  • active_shards属性说明所有分片(主分片 + 副本分片)总共是92个

使用head插件管理ES集群

我们在之前安装了es-head插件的时候提到,es-head插件可以管理我们elasticsearch集群的,那现在我们就使用之前安装好的es-head插件来看看elasticsearch集群如果做到图形化界面的管理。

打开首页我们可以看到以下界面:

Elasticsearch集群配置详解
  • 连接任意一个节点看到的结果都是一样的,这个就验证了我们上面提到的概念:用户的请求可以发往集群中任何一个节点,由该节点负责请求分发和返回结果。在该界面中,是以表格的形式展示数据的,纵向的是集群中的索引,横向的是集群中的节点。
  • 横向有4行,分别是4个我们自己命名的节点,其中标记了es1是星号,代表它为主节点。
  • 然后我们可以看到,每个单元格中都有一些绿色的小方块,小方块中标有数字,这些绿色小方块就代表分片,其中边框加粗的是主分片,没加粗的是副本分片。
  • 我们在创建索引时,如果没有指定分片的数量,那么默认是5个,再加上每个分片都有一个副本,所以加起来就有10个绿色小方块了。
  • 每个绿色小方块上都有0-4的数字,分别代表0-4号分片,和对应的0-4号分片的副本分片,我们点击绿色小方块,会弹出一个窗口,列出该分片的一些信息,其中有条信息是“primary”,如果为true,就代表主分片,发false就代表副本分片。
  • 其中es3节点是没有给它分配分片的,那是因为当时我们给该节点设置的是候选主节点,不是数据节点,所以他不承担数据存储的工作。
  • 这10个分片(包含主分片和副本分片)被elasticsearch分配到各个数据节点中,并且根据我们上面对分片概念的了解,主分片和副本分片都可以接收数据查询请求,但是只有主分片才能接收数据修改请求,副本分片的数据是从主分片同步过来的。

测试数据

最后,我们从用户的角度上来看看,向集群中各个节点发送一个查询请求,测试数据的一致性。我们先往集群中新增一条数据,以store索引作为测试,向任意一个节点发送一个新增文档的请求:

PUT /store/employee/3
{
"name":"王五",
"age":28,
"about":"我叫王五,但不是隔壁老王",
"interests":["code","music"]
}

看到以下返回结果提示添加成功:

Elasticsearch集群配置详解

但这里有一个问题,就是该文档会被存储到哪一个主分片中呢?首先这肯定不会是随机的,否则将来要获取文档的时候我们就不知道从何处寻找了。实际上,这个过程是根据下面这个公式决定的:

shard = hash(routing) % number_of_primary_shards

routing 是一个可变值,默认是文档的 _id ,也可以设置成一个自定义的值。 routing 通过 hash 函数生成一个数字,然后这个数字再除以 number_of_primary_shards (主分片的数量)后得到 余数 。这个分布在 0 到 number_of_primary_shards-1 之间的余数,就是我们所寻求的文档所在分片的位置。

这就顺便解释了为什么我们要在创建索引的时候就确定好主分片的数量,并且永远不会改变这个数量,因为如果数量变化了,那么所有之前路由的值都会无效,文档也再也找不到了。

那接下来我们看看每个节点是否可以查询到该数据:

Elasticsearch集群配置详解
Elasticsearch集群配置详解
Elasticsearch集群配置详解
Elasticsearch集群配置详解

这4个节点都是可以查询到刚刚我们新增的文档id为3的数据,集群正常运作。在我们整个集群配置的过程中,需要我们配置的东西很少,所以elasticsearch真的可以很轻松的完成一个规模超大的集群,并且在该集群中存储海量的数据。

高性能 Java 缓存库—Caffeine

1、介绍

在本文中,我们来看看 Caffeine — 一个高性能的 Java 缓存库

缓存和 Map 之间的一个根本区别在于缓存可以回收存储的 item。

回收策略为在指定时间删除哪些对象。此策略直接影响缓存的命中率 — 缓存库的一个重要特征。

Caffeine 因使用 Window TinyLfu 回收策略,提供了一个近乎最佳的命中率

2、依赖

我们需要在 pom.xml 中添加 caffeine 依赖:

高性能 Java 缓存库—Caffeine

3、填充缓存

让我们来了解一下 Caffeine 的三种缓存填充策略:手动、同步加载和异步加载。

首先,我们为要缓存中存储的值类型写一个类:

高性能 Java 缓存库—Caffeine

3.1、手动填充

在此策略中,我们手动将值放入缓存之后再检索。

让我们初始化缓存:

高性能 Java 缓存库—Caffeine

现在,我们可以使用 getIfPresent 方法从缓存中获取一些值。 如果缓存中不存在此值,则此方法将返回 null:

高性能 Java 缓存库—Caffeine

我们可以使用 put 方法手动填充缓存:

高性能 Java 缓存库—Caffeine

我们也可以使用 get 方法获取值,该方法将一个参数为 key 的 Function 作为参数传入。如果缓存中不存在该键,则该函数将用于提供回退值,该值在计算后插入缓存中:

高性能 Java 缓存库—Caffeine

get 方法可以原子方式执行计算。这意味着您只进行一次计算 — 即使多个线程同时请求该值。这就是为什么使用 get 优于 getIfPresent

有时我们需要手动使一些缓存的值失效

高性能 Java 缓存库—Caffeine

3.2、同步加载

这种加载缓存的方法使用了与用于初始化值的 Function 相似的手动策略的 get 方法。让我们看看如何使用它。

首先,我们需要初始化缓存:

高性能 Java 缓存库—Caffeine

现在我们可以使用 get 方法检索值:

高性能 Java 缓存库—Caffeine

我们也可以使用 getAll 方法获取一组值:

高性能 Java 缓存库—Caffeine

从传递给 build 方法的底层后端初始化函数检索值。 这使得可以使用缓存作为访问值的主要门面(Facade)。

3.3、异步加载

此策略的作用与之前相同,但是以异步方式执行操作,并返回一个包含值的CompletableFuture

高性能 Java 缓存库—Caffeine

我们可以以相同的方式使用 getgetAll 方法,同时考虑到他们返回的是CompletableFuture

高性能 Java 缓存库—Caffeine

CompletableFuture 有许多有用的 API,您可以在此文中获取更多内容。

4、值回收

Caffeine 有三个值回收策略:基于大小,基于时间和参考。

4.1、基于大小的回收

这种回收方式假定当超过配置的缓存大小限制时会发生回收。 获取大小有两种方法:缓存中计数对象,或获取权重。

让我们看看如何计算缓存中的对象。当缓存初始化时,其大小等于零:

高性能 Java 缓存库—Caffeine

当我们添加一个值时,大小明显增加:

高性能 Java 缓存库—Caffeine

我们可以将第二个值添加到缓存中,这导致第一个值被删除:

高性能 Java 缓存库—Caffeine

值得一提的是,在获取缓存大小之前,我们调用了 cleanUp 方法。 这是因为缓存回收被异步执行,这种方法有助于等待回收的完成。

我们还可以传递一个 weigher Function 来获取缓存的大小:

高性能 Java 缓存库—Caffeine

当 weight 超过 10 时,值将从缓存中删除:

高性能 Java 缓存库—Caffeine

4.2、基于时间的回收

这种回收策略是基于条目的到期时间,有三种类型:

  • 访问后到期 — 从上次读或写发生后,条目即过期。
  • 写入后到期 — 从上次写入发生之后,条目即过期
  • 自定义策略 — 到期时间由 Expiry 实现独自计算

让我们使用 expireAfterAccess 方法配置访问后过期策略:

高性能 Java 缓存库—Caffeine

要配置写入后到期策略,我们使用 expireAfterWrite 方法:

高性能 Java 缓存库—Caffeine

要初始化自定义策略,我们需要实现 Expiry 接口:

高性能 Java 缓存库—Caffeine

4.3、基于引用的回收

我们可以将缓存配置为启用缓存键值的垃圾回收。为此,我们将 key 和 value 配置为 弱引用,并且我们可以仅配置软引用以进行垃圾回收。

当没有任何对对象的强引用时,使用 WeakRefence 可以启用对象的垃圾收回收。SoftReference 允许对象根据 JVM 的全局最近最少使用(Least-Recently-Used)的策略进行垃圾回收。有关 Java 引用的更多详细信息,请参见此处。

我们应该使用 Caffeine.weakKeys()Caffeine.weakValues()Caffeine.softValues() 来启用每个选项:

高性能 Java 缓存库—Caffeine

5、刷新

可以将缓存配置为在定义的时间段后自动刷新条目。让我们看看如何使用 refreshAfterWrite 方法:

高性能 Java 缓存库—Caffeine

这里我们应该要明白 expireAfterrefreshAfter 之间的区别。 当请求过期条目时,执行将发生阻塞,直到 build Function 计算出新值为止。

但是,如果条目可以刷新,则缓存将返回一个旧值,并异步重新加载该值

6、统计

Caffeine 有一种记录缓存使用情况的统计方式

高性能 Java 缓存库—Caffeine

7、结论

在本文中,我们熟悉了 Java 的 Caffeine 缓存库。 我们看到了如何配置和填充缓存,以及如何根据我们的需要选择适当的到期或刷新策略。

搞懂“分布式锁”

但是随着分布式的快速发展,本地的加锁往往不能满足我们的需要,在我们的分布式环境中上面加锁的方法就会失去作用。

于是人们为了在分布式环境中也能实现本地锁的效果,也是纷纷各出其招,今天让我们来聊一聊一般分布式锁实现的套路。

为何需要分布式锁

Martin Kleppmann 是英国剑桥大学的分布式系统的研究员,之前和 Redis 之父 Antirez 进行过关于 RedLock(红锁,后续有讲到)是否安全的激烈讨论。

Martin 认为一般我们使用分布式锁有两个场景:

  • 效率:使用分布式锁可以避免不同节点重复相同的工作,这些工作会浪费资源。比如用户付了钱之后有可能不同节点会发出多封短信。
  • 正确性:加分布式锁同样可以避免破坏正确性的发生,如果两个节点在同一条数据上面操作,比如多个节点机器对同一个订单操作不同的流程有可能会导致该笔订单最后状态出现错误,造成损失。

分布式锁的一些特点

当我们确定了在不同节点上需要分布式锁,那么我们需要了解分布式锁到底应该有哪些特点?

分布式锁的特点如下:

  • 互斥性:和我们本地锁一样互斥性是最基本,但是分布式锁需要保证在不同节点的不同线程的互斥。
  • 可重入性:同一个节点上的同一个线程如果获取了锁之后那么也可以再次获取这个锁。
  • 锁超时:和本地锁一样支持锁超时,防止死锁。
  • 高效,高可用:加锁和解锁需要高效,同时也需要保证高可用防止分布式锁失效,可以增加降级。
  • 支持阻塞和非阻塞:和 ReentrantLock 一样支持 lock 和 trylock 以及 tryLock(long timeOut)。
  • 支持公平锁和非公平锁(可选):公平锁的意思是按照请求加锁的顺序获得锁,非公平锁就相反是无序的。这个一般来说实现的比较少。

常见的分布式锁

我们了解了一些特点之后,我们一般实现分布式锁有以下几个方式:

  • MySQL
  • ZK
  • Redis
  • 自研分布式锁:如谷歌的 Chubby。

下面分开介绍一下这些分布式锁的实现原理。

MySQL

首先来说一下 MySQL 分布式锁的实现原理,相对来说这个比较容易理解,毕竟数据库和我们开发人员在平时的开发中息息相关。

对于分布式锁我们可以创建一个锁表:

搞懂“分布式锁”,看这篇文章就对了

前面我们所说的 lock(),trylock(long timeout),trylock() 这几个方法可以用下面的伪代码实现。

lock()

lock 一般是阻塞式的获取锁,意思就是不获取到锁誓不罢休,那么我们可以写一个死循环来执行其操作:

搞懂“分布式锁”,看这篇文章就对了

mysqlLock.lcok 内部是一个 sql,为了达到可重入锁的效果,我们应该先进行查询,如果有值,需要比较 node_info 是否一致。

这里的 node_info 可以用机器 IP 和线程名字来表示,如果一致就加可重入锁 count 的值,如果不一致就返回 false。如果没有值就直接插入一条数据。

伪代码如下:

搞懂“分布式锁”,看这篇文章就对了

需要注意的是这一段代码需要加事务,必须要保证这一系列操作的原子性。

tryLock() 和 tryLock(long timeout)

tryLock() 是非阻塞获取锁,如果获取不到就会马上返回,代码如下:

搞懂“分布式锁”,看这篇文章就对了

tryLock(long timeout) 实现如下:

搞懂“分布式锁”,看这篇文章就对了

mysqlLock.lock 和上面一样,但是要注意的是 select … for update 这个是阻塞的获取行锁,如果同一个资源并发量较大还是有可能会退化成阻塞的获取锁。

unlock()

unlock 的话如果这里的 count 为 1 那么可以删除,如果大于 1 那么需要减去 1。

搞懂“分布式锁”,看这篇文章就对了

锁超时

我们有可能会遇到我们的机器节点挂了,那么这个锁就不会得到释放,我们可以启动一个定时任务,通过计算一般我们处理任务的时间。

比如是 5ms,那么我们可以稍微扩大一点,当这个锁超过 20ms 没有被释放我们就可以认定是节点挂了然后将其直接释放。

MySQL 小结:

  • 适用场景:MySQL 分布式锁一般适用于资源不存在数据库,如果数据库存在比如订单,可以直接对这条数据加行锁,不需要我们上面多的繁琐的步骤。

比如一个订单,我们可以用 select * from order_table where id = ‘xxx’ for update 进行加行锁,那么其他的事务就不能对其进行修改。

  • 优点:理解起来简单,不需要维护额外的第三方中间件(比如 Redis,ZK)。
  • 缺点:虽然容易理解但是实现起来较为繁琐,需要自己考虑锁超时,加事务等等。性能局限于数据库,一般对比缓存来说性能较低。对于高并发的场景并不是很适合。

乐观锁

前面我们介绍的都是悲观锁,这里想额外提一下乐观锁,在我们实际项目中也是经常实现乐观锁,因为我们加行锁的性能消耗比较大,通常我们对于一些竞争不是那么激烈。

但是其又需要保证我们并发的顺序执行使用乐观锁进行处理,我们可以对我们的表加一个版本号字段。

那么我们查询出来一个版本号之后,update 或者 delete 的时候需要依赖我们查询出来的版本号,判断当前数据库和查询出来的版本号是否相等,如果相等那么就可以执行,如果不等那么就不能执行。

这样的一个策略很像我们的 CAS(Compare And Swap),比较并交换是一个原子操作。这样我们就能避免加 select * for update 行锁的开销。

ZooKeeper

ZooKeeper 也是我们常见的实现分布式锁方法,相比于数据库如果没了解过 ZooKeeper 可能上手比较难一些。

ZooKeeper 是以 Paxos 算法为基础的分布式应用程序协调服务。ZK 的数据节点和文件目录类似,所以我们可以用此特性实现分布式锁。

我们以某个资源为目录,然后这个目录下面的节点就是我们需要获取锁的客户端,未获取到锁的客户端注册需要注册 Watcher 到上一个客户端,可以用下图表示:

搞懂“分布式锁”,看这篇文章就对了

/lock 是我们用于加锁的目录,/resource_name 是我们锁定的资源,其下面的节点按照我们加锁的顺序排列。

Curator

Curator 封装了 ZooKeeper 底层的 API,使我们更加容易方便的对 ZooKeeper 进行操作,并且它封装了分布式锁的功能,这样我们就不需要在自己实现了。

Curator 实现了可重入锁(InterProcessMutex),也实现了不可重入锁(InterProcessSemaphoreMutex)。在可重入锁中还实现了读写锁。

InterProcessMutex

InterProcessMutex 是 Curator 实现的可重入锁,我们可以通过下面的一段代码实现我们的可重入锁:

搞懂“分布式锁”,看这篇文章就对了

我们利用 acuire 进行加锁,release 进行解锁。

加锁的流程具体如下:

  • 首先进行可重入的判定:这里的可重入锁记录在 ConcurrentMap

如果 threadData.get(currentThread)是有值的那么就证明是可重入锁,然后记录就会加 1。

我们之前的 MySQL 其实也可以通过这种方法去优化,可以不需要 count 字段的值,将这个维护在本地可以提高性能。

  • 然后在我们的资源目录下创建一个节点:比如这里创建一个 /0000000002 这个节点,这个节点需要设置为 EPHEMERAL_SEQUENTIAL 也就是临时节点并且有序。
  • 获取当前目录下所有子节点,判断自己的节点是否位于子节点第一个。
  • 如果是第一个,则获取到锁,那么可以返回。
  • 如果不是第一个,则证明前面已经有人获取到锁了,那么需要获取自己节点的前一个节点。

/0000000002 的前一个节点是 /0000000001,我们获取到这个节点之后,再上面注册 Watcher(这里的 Watcher 其实调用的是 object.notifyAll(),用来解除阻塞)。

  • object.wait(timeout) 或 object.wait():进行阻塞等待,这里和我们第 5 步的 Watcher 相对应。

解锁的具体流程:

  • 首先进行可重入锁的判定:如果有可重入锁只需要次数减 1 即可,减 1 之后加锁次数为 0 的话继续下面步骤,不为 0 直接返回。
  • 删除当前节点。
  • 删除 threadDataMap 里面的可重入锁的数据。

读写锁

Curator 提供了读写锁,其实现类是 InterProcessReadWriteLock,这里的每个节点都会加上前缀:

private static final String READ_LOCK_NAME = "__READ__"; 
private static final String WRITE_LOCK_NAME = "__WRIT__";

根据不同的前缀区分是读锁还是写锁,对于读锁,如果发现前面有写锁,那么需要将 Watcher 注册到和自己最近的写锁。写锁的逻辑和我们之前 4.2 分析的依然保持不变。

锁超时

ZooKeeper 不需要配置锁超时,由于我们设置节点是临时节点,我们的每个机器维护着一个 ZK 的 Session,通过这个 Session,ZK 可以判断机器是否宕机。

如果我们的机器挂掉的话,那么这个临时节点对应的就会被删除,所以我们不需要关心锁超时。

ZK 小结:

  • 优点:ZK 可以不需要关心锁超时时间,实现起来有现成的第三方包,比较方便,并且支持读写锁,ZK 获取锁会按照加锁的顺序,所以其是公平锁。对于高可用利用 ZK 集群进行保证。
  • 缺点:ZK 需要额外维护,增加维护成本,性能和 MySQL 相差不大,依然比较差。并且需要开发人员了解 ZK 是什么。

Redis

大家在网上搜索分布式锁,恐怕最多的实现就是 Redis 了,Redis 因为其性能好,实现起来简单所以让很多人都对其十分青睐。

Redis 分布式锁简单实现

熟悉 Redis 的同学那么肯定对 setNx(set if not exist) 方法不陌生,如果不存在则更新,其可以很好的用来实现我们的分布式锁。

对于某个资源加锁我们只需要:

setNx resourceName value 

这里有个问题,加锁了之后如果机器宕机那么这个锁就不会得到释放所以会加入过期时间,加入过期时间需要和 setNx 同一个原子操作。

在 Redis 2.8 之前我们需要使用 Lua 脚本达到我们的目的,但是 Redis 2.8 之后 Redis 支持 nx 和 ex 操作是同一原子操作。

set resourceName value ex 5 nx 

Redission

Javaer 都知道 Jedis,Jedis 是 Redis 的 Java 实现的客户端,其 API 提供了比较全面的 Redis 命令的支持。

Redission 也是 Redis 的客户端,相比于 Jedis 功能简单。Jedis 简单使用阻塞的 I/O 和 Redis 交互,Redission 通过 Netty 支持非阻塞 I/O。

Jedis 最新版本 2.9.0 是 2016 年的快 3 年了没有更新,而 Redission 最新版本是 2018 年 10 月更新。

Redission 封装了锁的实现,其继承了 java.util.concurrent.locks.Lock 的接口,让我们像操作我们的本地 Lock 一样去操作 Redission 的 Lock。

下面介绍一下其如何实现分布式锁:

搞懂“分布式锁”,看这篇文章就对了

Redission 不仅提供了 Java 自带的一些方法(lock,tryLock),还提供了异步加锁,对于异步编程更加方便。

由于内部源码较多,就不贴源码了,这里用文字叙述来分析它是如何加锁的,这里分析一下 tryLock 方法:

①尝试加锁:首先会尝试进行加锁,由于需要兼容老版本的 Redis,所以不能直接使用 ex,nx 原子操作的 API,那么就只能使用 Lua 脚本,相关的 Lua 脚本如下:

搞懂“分布式锁”,看这篇文章就对了

可以看见它并没有使用我们的 sexNx 来进行操作,而是使用的 hash 结构,我们的每一个需要锁定的资源都可以看做是一个 HashMap,锁定资源的节点信息是 Key,锁定次数是 Value。

通过这种方式可以很好的实现可重入的效果,只需要对 Value 进行加 1 操作,就能进行可重入锁。当然这里也可以用之前我们说的本地计数进行优化。

②如果尝试加锁失败,判断是否超时,如果超时则返回 false。

③如果加锁失败之后,没有超时,那么需要在名字为 redisson_lock__channel+lockName 的 channel 上进行订阅,用于订阅解锁消息,然后一直阻塞直到超时,或者有解锁消息。

④重试步骤 1,2,3,直到最后获取到锁,或者某一步获取锁超时。

对于我们的 unlock 方法比较简单也是通过 lua 脚本进行解锁,如果是可重入锁,只是减 1。如果是非加锁线程解锁,那么解锁失败。

搞懂“分布式锁”,看这篇文章就对了

Redission 还有公平锁的实现,对于公平锁其利用了 list 结构和 hashset 结构分别用来保存我们排队的节点,和我们节点的过期时间,用这两个数据结构帮助我们实现公平锁,这里就不展开介绍了,有兴趣可以参考源码。

RedLock

我们想象一个这样的场景当机器 A 申请到一把锁之后,如果 Redis 主宕机了,这个时候从机并没有同步到这一把锁,那么机器 B 再次申请的时候就会再次申请到这把锁。

为了解决这个问题 Redis 作者提出了 RedLock 红锁的算法,在 Redission 中也对 RedLock 进行了实现。

搞懂“分布式锁”,看这篇文章就对了

通过上面的代码,我们需要实现多个 Redis 集群,然后进行红锁的加锁,解锁。

具体的步骤如下:

①首先生成多个 Redis 集群的 Rlock,并将其构造成 RedLock。

②依次循环对三个集群进行加锁,加锁的过程和 5.2 里面一致。

③如果循环加锁的过程中加锁失败,那么需要判断加锁失败的次数是否超出了最大值,这里的最大值是根据集群的个数,比如三个那么只允许失败一个,五个的话只允许失败两个,要保证多数成功。

④加锁的过程中需要判断是否加锁超时,有可能我们设置加锁只能用 3ms,第一个集群加锁已经消耗了 3ms 了。那么也算加锁失败。

⑤3,4 步里面加锁失败的话,那么就会进行解锁操作,解锁会对所有的集群在请求一次解锁。

可以看见 RedLock 基本原理是利用多个 Redis 集群,用多数的集群加锁成功,减少 Redis 某个集群出故障,造成分布式锁出现问题的概率。

Redis 小结:

  • 优点:对于 Redis 实现简单,性能对比 ZK 和 MySQL 较好。如果不需要特别复杂的要求,自己就可以利用 setNx 进行实现,如果自己需要复杂的需求的话,可以利用或者借鉴 Redission。对于一些要求比较严格的场景可以使用 RedLock。
  • 缺点:需要维护 Redis 集群,如果要实现 RedLock 需要维护更多的集群。

分布式锁的安全问题

上面我们介绍过红锁,但是 Martin Kleppmann 认为其依然不安全。

有关于 Martin 反驳的几点,我认为其实不仅仅局限于 RedLock,前面说的算法基本都有这个问题,下面我们来讨论一下这些问题。

长时间的 GC pause

熟悉 Java 的同学肯定对 GC 不陌生,在 GC 的时候会发生 STW(stop-the-world)。

例如 CMS 垃圾回收器,它会有两个阶段进行 STW 防止引用继续进行变化。那么有可能会出现下面图(引用至 Martin 反驳 Redlock 的文章)中这个情况:

搞懂“分布式锁”,看这篇文章就对了

client1 获取了锁并且设置了锁的超时时间,但是 client1 之后出现了 STW,这个 STW 时间比较长,导致分布式锁进行了释放。

client2 获取到了锁,这个时候 client1 恢复了锁,那么就会出现 client1,2 同时获取到锁,这个时候分布式锁不安全问题就出现了。

这个不仅仅局限于 RedLock,对于我们的 ZK,MySQL 一样的有同样的问题。

时钟发生跳跃

对于 Redis 服务器如果其时间发生了跳跃,肯定会影响我们锁的过期时间。

那么我们的锁过期时间就不是我们预期的了,也会出现 client1 和 client2 获取到同一把锁,也会出现不安全,这个对于 MySQL 也会出现。但是 ZK 由于没有设置过期时间,那么发生跳跃也不会受影响。

长时间的网络 I/O

这个问题和我们的 GC 的 STW 很像,也就是我们这个获取了锁之后我们进行网络调用,其调用时间由可能比我们锁的过期时间都还长,那么也会出现不安全的问题,这个 MySQL 也会有,ZK 也不会出现这个问题。

对于这三个问题,在网上包括 Redis 作者在内发起了很多讨论。

GC 的 STW

对于这个问题可以看见基本所有的都会出现问题,Martin 给出了一个解法,对于 ZK 这种他会生成一个自增的序列,那么我们真正进行对资源操作的时候,需要判断当前序列是否是最新,有点类似于乐观锁。

当然这个解法 Redis 作者进行了反驳,你既然都能生成一个自增的序列了那么你完全不需要加锁了,也就是可以按照类似于 MySQL 乐观锁的解法去做。

我自己认为这种解法增加了复杂性,当我们对资源操作的时候需要增加判断序列号是否是最新,无论用什么判断方法都会增加复杂度,后面会介绍谷歌的 Chubby 提出了一个更好的方案。

时钟发生跳跃

Martin 觉得 RedLock 不安全很大的原因也是因为时钟的跳跃,因为锁过期强依赖于时间,但是 ZK 不需要依赖时间,依赖每个节点的 Session。

Redis 作者也给出了解答,对于时间跳跃分为人为调整和 NTP 自动调整:

  • 人为调整:人为调整影响的完全可以人为不调整,这个是处于可控的。
  • NTP 自动调整:这个可以通过一定的优化,把跳跃时间控制在可控范围内,虽然会跳跃,但是是完全可以接受的。

长时间的网络 I/O

这一块不是他们讨论的重点,我自己觉得,对于这个问题的优化可以控制网络调用的超时时间,把所有网络调用的超时时间相加。

那么我们锁过期时间其实应该大于这个时间,当然也可以通过优化网络调用比如串行改成并行,异步化等。

Chubby 的一些优化

大家搜索 ZK 的时候,会发现他们都写了 ZK 是 Chubby 的开源实现,Chubby 内部工作原理和 ZK 类似。但是 Chubby 的定位是分布式锁和 ZK 有点不同。

Chubby 也是使用上面自增序列的方案用来解决分布式不安全的问题,但是它提供了多种校验方法:

  • CheckSequencer():调用 Chubby 的 API 检查此时这个序列号是否有效。
  • 访问资源服务器检查,判断当前资源服务器最新的序列号和我们的序列号的大小。
  • lock-delay:为了防止我们校验的逻辑入侵我们的资源服务器,其提供了一种方法当客户端失联的时候,并不会立即释放锁,而是在一定的时间内(默认 1min)阻止其他客户端拿去这个锁。

那么也就是给予了一定的 buffer 等待 STW 恢复,而我们的 GC 的 STW 时间如果比 1min 还长那么你应该检查你的程序,而不是怀疑你的分布式锁了。

小结

本文主要讲了多种分布式锁的实现方法,以及它们的一些优缺点。最后也说了一下关于分布式锁的安全的问题。

对于不同的业务需要的安全程度完全不同,我们需要根据自己的业务场景,通过不同的维度分析,选取最适合自己的方案。

一文弄懂“分布式锁”

多线程情况下对共享资源的操作需要加锁,避免数据被写乱,在分布式系统中,这个问题也是存在的,此时就需要一个分布式锁服务。常见的分布式锁实现一般是基于DB、Redis、zookeeper。下面笔者会按照顺序分析下这3种分布式锁的设计与实现,想直接看分布式锁总结的小伙伴可直接翻到文档末尾处。

分布式锁的实现由多种方式,但是不管怎样,分布式锁一般要有以下特点:

  • 排他性:任意时刻,只能有一个client能获取到锁
  • 容错性:分布式锁服务一般要满足AP,也就是说,只要分布式锁服务集群节点大部分存活,client就可以进行加锁解锁操作
  • 避免死锁:分布式锁一定能得到释放,即使client在释放之前崩溃或者网络不可达

除了以上特点之外,分布式锁最好也能满足可重入、高性能、阻塞锁特性(AQS这种,能够及时从阻塞状态唤醒)等,下面就话不多说,赶紧上(开往分布式锁的设计与实现的)车~

DB锁

在数据库新建一张表用于控制并发控制,表结构可以如下所示:

CREATE TABLE `lock_table` (
 `id` int(11) unsigned NOT NULL COMMENT '主键',
 `key_id` bigint(20) NOT NULL COMMENT '分布式key',
 `memo` varchar(43) NOT NULL DEFAULT '' COMMENT '可记录操作内容',
 `update_time` datetime NOT NULL COMMENT '更新时间',
 PRIMARY KEY (`id`,`key_id`),
 UNIQUE KEY `key_id` (`key_id`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8;

key_id作为分布式key用来并发控制,memo可用来记录一些操作内容(比如memo可用来支持重入特性,标记下当前加锁的client和加锁次数)。将key_id设置为唯一索引,保证了针对同一个key_id只有一个加锁(数据插入)能成功。此时lock和unlock伪代码如下:

def lock :
 exec sql: insert into lock_table(key_id, memo, update_time) values (key_id, memo, NOW())
 if result == true :
 return true
 else :
 return false
def unlock :
 exec sql: delete from lock_table where key_id = 'key_id' and memo = 'memo'

注意,伪代码中的lock操作是非阻塞锁,也就是tryLock,如果想实现阻塞(或者阻塞超时)加锁,只修反复执行lock伪代码直到加锁成功为止即可。基于DB的分布式锁其实有一个问题,那就是如果加锁成功后,client端宕机或者由于网络原因导致没有解锁,那么其他client就无法对该key_id进行加锁并且无法释放了。为了能够让锁失效,需要在应用层加上定时任务,去删除过期还未解锁的记录,比如删除2分钟前未解锁的伪代码如下:

def clear_timeout_lock :
 exec sql : delete from lock_table where update_time < ADDTIME(NOW(),'-00:02:00')

因为单实例DB的TPS一般为几百,所以基于DB的分布式性能上限一般也是1k以下,一般在并发量不大的场景下该分布式锁是满足需求的,不会出现性能问题。不过DB作为分布式锁服务需要考虑单点问题,对于分布式系统来说是不允许出现单点的,一般通过数据库的同步复制,以及使用vip切换Master就能解决这个问题。

以上DB分布式锁是通过insert来实现的,如果加锁的数据已经在数据库中存在,那么用select xxx where key_id = xxx for udpate方式来做也是可以的。

Redis锁

Redis锁是通过以下命令对资源进行加锁:

set key_id key_value NX PX expireTime

其中,set nx命令只会在key不存在时给key进行赋值,px用来设置key过期时间,key_value一般是随机值,用来保证释放锁的安全性(释放时会判断是否是之前设置过的随机值,只有是才释放锁)。由于资源设置了过期时间,一定时间后锁会自动释放。

set nx保证并发加锁时只有一个client能设置成功(Redis内部是单线程,并且数据存在内存中,也就是说redis内部执行命令是不会有多线程同步问题的),此时的lock/unlock伪代码如下:

def lock:
 if (redis.call('set', KEYS[1], ARGV[1], 'ex', ARGV[2], 'nx')) then
 return true
 end
 return false
 
def unlock:
 if (redis.call('get', KEYS[1]) == ARGV[1]) then
 redis.call('del', KEYS[1])
 return true
 end
 return false

分布式锁服务中的一个问题

如果一个获取到锁的client因为某种原因导致没能及时释放锁,并且redis因为超时释放了锁,另外一个client获取到了锁,此时情况如下图所示:

一文弄懂“分布式锁”

那么如何解决这个问题呢,一种方案是引入锁续约机制,也就是获取锁之后,释放锁之前,会定时进行锁续约,比如以锁超时时间的1/3为间隔周期进行锁续约。

关于开源的redis的分布式锁实现有很多,比较出名的有redisson、百度的dlock,关于分布式锁,笔者也写了一个简易版的分布式锁redis-lock,主要是增加了锁续约和可同时针对多个key加锁的机制。

对于高可用性,一般可以通过集群或者master-slave来解决,redis锁优势是性能出色,劣势就是由于数据在内存中,一旦缓存服务宕机,锁数据就丢失了。像redis自带复制功能,可以对数据可靠性有一定的保证,但是由于复制也是异步完成的,因此依然可能出现master节点写入锁数据而未同步到slave节点的时候宕机,锁数据丢失问题。

zookeeper分布式锁

ZooKeeper是一个高可用的分布式协调服务,由雅虎创建,是Google Chubby的开源实现。ZooKeeper提供了一项基本的服务:分布式锁服务。zookeeper重要的3个特征是:zab协议、node存储模型和watcher机制。通过zab协议保证数据一致性,zookeeper集群部署保证可用性,node存储在内存中,提高了数据操作性能,使用watcher机制,实现了通知机制(比如加锁成功的client释放锁时可以通知到其他client)。

zookeeper node模型支持临时节点特性,即client写入的数据时临时数据,当客户端宕机时临时数据会被删除,这样就不需要给锁增加超时释放机制了。当针对同一个path并发多个创建请求时,只有一个client能创建成功,这个特性用来实现分布式锁。注意:如果client端没有宕机,由于网络原因导致zookeeper服务与client心跳失败,那么zookeeper也会把临时数据给删除掉的,这时如果client还在操作共享数据,是有一定风险的。

基于zookeeper实现分布式锁,相对于基于redis和DB的实现来说,使用上更容易,效率与稳定性较好。curator封装了对zookeeper的api操作,同时也封装了一些高级特性,如:Cache事件监听、选举、分布式锁、分布式计数器、分布式Barrier等,使用curator进行分布式加锁示例如下:

<!--引入依赖-->
<!--对zookeeper的底层api的一些封装-->
<dependency>
 <groupId>org.apache.curator</groupId>
 <artifactId>curator-framework</artifactId>
 <version>2.12.0</version>
</dependency>
 
<!--封装了一些高级特性,如:Cache事件监听、选举、分布式锁、分布式计数器、分布式Barrier等-->
<dependency>
 <groupId>org.apache.curator</groupId>
 <artifactId>curator-recipes</artifactId>
 <version>2.12.0</version>
</dependency>
public static void main(String[] args) throws Exception {
 String lockPath = "/curator_recipes_lock_path";
 CuratorFramework client = CuratorFrameworkFactory.builder().connectString("192.168.193.128:2181")
 .retryPolicy(new ExponentialBackoffRetry(1000, 3)).build();
 client.start();
 InterProcessMutex lock = new InterProcessMutex(client, lockPath);
 Runnable task = () -> {
 try {
 lock.acquire();
 try {
 System.out.println("zookeeper acquire success: " + Thread.currentThread().getName());
 Thread.sleep(1000);
 } catch (Exception e) {
 e.printStackTrace();
 } finally {
 lock.release();
 }
 } catch (Exception ex) {
 ex.printStackTrace();
 }
 };
 ExecutorService executor = Executors.newFixedThreadPool(10);
 for (int i = 0; i < 1000; i++) {
 executor.execute(task);
 }
 LockSupport.park();
}

总结

从上面介绍的3种分布式锁的设计与实现中,我们可以看出每种实现都有各自的特点,针对潜在的问题有不同的解决方案,归纳如下:

  • 性能:redis > zookeeper > db。
  • 避免死锁:DB通过应用层设置定时任务来删除过期还未释放的锁,redis通过设置超时时间来解决,而zookeeper是通过临时节点来解决。
  • 可用性:DB可通过数据库同步复制,vip切换master来解决,redis可通过集群或者master-slave方式来解决,zookeeper本身自己是通过zab协议集群部署来解决的。注意,DB和redis的复制一般都是异步的,也就是说某些时刻分布式锁发生故障可能存在数据不一致问题,而zookeeper本身通过zab协议保证集群内(至少n/2+1个)节点数据一致性。
  • 锁唤醒:DB和redis分布式锁一般不支持唤醒机制(也可以通过应用层自己做轮询检测锁是否空闲,空闲就唤醒内部加锁线程),zookeeper可通过本身的watcher/notify机制来做。

使用分布式锁,安全性上和多线程(同一个进程内)加锁是没法比的,可能由于网络原因,分布式锁服务(因为超时或者认为client挂了)将加锁资源给删除了,如果client端继续操作共享资源,此时是有隐患的。因此,对于分布式锁,一个是尽量提高分布式锁服务的可用性,另一个就是要部署同一内网,尽量降低网络问题发生几率。这样来看,貌似分布式锁服务不是“完美”的(PS:技术貌似也不好做到十全十美 🙁 ),那么开发人员该如何选择分布式锁呢?最好是结合自己的业务实际场景,来选择不同的分布式锁实现,一般来说,基于redis的分布式锁服务应用较多。

restful最佳实践——接口规范

为了前后端分工明确,对接流畅,确保可读性和扩展性以及高可用、一致性,特约定下述无状态RESTful API规范:

写在前面

前后端分离意味着,前后端之间使⽤ JSON 来交流,两个开发团队之间使⽤ API 作为契约进⾏交互。从此,后台选⽤的技术栈不影响前台。当我们决定需要前后端分离时,我们仍然还需要⾯对⼀系列的问题:

  1. 是否⾜够的安全?我们怎么去存储⽤户数据,使⽤ LocalStorage 的话,还要考虑加密。采⽤哪种认证⽅式来让⽤户登录,并保存相应的状态?
  2. 是否有⾜够的技术来⽀撑前后端分离?有没有能⼒创建出符合 RESTful 风格的API?
  3. 是否有能⼒维护 API 接口?当前端或者后台需要修改接⼜时,是否能轻松地修改?前端和后台两个团队是不是很容易合作?是不是可以轻松地进⾏联调?
  4. 前后端职责是否能明确?即:后台提供数据,前端负责显⽰。
  5. 是否建⽴了前端的错误追踪机制?能否帮助我们快速地定位出问题。

前后端分离的核⼼:后台提供数据,前端负责显⽰

前提

RESTful API 统一约束客户端和服务器之间的接口。简化和分离系统架构,使每个模块独立!

请求中使用URI定位资源

用HTTP Verbs[动词](GET、POST、PUT、DELETE)描述操作(具体表现形式)

数据传递(默认)采用:Content-Type: application/json; charset=utf-8

Rest

REST即表述性状态传递(英文:Representational State Transfer,简称REST)是Roy Fielding博士在2000年他的博士论文中提出来的一种软件架构风格。它是一种针对网络应用的设计和开发方式,可以降低开发的复杂性,提高系统的可伸缩性。**REST是设计风格而不是标准。**REST通常基于使用HTTP,URI,和XML(标准通用标记语言下的一个子集)以及HTML(标准通用标记语言下的一个应用)

统一接口(Uniform Interface)

统一接口约束定义客户端和服务器之间的接口。它简化了分离的结构,使各部分独立发展。

无状态(Stateless)

REST要求状态要么被放入资源状态中,要么保存在客户端上。或者换句话说,服务器端不能保持除了单次请求之外的,任何与其通信的客户端的通信状态。从客户端的每个请求要包含服务器所需要的所有信息。这样做的最直接的理由就是可伸缩性—— 如果服务器需要保持客户端状态,那么大量的客户端交互会严重影响服务器的内存可用空间(footprint)。

缓存(Cachable)

服务器返回信息必须被标记是否可以缓存,如果缓存,客户端可能会重用之前的信息发送请求。

客户-服务器(Client-Server)

客户端无需关注数据存储,服务器端无需关注用户界面,提高了前后端可移植性。

分层系统(Layered System)

客户端不关心直接连接到最终服务器还是连接到中间服务器。中间服务器可以通过启用负载平衡和提供共享缓存来提高系统可扩展性。分层系统也可以执行安全策略。

支持按需代码(Code on Demand,可选)

服务器可以通过传输逻辑来临时扩展或定制客户端的功能。

URL规范

GET https//domain.com/api/{模块名}/{?菜单名}/{接口名}/:param

  1. 不能使用大写,用中横线 – 不用下划线 _ ;
  2. 使用名词表示资源集合,使用复数形式(为确保所有API URIs保持一致),不能使用动词;
  3. 每个资源都至少有一个标识它的URI,同时应该遵循一个可预测的层次结构来提高可理解性,从而提高可用性;
  4. 无需在URI中增加版本号,通过HTTP请求头信息的字段中进行区分(或者在URI包含主版本信息,同时请求头包含子版本信息。

Accept: vnd.example-com.foo+json;version=1.1Accept: vnd.example-com.foo+json; version=2.0

restful最佳实践——接口规范

Request

restful最佳实践——接口规范

说明:

  1. 安全性 :不会改变资源状态,可以理解为只读的;
  2. 幂等性 :执行1次和执行N次,对资源状态改变的效果是等价的。
  3. 查询字段内容过多,统一使用POST方式查询,请求地址增加/query加以区分
  4. 批量删除,统一使用POST方式,请求地址增加/delete加以区分

由于存在批量删除的情况,而一些网关、代理、防火墙在收到DELETE请求后,会把请求的body直接剥离掉。建议将存在批量删除的接口统一改成POST提交,为了标识是删除操作,在请求路径上增加/delete。

GET

被用于获取资源。不允许对服务器上资源做任何修改操作。

示例:

http://www.example.com/customers/12345
http://www.example.com/customers/12345/orders
http://www.example.com/buckets/sample

PUT

常用于更新资源。通过请求体携带资源发送给服务器。注意:在资源ID由客户端而不是由服务器选择的情况下,也可以使用PUT来创建资源。修改成功返回200,创建成功返回201。建议使用post进行创建新资源。

http://www.example.com/customers/12345
http://www.example.com/customers/12345/orders/98765
http://www.example.com/buckets/secret_stuff

POST

常用于创建新资源。创建成功通常返回201。

http://www.example.com/customers
http://www.example.com/customers/12345/orders

DELETE

删除资源。

http://www.example.com/customers/12345
http://www.example.com/customers/12345/orders
http://www.example.com/bucket

restful最佳实践——接口规范

其他

  • 排序

使用数组传递排序字段,-表示降序,无任何标识表示升序。

sorts: [‘-age’, ‘name’]

  • 时间传递

日期和时间戳如果没有适当和一致地处理,可能是一个真正的头痛。建议使用UTC或GMT时间存储,处理,缓存等时间戳或者使用统一格式化的时间字符串”yyyy-MM-dd HH:mm:ss”

Respone

状态码

restful最佳实践——接口规范

格式

  • 前后端交互字段全部使用小驼峰方式

// HTTP响应码(好多javascript框架并不会获取http状态码,所以包装到body中便于使用)
{ “code”: “200”, “status”: “success/fail/error”, // 见下述表格 “content/data”: []/{}, // 多条记录使用JSON数组,单条记录使用JSON对象 “message”: [] // 状态为error或fail时,对应的错误信息}

status说明

restful最佳实践——接口规范

示例

图表、下拉列表框

  • 图表、下拉列表框等建议统一key-name-value形式返回,这样对于图表来说可以统一处理,无需考虑业务性,增加了其复用性!

GET http://xxx.com/api/dashboard/se-count/:uid

{ “status”: “success”, “content”: [ { “key”: “low”, // 前后端交互使用关键字 “name”: “低级”, // 前端显示 “value”: 540 }, { “key”: “medium”, “name”: “中级”, “value”: 336 }, { “key”: “high”, “name”: “高级”, “value”: 92 } ], errorCode: “”, message: “”}

多条曲线

“status”: “success”, “content”:[ { “key”: “firewall”, “name”: “Firewall”, “value”: [ { “key”: 1508083200000, “name”: “3:00”, “value”: 23 }, { “key”: 1508094000000, “name”: “6:00”, “value”: 43 } ] }, { “key”: “vpn”, “name”: “VPN”, “value”: [ { “key”: 1508083200000, “name”: “3:00”, “value”: 31 }, { “key”: 1508094000000, “name”: “6:00”, “value”: 33 } ] } ]}

表格分页

请求

POST http://xxx.com/api/dashboard/se-by-source-ip

{ startTime: 1505977365777, endTime: 1506063765777, pageNum: 1, // 当前页码 pageSize: 20, // 每页条数 }

响应结果

{ “total”: 1, // 总条数 “totalPage”: 1, // (可选)总页数 “pageNum”: 1, // (可选)当前页码 “pageSize”: 20, // (可选)每页条数 “value”: [ { “field1”: “value”, “field2”: “value”, “field3”: “value”, “field4”: “value” } ]}

需要处理的问题

  • Get请求参数过多,或携带敏感信息
  • 批量删除,携带一组id信息
  • 文件导出、文件上传