这里将持续拓展增加一些分布式相关的核心要点。
-
消息中间件
- Kafka
- RabbitMQ
-
微服务
- Dubbo
- Spring Boot
- Spring Cloud
-
Zookeeper
-
Hadoop
-
Dobbo
金山云面经
-
为什么要使用分布式锁?
- 在单体应用单机环境下,ReentrantLock 或 Synchronized 进行互斥控制。在单机环境中,Java 中提供了很多并发处理相关的 API。但是,随着业务发展的需要,原单体单机部署的系统被演化成分布式集群系统后,由于分布式系统多线程、多进程并且分布在不同机器上,这将使原单机部署情况下的并发控制锁策略失效,单纯的 Java API 并不能提供分布式锁的能力。为了解决这个问题就需要一种跨 JVM 的互斥机制来控制共享资源的访问,这就是分布式锁要解决的问题!
-
分布式锁一般有三种实现方式:
- 数据库乐观锁
- 基于 Redis 的分布式锁
- 基于 Zookeeper 的分布式锁
尽管有这三种方案,但是不同的业务也要根据自己的情况进行选型,他们之间没有最好只有更适合!
首先,为了确保分布式锁可用,我们至少要确保锁的实现同时满足以下四个条件:
- **互斥性。**在任意时刻,只有一个客户端能持有锁。
- **不会发生死锁。**即使有一个客户端在持有锁的期间崩溃而没有主动解锁,也能保证后续其他客户端能加锁。
- **具有容错性。**只要大部分的Redis节点正常运行,客户端就可以加锁和解锁。
- **解铃还须系铃人。**加锁和解锁必须是同一个客户端,客户端自己不能把别人加的锁给解了。
基于数据库的实现方式的核心思想是:在数据库中创建一个表,表中包含方法名等字段,并在方法名字段上创建唯一索引,想要执行某个方法,就使用这个方法名向表中插入数据,成功插入则获取锁,执行完成后删除对应的行数据释放锁。
(1)创建表
要实现分布式锁,最简单的方式可能就是直接创建一张锁表,然后通过操作该表中的数据来实现了。
当我们要锁住某个方法或资源时,我们就在该表中增加一条记录,想要释放锁的时候就删除这条记录。
DROP TABLE IF EXISTS `method_lock`;
CREATE TABLE `method_lock` (
`id` int(11) unsigned NOT NULL AUTO_INCREMENT COMMENT '主键',
`method_name` varchar(64) NOT NULL COMMENT '锁定的方法名',
`desc` varchar(255) NOT NULL COMMENT '备注信息',
`update_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uidx_method_name` (`method_name`) USING BTREE
) ENGINE=InnoDB AUTO_INCREMENT=3 DEFAULT CHARSET=utf8 COMMENT='锁定中的方法';UNIQUE 约束唯一标识数据库表中的每条记录,每个表可以有多个 UNIQUE 约束.
(2)想要执行某个方法,就使用这个方法名向表中插入数据:
INSERT INTO method_lock (method_name, desc) VALUES ('methodName', '测试的methodName');因为我们对 method_name 做了唯一性约束,这里如果有多个请求同时提交到数据库的话,数据库会保证只有一个操作可以成功,那么我们就可以认为操作成功的那个线程获得了该方法的锁,可以执行方法体内容。
(3)成功插入则获取锁,执行完成后删除对应的行数据释放锁:
delete from method_lock where method_name ='methodName';上面这种简单的实现有以下几个问题:
-
这把锁强依赖数据库的可用性,数据库是一个单点,一旦数据库挂掉,会导致业务系统不可用。
-
这把锁没有失效时间,一旦解锁操作失败,就会导致锁记录一直在数据库中,其他线程无法再获得到锁。
-
这把锁只能是非阻塞的,因为数据的insert操作,一旦插入失败就会直接报错。没有获得锁的线程并不会进入排队队列,要想再次获得锁就要再次触发获得锁操作。
-
这把锁是非重入的,同一个线程在没有释放锁之前无法再次获得该锁。因为数据中数据已经存在了。
以上问题的解决方案:
-
数据库是单点?
搞两个数据库,数据之前双向同步。一旦挂掉快速切换到备库上。
-
没有失效时间?
只要做一个定时任务,每隔一定时间把数据库中的超时数据清理一遍。
-
非阻塞的?
搞一个 while 循环,直到 insert 成功再返回成功。
-
非重入的?
在数据库表中加个字段,记录当前获得锁的机器的主机信息和线程信息,那么下次再获取锁的时候先查询数据库,如果当前机器的主机信息和线程信息在数据库可以查到的话,直接把锁分配给他就可以了。
1、选用Redis实现分布式锁原因:
- Redis 有很高的性能;
- Redis 命令对此支持较好,实现起来比较方便
2、使用命令介绍:
在使用 Redis 实现分布式锁的时候,主要就会使用到这三个命令。
- SETNX
SETNX key val:
当且仅当key不存在时,set一个key为val的字符串,返回1;若key存在,则什么都不做,返回0。
- expire
expire key timeout:
为key设置一个超时时间,单位为second,超过这个时间锁会自动释放,避免死锁。
- delete
delete key:删除key1
3、实现思想:
-
获取锁的时候,使用 setnx 加锁,并使用 expire 命令为锁添加一个超时时间,超过该时间则自动释放锁,锁的 value 值为一个随机生成的 UUID,通过此在释放锁的时候进行判断。
-
获取锁的时候还设置一个获取的超时时间,若超过这个时间则放弃获取锁。
-
释放锁的时候,通过UUID判断是不是该锁,若是该锁,则执行delete进行锁释放。
注解:UUID是128位长的数字,一般用16进制表示,结合机器的网卡、当地时间、一个随机数来生成UUID
众所周知,reids锁是通过 setnx + expire 的方式实现的,setnx 保证只有在 key 不存在时才能set成功,expire 保证锁在非正常释放的情况下不会形成死锁。基本原理就是这个,但实际操作中我们需要注意几个问题:
-
setnx 与 expire 是非原子性的,那么如果 setnx 执行成功、但 expire 未执行,那么锁也就无法过期自动删除了。解决方案:redis 提供命令
set(key,1,30,nx)一步到位设置超时时间。 -
如果线程 A 先获得了锁,但是执行时间超过了锁的过期时间,锁自动释放了,那么线程 B 获得锁,不仅有可能导致执行结果出现并发不一致问题,如果 A 执行完了,那么还会删除掉 B 的锁。
解决方案如下:
- 使用一个请求标识作为锁的 value 值,在删除前判断一下。
- 使用一个守护线程,不断的更新锁过期时间,保证执行过程中锁不会释放。
-
判断 value 值与删除锁不是原子的。解决方案:使用 lua 脚本,保证判断与删除原子操作。
参考资料:
ZooKeeper 是一个为分布式应用提供一致性服务的开源组件,它内部是一个分层的文件系统目录树结构,规定同一个目录下只能有一个唯一文件名。
基本思路:每个客户端对某个方法加锁时,在 zookeeper 上的与该方法对应的指定节点的目录下,生成一个唯一的瞬时有序节点。 判断是否获取锁的方式很简单,只需要判断有序节点中序号最小的一个。 当释放锁的时候,只需将这个瞬时节点删除即可。同时,其可以避免服务宕机导致的锁无法释放,而产生的死锁问题。
Zookeeper能不能解决前面提到的问题。
- 锁无法释放?使用 Zookeeper 可以有效的解决锁无法释放的问题,因为在创建锁的时候,客户端会在 ZK 中创建一个临时节点,一旦客户端获取到锁之后突然挂掉(Session 连接断开),那么这个临时节点就会自动删除掉。其他客户端就可以再次获得锁。
- 非阻塞锁?使用 Zookeeper 可以实现阻塞的锁,客户端可以通过在ZK中创建顺序节点,并且在节点上绑定监听器,一旦节点有变化,Zookeeper 会通知客户端,客户端可以检查自己创建的节点是不是当前所有节点中序号最小的,如果是,那么自己就获取到锁,便可以执行业务逻辑了。
- 不可重入?使用 Zookeeper 也可以有效的解决不可重入的问题,客户端在创建节点的时候,把当前客户端的主机信息和线程信息直接写入到节点中,下次想要获取锁的时候和当前最小的节点中的数据比对一下就可以了。如果和自己的信息一样,那么自己直接获取到锁,如果不一样就再创建一个临时的顺序节点,参与排队。
- 单点问题?使用 Zookeeper 可以有效的解决单点问题,ZK是集群部署的,只要集群中有半数以上的机器存活,就可以对外提供服务。
-
从实现的复杂性角度(从低到高) Zookeeper >= 缓存 > 数据库
-
从性能角度(从高到低) 缓存 > Zookeeper >= 数据库
-
从可靠性角度(从高到低) Zookeeper > 缓存 > 数据库
参考资料
web 应用采用 browser/server 架构,http 作为通信协议。http 是无状态协议,浏览器的每一次请求,服务器会独立处理,不与之前或之后的请求产生关联,这个过程用下图说明,三次请求/响应对之间没有任何联系
但这也同时意味着,任何用户都能通过浏览器访问服务器资源,如果想保护服务器的某些资源,必须限制浏览器请求;要限制浏览器请求,必须鉴别浏览器请求,响应合法请求,忽略非法请求;要鉴别浏览器请求,必须清楚浏览器请求状态。既然 http 协议无状态,那就让服务器和浏览器共同维护一个状态吧!这就是会话机制
浏览器第一次请求服务器,服务器创建一个会话,并将会话的 id 作为响应的一部分发送给浏览器,浏览器存储会话 id,并在后续第二次和第三次请求中带上会话 id,服务器取得请求中的会话 id 就知道是不是同一个用户了,这个过程用下图说明,后续请求与第一次请求产生了关联
服务器在内存中保存会话的两种方式
- 请求参数
- Cookie
将会话 id 作为每一个请求的参数,服务器接收请求自然能解析参数获得会话 id,并借此判断是否来自同一会话,很明显,这种方式不靠谱。那就浏览器自己来维护这个会话 id 吧,每次发送 http 请求时浏览器自动发送会话 id,cookie 机制正好用来做这件事。cookie 是浏览器用来存储少量数据的一种机制,数据以 key/value 形式存储,浏览器发送 http 请求时自动附带 cookie 信息
tomcat 会话机制当然也实现了 cookie,访问 tomcat 服务器时,浏览器中可以看到一个名为 JSESSIONID 的 cookie,这就是 tomcat 会话机制维护的会话 id,使用了 cookie 的请求响应过程如下图
有了会话机制,登录状态就好明白了,我们假设浏览器第一次请求服务器需要输入用户名与密码验证身份,服务器拿到用户名密码去数据库比对,正确的话说明当前持有这个会话的用户是合法用户,应该将这个会话标记为“已授权”或者“已登录”等等之类的状态,既然是会话的状态,自然要保存在会话对象中,tomcat 在会话对象中设置登录状态如下
HttpSession session = request.getSession();
session.setAttribute("isLogin", true);用户再次访问时,tomcat 在会话对象中查看登录状态
HttpSession session = request.getSession();
session.getAttribute("isLogin");实现了登录状态的浏览器请求服务器模型如下图描述
每次请求受保护资源时都会检查会话对象中的登录状态,只有 isLogin=true 的会话才能访问,登录机制因此而实现
web 系统早已从久远的单系统发展成为如今由多系统组成的应用群,面对如此众多的系统,用户难道要一个一个登录、然后一个一个注销吗?就像下图描述的这样
web 系统由单系统发展成多系统组成的应用群,复杂性应该由系统内部承担,而不是用户。无论 web 系统内部多么复杂,对用户而言,都是一个统一的整体,也就是说,用户访问 web 系统的整个应用群与访问单个系统一样,登录/注销只要一次就够了
虽然单系统的登录解决方案很完美,但对于多系统应用群已经不再适用了,为什么呢?
单系统登录解决方案的核心是 cookie,cookie 携带会话 id 在浏览器与服务器之间维护会话状态。但 cookie 是有限制的,这个限制就是 cookie 的域(通常对应网站的域名),浏览器发送 http 请求时会自动携带与该域匹配的 cookie,而不是所有 cookie
既然这样,为什么不将 web 应用群中所有子系统的域名统一在一个顶级域名下,例如 *.baidu.com,然后将它们的 cookie 域设置为 baidu.com,这种做法理论上是可以的,甚至早期很多多系统登录就采用这种同域名共享 cookie 的方式。
然而,可行并不代表好,共享 cookie 的方式存在众多局限。
-
应用群域名得统一
-
应用群各系统使用的技术(至少是
web服务器)要相同,不然cookie的key值(tomcat为JSESSIONID)不同,无法维持会话,共享cookie的方式是无法实现跨语言技术平台登录的,比如java、php、python系统之间 -
cookie本身不安全
因此,我们需要一种全新的登录方式来实现多系统应用群的登录,这就是 单点登录
什么是单点登录?单点登录全称 Single Sign On(以下简称 SSO),是指在多系统应用群中登录一个系统,便可在其他所有系统中得到授权而无需再次登录,包括 单点登录 与 单点注销 两部分
相比于单系统登录,sso 需要一个独立的认证中心,只有认证中心能接受用户的用户名密码等安全信息,其他系统不提供登录入口,只接受认证中心的间接授权。间接授权通过令牌实现,sso 认证中心验证用户的用户名密码没问题,创建授权令牌,在接下来的跳转过程中,授权令牌作为参数发送给各个子系统,子系统拿到令牌,即得到了授权,可以借此创建局部会话,局部会话登录方式与单系统的登录方式相同。这个过程,也就是单点登录的原理,用下图说明
下面对上图简要描述
-
用户访问系统 1 的受保护资源,系统 1 发现用户未登录,跳转至
sso认证中心,并将自己的地址作为参数 -
sso认证中心发现用户未登录,将用户引导至登录页面 -
用户输入用户名密码提交登录申请
-
sso认证中心校验用户信息,创建用户与sso认证中心之间的会话,称为全局会话,同时创建授权令牌 -
sso认证中心带着令牌跳转会最初的请求地址(系统1) -
系统1拿到令牌,去
sso认证中心校验令牌是否有效 -
sso认证中心校验令牌,返回有效,注册系统 1 -
系统 1 使用该令牌创建与用户的会话,称为局部会话,返回受保护资源
-
用户访问系统 2 的受保护资源
-
系统2发现用户未登录,跳转至
sso认证中心,并将自己的地址作为参数 -
sso认证中心发现用户已登录,跳转回系统 2 的地址,并附上令牌 -
系统 2 拿到令牌,去
sso认证中心校验令牌是否有效 -
sso认证中心校验令牌,返回有效,注册系统 2 -
系统 2 使用该令牌创建与用户的局部会话,返回受保护资源
用户登录成功之后,会与 sso 认证中心及各个子系统建立会话,用户与 sso 认证中心建立的会话称为全局会话,用户与各个子系统建立的会话称为局部会话,局部会话建立之后,用户访问子系统受保护资源将不再通过 sso 认证中心,全局会话与局部会话有如下约束关系
- 局部会话存在,全局会话一定存在
- 全局会话存在,局部会话不一定存在
- 全局会话销毁,局部会话必须销毁
单点登录自然也要单点注销,在一个子系统中注销,所有子系统的会话都将被销毁,用下面的图来说明
sso 认证中心一直监听全局会话的状态,一旦全局会话销毁,监听器将通知所有注册系统执行注销操作
- 用户向系统 1 发起注销请求
- 系统 1 根据用户与系统1建立的会话
id拿到令牌,向sso认证中心发起注销请求 sso认证中心校验令牌有效,销毁全局会话,同时取出所有用此令牌注册的系统地址sso认证中心向所有注册系统发起注销请求- 各注册系统接收
sso认证中心的注销请求,销毁局部会话 sso认证中心引导用户至登录页面
参考资料:
SSO 全局token和局部token过期时间?
- 基于 server-session 的管理方式
- cookie-based 的管理方式
- token-based 的管理方式
参考资料:
HTTP 是一种没有状态的协议,也就是它并不知道是谁是访问应用。这里我们把用户看成是客户端,客户端使用用户名还有密码通过了身份验证,不过下回这个客户端再发送请求时候,还得再验证一下。
解决的方法就是,当用户请求登录的时候,如果没有问题,我们在服务端生成一条记录,这个记录里可以说明一下登录的用户是谁,然后把这条记录的 ID 号发送给客户端,客户端收到以后把这个 ID 号存储在 Cookie 里,下次这个用户再向服务端发送请求的时候,可以带着这个 Cookie ,这样服务端会验证一个这个 Cookie 里的信息,看看能不能在服务端这里找到对应的记录,如果可以,说明用户已经通过了身份验证,就把用户请求的数据返回给客户端。
上面说的就是 Session,我们需要在服务端存储为登录的用户生成的 Session ,这些 Session 可能会存储在内存,磁盘,或者数据库里。我们可能需要在服务端定期的去清理过期的 Session 。
使用基于 Token 的身份验证方法,在服务端不需要存储用户的登录记录。大概的流程是这样的:
- 客户端使用用户名跟密码请求登录
- 服务端收到请求,去验证用户名与密码
- 验证成功后,服务端会签发一个 Token,再把这个 Token 发送给客户端
- 客户端收到 Token 以后可以把它存储起来,比如放在 Cookie 里或者 Local Storage 里
- 客户端每次向服务端请求资源的时候需要带着服务端签发的 Token
- 服务端收到请求,然后去验证客户端请求里面带着的 Token,如果验证成功,就向客户端返回请求的数据
实施 Token 验证的方法挺多的,还有一些标准方法,比如 JWT(读作:jot ,表示:JSON Web Tokens) 。
JWT 标准的 Token 有三个部分:
- header(头部)
- payload(数据)
- signature(签名)
中间用点分隔开,并且都会使用 Base64 编码,所以真正的 Token 看起来像这样:
eyJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJuaW5naGFvLm5ldCIsImV4cCI6IjE0Mzg5NTU0NDUiLCJuYW1lIjoid2FuZ2hhbyIsImFkbWluIjp0cnVlfQ.SwyHTEx_RQppr97g4J5lKXtabJecpejuef8AqKYMAJc
每个 JWT token 里面都有一个 header,也就是头部数据。里面包含了使用的算法,这个 JWT 是不是带签名的或者加密的。主要就是说明一下怎么处理这个 JWT token 。
头部里包含的东西可能会根据 JWT 的类型有所变化,比如一个加密的 JWT 里面要包含使用的加密的算法。唯一在头部里面要包含的是 alg 这个属性,如果是加密的 JWT,这个属性的值就是使用的签名或者解密用的算法。如果是未加密的 JWT,这个属性的值要设置成 none。
示例:
{
"alg": "HS256"
}意思是这个 JWT 用的算法是 HS256。上面的内容得用 base64url 的形式编码一下,所以就变成这样:
eyJhbGciOiJIUzI1NiJ9Payload 里面是 Token 的具体内容,这些内容里面有一些是标准字段,你也可以添加其它需要的内容。下面是标准字段:
- iss:Issuer,发行者
- sub:Subject,主题
- aud:Audience,观众
- exp:Expiration time,过期时间
- nbf:Not before
- iat:Issued at,发行时间
- jti:JWT ID
比如下面这个 Payload ,用到了 iss 发行人,还有 exp 过期时间这两个标准字段。另外还有两个自定义的字段,一个是 name ,还有一个是 admin 。
{
"iss": "ninghao.net",
"exp": "1438955445",
"name": "wanghao",
"admin": true
}使用 base64url 编码以后就变成了这个样子:
eyJpc3MiOiJuaW5naGFvLm5ldCIsImV4cCI6IjE0Mzg5NTU0NDUiLCJuYW1lIjoid2FuZ2hhbyIsImFkbWluIjp0cnVlfQJWT 的最后一部分是 Signature ,这部分内容有三个部分,先是用 Base64 编码的 header.payload ,再用加密算法加密一下,加密的时候要放进去一个 Secret ,这个相当于是一个密码,这个密码秘密地存储在服务端。
- header
- payload
- secret
const encodedString = base64UrlEncode(header) + "." + base64UrlEncode(payload);
HMACSHA256(encodedString, 'secret');处理完成以后看起来像这样:
SwyHTEx_RQppr97g4J5lKXtabJecpejuef8AqKYMAJc最后这个在服务端生成并且要发送给客户端的 Token 看起来像这样:
eyJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJuaW5naGFvLm5ldCIsImV4cCI6IjE0Mzg5NTU0NDUiLCJuYW1lIjoid2FuZ2hhbyIsImFkbWluIjp0cnVlfQ.SwyHTEx_RQppr97g4J5lKXtabJecpejuef8AqKYMAJc客户端收到这个 Token 以后把它存储下来,下回向服务端发送请求的时候就带着这个 Token 。服务端收到这个 Token ,然后进行验证,通过以后就会返回给客户端想要的资源。
引用来源:
过期时间为 2t,每次请求验证是否判断 exp < t,是的话 t 往后加一定的时间。
IaaS 基础设施即服务
PaaS 平台即服务
SaaS 软件即服务
BaaS 区块链即服务
云计算、云服务、云存储 —— 全栈
持续集成与持续交付








