集群是个物理形态, 分布式是个工作方式。
只要是一堆机器, 就可以叫集群, 他们是不是一起协作着干活, 这个谁也不知道; 一个程序或系统, 只要运行在不同的机器上, 就可以叫分布式, 嗯, C/S 架构也可以叫分布式。
集群一般是物理集中、统一管理的, 而分布式系统则不强调这一点。
所以, 集群可能运行着一个或多个分布式系统, 也可能根本没有运行分布式系统; 分布式系统可能运行在一个集群上, 也可能运行在不属于一个集群的多台 (2 台也算多台) 机器上。
有些情况下, 对分布式的需求就没这么简单, 在每个环节上都有分布式的需求, 比如 Load Balance、DB、Cache 和文件等等, 并且当分布式节点之间有关联时, 还得考虑之间的通讯, 另外, 节点非常多的时候, 得有监控和管理来支撑。这样看起来, 分布式是一个非常庞大的体系, 只不过你可以根据具体需求进行适当地裁剪。按照最完备的分布式体系来看, 可以由以下模块组成:
因此, 若要深入研究云计算和分布式, 就得深入研究以上领域, 而这些领域每一块的水都很深, 都需要很底层的知识和技术来支撑, 所以说, 对于想提升技术的开发者来说, 以分布式来作为切入点是非常好的, 可以以此为线索, 探索计算机世界的各个角落。
布式是相对中心化而来, 强调的是任务在多个物理隔离的节点上进行。中心化带来的主要问题是可靠性, 若中心节点宕机则整个系统不可用, 分布式除了解决部分中心化问题, 也倾向于分散负载, 但分布式会带来很多的其他问题, 最主要的就是一致性。
集群就是逻辑上处理同一任务的机器集合, 可以属于同一机房, 也可分属不同的机房。分布式这个概念可以运行在某个集群里面, 某个集群也可作为分布式概念的一个节点。
一句话, 就是: “分头做事” 与 “一堆人” 的区别。
从 Elasticsearch 来看分布式系统架构设计
分布式系统 (distributed system) 正变得越来越重要, 大型网站几乎都是分布式的。
分布式系统的最大难点, 就是各个节点的状态如何同步。CAP 定理是这方面的基本定理, 也是理解分布式系统的起点。
本文介绍该定理。它其实很好懂, 而且是显而易见的。下面的内容主要参考了 Michael Whittaker 的文章。
1998 年, 加州大学的计算机科学家 Eric Brewer 提出, 分布式系统有三个指标。
它们的第一个字母分别是 C、A、P。
Eric Brewer 说, 这三个指标不可能同时做到。这个结论就叫做 CAP 定理(CAP Theorem)。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-BdppRxVR-1670574512204)(https://hjs-1251193177.cos.ap-shanghai.myqcloud.com/distributed/distributed-cap-1.jpg)]
先看 Partition tolerance, 中文叫做 “分区容错”。
大多数分布式系统都分布在多个子网络。每个子网络就叫做一个区(partition)。分区容错的意思是, 区间通信可能失败。比如, 一台服务器放在中国, 另一台服务器放在美国, 这就是两个区, 它们之间可能无法通信。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-39aFG31M-1670574512204)(https://hjs-1251193177.cos.ap-shanghai.myqcloud.com/distributed/distributed-cap-2.jpg)]
上图中, G1 和 G2 是两台跨区的服务器。G1 向 G2 发送一条消息, G2 可能无法收到。系统设计的时候, 必须考虑到这种情况。
一般来说, 分区容错无法避免, 因此可以认为 CAP 的 P 总是成立。CAP 定理告诉我们, 剩下的 C 和 A 无法同时做到。
Consistency 中文叫做 “一致性”。意思是, 写操作之后的读操作, 必须返回该值。举例来说, 某条记录是 v0, 用户向 G1 发起一个写操作, 将其改为 v1。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-fBtOxrXS-1670574512205)(https://hjs-1251193177.cos.ap-shanghai.myqcloud.com/distributed/distributed-cap-3.jpg)]
接下来, 用户的读操作就会得到 v1。这就叫一致性。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-lkOqzrIx-1670574512205)(https://hjs-1251193177.cos.ap-shanghai.myqcloud.com/distributed/distributed-cap-4.jpg)]
问题是, 用户有可能向 G2 发起读操作, 由于 G2 的值没有发生变化, 因此返回的是 v0。G1 和 G2 读操作的结果不一致, 这就不满足一致性了。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-0xvYtwfS-1670574512205)(https://hjs-1251193177.cos.ap-shanghai.myqcloud.com/distributed/distributed-cap-5.jpg)]
为了让 G2 也能变为 v1, 就要在 G1 写操作的时候, 让 G1 向 G2 发送一条消息, 要求 G2 也改成 v1。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-9WEnMlhz-1670574512205)(https://hjs-1251193177.cos.ap-shanghai.myqcloud.com/distributed/distributed-cap-6.jpg)]
这样的话, 用户向 G2 发起读操作, 也能得到 v1。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-u6Awa7BQ-1670574512205)(https://hjs-1251193177.cos.ap-shanghai.myqcloud.com/distributed/distributed-cap-7.jpg)]
Availability 中文叫做 “可用性”, 意思是只要收到用户的请求, 服务器就必须给出回应。
用户可以选择向 G1 或 G2 发起读操作。不管是哪台服务器, 只要收到请求, 就必须告诉用户, 到底是 v0 还是 v1, 否则就不满足可用性。
一致性和可用性, 为什么不可能同时成立? 答案很简单, 因为可能通信失败(即出现分区容错)。
如果保证 G2 的一致性, 那么 G1 必须在写操作时, 锁定 G2 的读操作和写操作。只有数据同步后, 才能重新开放读写。锁定期间, G2 不能读写, 没有可用性不。
如果保证 G2 的可用性, 那么势必不能锁定 G2, 所以一致性不成立。
综上所述, G2 无法同时做到一致性和可用性。系统设计时只能选择一个目标。如果追求一致性, 那么无法保证所有节点的可用性; 如果追求所有节点的可用性, 那就没法做到一致性。
读者问, 在什么场合, 可用性高于一致性?
举例来说, 发布一张网页到 CDN, 多个服务器有这张网页的副本。后来发现一个错误, 需要更新网页, 这时只能每个服务器都更新一遍。
一般来说, 网页的更新不是特别强调一致性。短时期内, 一些用户拿到老版本, 另一些用户拿到新版本, 问题不会特别大。当然, 所有人最终都会看到新版本。所以, 这个场合就是可用性高于一致性。
CAP 三个特性只能满足其中两个, 那么取舍的策略就共有三种:
CA without P: 如果不要求 P(不允许分区), 则 C(强一致性)和 A(可用性)是可以保证的。但放弃 P 的同时也就意味着放弃了系统的扩展性, 也就是分布式节点受限, 没办法部署子节点, 这是违背分布式系统设计的初衷的。
CP without A: 如果不要求 A(可用), 相当于每个请求都需要在服务器之间保持强一致, 而 P(分区)会导致同步时间无限延长(也就是等待数据同步完才能正常访问服务), 一旦发生网络故障或者消息丢失等情况, 就要牺牲用户的体验, 等待所有数据全部一致了之后再让用户访问系统。设计成 CP 的系统其实不少, 最典型的就是分布式数据库, 如 Redis、HBase 等。对于这些分布式数据库来说, 数据的一致性是最基本的要求, 因为如果连这个标准都达不到, 那么直接采用关系型数据库就好, 没必要再浪费资源来部署分布式数据库。
AP wihtout C: 要高可用并允许分区, 则需放弃一致性。一旦分区发生, 节点之间可能会失去联系, 为了高可用, 每个节点只能用本地数据提供服务, 而这样会导致全局数据的不一致性。典型的应用就如某米的抢购手机场景, 可能前几秒你浏览商品的时候页面提示是有库存的, 当你选择完商品准备下单的时候, 系统提示你下单失败, 商品已售完。这其实就是先在 A(可用性)方面保证系统可以正常的服务, 然后在数据的一致性方面做了些牺牲, 虽然多少会影响一些用户体验, 但也不至于造成用户购物流程的严重阻塞。
现如今, 对于多数大型互联网应用的场景, 主机众多、部署分散, 而且现在的集群规模越来越大, 节点只会越来越多, 所以节点故障、网络故障是常态, 因此分区容错性也就成为了一个分布式系统必然要面对的问题。那么就只能在 C 和 A 之间进行取舍。但对于传统的项目就可能有所不同, 拿银行的转账系统来说, 涉及到金钱的对于数据一致性不能做出一丝的让步, C 必须保证, 出现网络故障的话, 宁可停止服务, 可以在 A 和 P 之间做取舍。
总而言之, 没有最好的策略, 好的系统应该是根据业务场景来进行架构设计的, 只有适合的才是最好的。
目前几乎很多大型网站及应用都是分布式部署的, 分布式场景中的数据一致性问题一直是一个比较重要的话题。分布式的 CAP 理论告诉我们 “任何一个分布式系统都无法同时满足一致性 (Consistency)、可用性(Availability) 和分区容错性(Partition tolerance), 最多只能同时满足两项。” 所以, 很多系统在设计之初就要对这三者做出取舍。在互联网领域的绝大多数的场景中, 都需要牺牲强一致性来换取系统的高可用性, 系统往往只需要保证 “最终一致性”, 只要这个最终时间是在用户可以接受的范围内即可。
在很多场景中, 我们为了保证数据的最终一致性, 需要很多的技术方案来支持, 比如分布式事务、分布式锁等。有的时候, 我们需要保证一个方法在同一时间内只能被同一个线程执行。在单机环境中, Java 中其实提供了很多并发处理相关的 API, 但是这些 API 在分布式场景中就无能为力了。也就是说单纯的 Java Api 并不能提供分布式锁的能力。所以针对分布式锁的实现目前有多种方案。
针对分布式锁的实现, 目前比较常用的有以下几种方案:
在分析这几种实现方案之前我们先来想一下, 我们需要的分布式锁应该是怎么样的? (这里以方法锁为例, 资源锁同理)
可以保证在分布式部署的应用集群中, 同一个方法在同一时间只能被一台机器上的一个线程执行。
这把锁要是一把可重入锁(避免死锁)
这把锁最好是一把阻塞锁(根据业务需求考虑要不要这条)
有高可用的获取锁和释放锁功能
获取锁和释放锁的性能要好
要实现分布式锁, 最简单的方式可能就是直接创建一张锁表, 然后通过操作该表中的数据来实现了。
当我们要锁住某个方法或资源时, 我们就在该表中增加一条记录, 想要释放锁的时候就删除这条记录。
创建这样一张数据库表:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-cmPg6Fxo-1670574512205)(https://hjs-1251193177.cos.ap-shanghai.myqcloud.com/distributed/distributed-lock-1.png)]
当我们想要锁住某个方法时, 执行以下 SQL:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-pqW6HSvc-1670574512205)(https://hjs-1251193177.cos.ap-shanghai.myqcloud.com/distributed/distributed-lock-2.png)]
因为我们对 method_name 做了唯一性约束, 这里如果有多个请求同时提交到数据库的话, 数据库会保证只有一个操作可以成功, 那么我们就可以认为操作成功的那个线程获得了该方法的锁, 可以执行方法体内容。
当方法执行完毕之后, 想要释放锁的话, 需要执行以下 Sql:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-4H17PI3p-1670574512206)(https://hjs-1251193177.cos.ap-shanghai.myqcloud.com/distributed/distributed-lock-3.png)]
上面这种简单的实现有以下几个问题:
这把锁强依赖数据库的可用性, 数据库是一个单点, 一旦数据库挂掉, 会导致业务系统不可用。
这把锁没有失效时间, 一旦解锁操作失败, 就会导致锁记录一直在数据库中, 其他线程无法再获得到锁。
这把锁只能是非阻塞的, 因为数据的 insert 操作, 一旦插入失败就会直接报错。没有获得锁的线程并不会进入排队队列, 要想再次获得锁就要再次触发获得锁操作。
这把锁是非重入的, 同一个线程在没有释放锁之前无法再次获得该锁。因为数据中数据已经存在了。
当然, 我们也可以有其他方式解决上面的问题。
除了可以通过增删操作数据表中的记录以外, 其实还可以借助数据中自带的锁来实现分布式的锁。
我们还用刚刚创建的那张数据库表。可以通过数据库的排他锁来实现分布式锁。 基于 MySql 的 InnoDB 引擎, 可以使用以下方法来实现加锁操作:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-LfWozDBs-1670574512206)(https://hjs-1251193177.cos.ap-shanghai.myqcloud.com/distributed/distributed-lock-4.png)]
在查询语句后面增加 for update, 数据库会在查询过程中给数据库表增加排他锁(这里再多提一句, InnoDB 引擎在加锁的时候, 只有通过索引进行检索的时候才会使用行级锁, 否则会使用表级锁。这里我们希望使用行级锁, 就要给 method_name 添加索引, 值得注意的是, 这个索引一定要创建成唯一索引, 否则会出现多个重载方法之间无法同时被访问的问题。重载方法的话建议把参数类型也加上。)。当某条记录被加上排他锁之后, 其他线程无法再在该行记录上增加排他锁。
我们可以认为获得排它锁的线程即可获得分布式锁, 当获取到锁之后, 可以执行方法的业务逻辑, 执行完方法之后, 再通过以下方法解锁:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-yTS5Aczc-1670574512206)(https://hjs-1251193177.cos.ap-shanghai.myqcloud.com/distributed/distributed-lock-5.png)]
通过 connection.commit()操作来释放锁。
这种方法可以有效的解决上面提到的无法释放锁和阻塞锁的问题。
但是还是无法直接解决数据库单点和可重入问题。
这里还可能存在另外一个问题, 虽然我们对 method_name 使用了唯一索引, 并且显示使用 for update 来使用行级锁。但是, MySql 会对查询进行优化, 即便在条件中使用了索引字段, 但是否使用索引来检索数据是由 MySQL 通过判断不同执行计划的代价来决定的, 如果 MySQL 认为全表扫效率更高, 比如对一些很小的表, 它就不会使用索引, 这种情况下 InnoDB 将使用表锁, 而不是行锁。如果发生这种情况就悲剧了。。。
还有一个问题, 就是我们要使用排他锁来进行分布式锁的 lock, 那么一个排他锁长时间不提交, 就会占用数据库连接。一旦类似的连接变得多了, 就可能把数据库连接池撑爆
总结一下使用数据库来实现分布式锁的方式, 这两种方式都是依赖数据库的一张表, 一种是通过表中的记录的存在情况确定当前是否有锁存在, 另外一种是通过数据库的排他锁来实现分布式锁。
数据库实现分布式锁的优点
直接借助数据库, 容易理解。
数据库实现分布式锁的缺点
会有各种各样的问题, 在解决问题的过程中会使整个方案变得越来越复杂。
操作数据库需要一定的开销, 性能问题需要考虑。
使用数据库的行级锁并不一定靠谱, 尤其是当我们的锁表并不大的时候。
相比较于基于数据库实现分布式锁的方案来说, 基于缓存来实现在性能方面会表现的更好一点。而且很多缓存是可以集群部署的, 可以解决单点问题。
目前有很多成熟的缓存产品, 包括 Redis, memcached 以及我们公司内部的 Tair。
这里以 Tair 为例来分析下使用缓存实现分布式锁的方案。关于 Redis 和 memcached 在网络上有很多相关的文章, 并且也有一些成熟的框架及算法可以直接使用。
基于 Tair 的实现分布式锁其实和 Redis 类似, 其中主要的实现方式是使用 TairManager.put 方法来实现。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-Qh8o3aB2-1670574512206)(https://hjs-1251193177.cos.ap-shanghai.myqcloud.com/distributed/distributed-lock-6.png)]
以上实现方式同样存在几个问题:
这把锁没有失效时间, 一旦解锁操作失败, 就会导致锁记录一直在 tair 中, 其他线程无法再获得到锁。
这把锁只能是非阻塞的, 无论成功还是失败都直接返回。
这把锁是非重入的, 一个线程获得锁之后, 在释放锁之前, 无法再次获得该锁, 因为使用到的 key 在 tair 中已经存在。无法再执行 put 操作。
当然, 同样有方式可以解决。
但是, 失效时间我设置多长时间为好? 如何设置的失效时间太短, 方法没等执行完, 锁就自动释放了, 那么就会产生并发问题。如果设置的时间太长, 其他获取锁的线程就可能要平白的多等一段时间。这个问题使用数据库实现分布式锁同样存在
可以使用缓存来代替数据库来实现分布式锁, 这个可以提供更好的性能, 同时, 很多缓存服务都是集群部署的, 可以避免单点问题。并且很多缓存服务都提供了可以用来实现分布式锁的方法, 比如 Tair 的 put 方法, redis 的 setnx 方法等。并且, 这些缓存服务也都提供了对数据的过期自动删除的支持, 可以直接设置超时时间来控制锁的释放。
使用缓存实现分布式锁的优点
性能好, 实现起来较为方便。
使用缓存实现分布式锁的缺点
通过超时时间来控制锁的失效时间并不是十分的靠谱。
基于 zookeeper 临时有序节点可以实现的分布式锁。
大致思想即为: 每个客户端对某个方法加锁时, 在 zookeeper 上的与该方法对应的指定节点的目录下, 生成一个唯一的瞬时有序节点。 判断是否获取锁的方式很简单, 只需要判断有序节点中序号最小的一个。 当释放锁的时候, 只需将这个瞬时节点删除即可。同时, 其可以避免服务宕机导致的锁无法释放, 而产生的死锁问题。
来看下 Zookeeper 能不能解决前面提到的问题。
锁无法释放? 使用 Zookeeper 可以有效的解决锁无法释放的问题, 因为在创建锁的时候, 客户端会在 ZK 中创建一个临时节点, 一旦客户端获取到锁之后突然挂掉(Session 连接断开), 那么这个临时节点就会自动删除掉。其他客户端就可以再次获得锁。
非阻塞锁? 使用 Zookeeper 可以实现阻塞的锁, 客户端可以通过在 ZK 中创建顺序节点, 并且在节点上绑定监听器, 一旦节点有变化, Zookeeper 会通知客户端, 客户端可以检查自己创建的节点是不是当前所有节点中序号最小的, 如果是, 那么自己就获取到锁, 便可以执行业务逻辑了。
不可重入? 使用 Zookeeper 也可以有效的解决不可重入的问题, 客户端在创建节点的时候, 把当前客户端的主机信息和线程信息直接写入到节点中, 下次想要获取锁的时候和当前最小的节点中的数据比对一下就可以了。如果和自己的信息一样, 那么自己直接获取到锁, 如果不一样就再创建一个临时的顺序节点, 参与排队。
单点问题? 使用 Zookeeper 可以有效的解决单点问题, ZK 是集群部署的, 只要集群中有半数以上的机器存活, 就可以对外提供服务。
可以直接使用 zookeeper 第三方库 Curator 客户端, 这个客户端中封装了一个可重入的锁服务。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-ZUgZC4vD-1670574512206)(https://hjs-1251193177.cos.ap-shanghai.myqcloud.com/distributed/distributed-lock-7.png)]
Curator 提供的 InterProcessMutex 是分布式锁的实现。acquire 方法用户获取锁, release 方法用于释放锁。
使用 ZK 实现的分布式锁好像完全符合了本文开头我们对一个分布式锁的所有期望。但是, 其实并不是, Zookeeper 实现的分布式锁其实存在一个缺点, 那就是性能上可能并没有缓存服务那么高。因为每次在创建锁和释放锁的过程中, 都要动态创建、销毁瞬时节点来实现锁功能。ZK 中创建和删除节点只能通过 Leader 服务器来执行, 然后将数据同不到所有的 Follower 机器上。
其实, 使用 Zookeeper 也有可能带来并发问题, 只是并不常见而已。考虑这样的情况, 由于网络抖动, 客户端可 ZK 集群的 session 连接断了, 那么 zk 以为客户端挂了, 就会删除临时节点, 这时候其他客户端就可以获取到分布式锁了。就可能产生并发问题。这个问题不常见是因为 zk 有重试机制, 一旦 zk 集群检测不到客户端的心跳, 就会重试, Curator 客户端支持多种重试策略。多次重试之后还不行的话才会删除临时节点。(所以, 选择一个合适的重试策略也比较重要, 要在锁的粒度和并发之间找一个平衡。)
使用 Zookeeper 实现分布式锁的优点
有效的解决单点问题, 不可重入问题, 非阻塞问题以及锁无法释放的问题。实现起来较为简单。
使用 Zookeeper 实现分布式锁的缺点
性能上不如使用缓存实现分布式锁。 需要对 ZK 的原理有所了解。
上面几种方式, 哪种方式都无法做到完美。就像 CAP 一样, 在复杂性、可靠性、性能等方面无法同时满足, 所以, 根据不同的应用场景选择最适合自己的才是王道。
数据库 > 缓存 > Zookeeper
Zookeeper >= 缓存 > 数据库
缓存 > Zookeeper >= 数据库
Zookeeper > 缓存 > 数据库
高可用(High Availability), 是当一台服务器停止服务后, 对于业务及用户毫无影响。 停止服务的原因可能由于网卡、路由器、机房、CPU 负载过高、内存溢出、自然灾害等不可预期的原因导致, 在很多时候也称单点问题。
这种通常是一台主机、一台或多台备机, 在正常情况下主机对外提供服务, 并把数据同步到备机, 当主机宕机后, 备机立刻开始服务。
Redis HA 中使用比较多的是 keepalived, 它使主机备机对外提供同一个虚拟 IP, 客户端通过虚拟 IP 进行数据操作, 正常期间主机一直对外提供服务, 宕机后 VIP 自动漂移到备机上。
优点是对客户端毫无影响, 仍然通过 VIP 操作。
缺点也很明显, 在绝大多数时间内备机是一直没使用, 被浪费着的。
这种采取一主多从的办法, 主从之间进行数据同步。 当 Master 宕机后, 通过选举算法 (Paxos、Raft) 从 slave 中选举出新 Master 继续对外提供服务, 主机恢复后以 slave 的身份重新加入。
主从另一个目的是进行读写分离, 这是当单机读写压力过高的一种通用型解决方案。 其主机的角色只提供写操作或少量的读, 把多余读请求通过负载均衡算法分流到单个或多个 slave 服务器上。
缺点是主机宕机后, Slave 虽然被选举成新 Master 了, 但对外提供的 IP 服务地址却发生变化了, 意味着会影响到客户端。 解决这种情况需要一些额外的工作, 在当主机地址发生变化后及时通知到客户端, 客户端收到新地址后, 使用新地址继续发送新请求。
无论是主备还是主从都牵扯到数据同步的问题, 这也分 2 种情况:
同步方式: 当主机收到客户端写操作后, 以同步方式把数据同步到从机上, 当从机也成功写入后, 主机才返回给客户端成功, 也称数据强一致性。 很显然这种方式性能会降低不少, 当从机很多时, 可以不用每台都同步, 主机同步某一台从机后, 从机再把数据分发同步到其他从机上, 这样提高主机性能分担同步压力。 在 redis 中是支持这杨配置的, 一台 master, 一台 slave, 同时这台 salve 又作为其他 slave 的 master。
异步方式: 主机接收到写操作后, 直接返回成功, 然后在后台用异步方式把数据同步到从机上。 这种同步性能比较好, 但无法保证数据的完整性, 比如在异步同步过程中主机突然宕机了, 也称这种方式为数据弱一致性。
Redis 主从同步采用的是异步方式, 因此会有少量丢数据的危险。还有种弱一致性的特例叫最终一致性, 这块详细内容可参见 CAP 原理及一致性模型。
keepalived 方案配置简单、人力成本小, 在数据量少、压力小的情况下推荐使用。 如果数据量比较大, 不希望过多浪费机器, 还希望在宕机后, 做一些自定义的措施, 比如报警、记日志、数据迁移等操作, 推荐使用主从方式, 因为和主从搭配的一般还有个管理监控中心。
宕机通知这块, 可以集成到客户端组件上, 也可单独抽离出来。 Redis 官方 Sentinel 支持故障自动转移、通知等, 详情见低成本高可用方案设计(四)。
逻辑图:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-jI6JbDfU-1670574512206)(https://hjs-1251193177.cos.ap-shanghai.myqcloud.com/distributed/distributed-high-availability-1.png)]
分布式(distributed), 是当业务量、数据量增加时, 可以通过任意增加减少服务器数量来解决问题。
至少部署两台 Redis 服务器构成一个小的集群, 主要有 2 个目的:
高可用性: 在主机挂掉后, 自动故障转移, 使前端服务对用户无影响。
读写分离: 将主机读压力分流到从机上。
可在客户端组件上实现负载均衡, 根据不同服务器的运行情况, 分担不同比例的读请求压力。
逻辑图:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-9NfkwjdZ-1670574512206)(https://hjs-1251193177.cos.ap-shanghai.myqcloud.com/distributed/distributed-high-availability-2.png)]
当缓存数据量不断增加时, 单机内存不够使用, 需要把数据切分不同部分, 分布到多台服务器上。
可在客户端对数据进行分片, 数据分片算法详见 C# 一致性 Hash 详解、C# 之虚拟桶分片。
逻辑图:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-cKxG3ud2-1670574512206)(https://hjs-1251193177.cos.ap-shanghai.myqcloud.com/distributed/distributed-high-availability-3.png)]
当数据量持续增加时, 应用可根据不同场景下的业务申请对应的分布式集群。 这块最关键的是缓存治理这块, 其中最重要的部分是加入了代理服务。 应用通过代理访问真实的 Redis 服务器进行读写, 这样做的好处是:
避免越来越多的客户端直接访问 Redis 服务器难以管理, 而造成风险。
在代理这一层可以做对应的安全措施, 比如限流、授权、分片。
避免客户端越来越多的逻辑代码, 不但臃肿升级还比较麻烦。
代理这层无状态的, 可任意扩展节点, 对于客户端来说, 访问代理跟访问单机 Redis 一样。
目前楼主公司使用的是客户端组件和代理两种方案并存, 因为通过代理会影响一定的性能。 代理这块对应的方案实现有 Twitter 的 Twemproxy 和豌豆荚的 codis。
逻辑图:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-1auxeXa5-1670574512206)(https://hjs-1251193177.cos.ap-shanghai.myqcloud.com/distributed/distributed-high-availability-4.png)]
分布式缓存再向后是云服务缓存, 对使用端完全屏蔽细节, 各应用自行申请大小、流量方案即可, 如淘宝 OCS 云服务缓存。
分布式缓存对应需要的实现组件有:
_ | redis | zookeeper | etcd |
---|---|---|---|
一致性算法 | 无 | paxos | raft |
CAP | AP | CP | CP |
高可用 | 主从 | N+1可用 | N+1可用 |
接口类型 | 客户端 | 客户端 | http/grpc |
实现 | setNX | createEphemeral | restful API |
管理后台的部署架构 (多台 tomcat 服务器 + redis【多台 tomcat 服务器访问一台 redis】+mysql【多台 tomcat 服务器访问一台服务器上的 mysql】) 就满足使用分布式锁的条件。多台服务器要访问 redis 全局缓存的资源, 如果不使用分布式锁就会出现问题。 看如下伪代码:
long N=0L;
//N 从 redis 获取值
if(N<5){
N++;
//N 写回 redis
}
上面的代码主要实现的功能:
从 redis 获取值 N, 对数值 N 进行边界检查, 自加 1, 然后 N 写回 redis 中。 这种应用场景很常见, 像秒杀, 全局递增 ID、IP 访问限制等。以 IP 访问限制来说, 恶意攻击者可能发起无限次访问, 并发量比较大, 分布式环境下对 N 的边界检查就不可靠, 因为从 redis 读的 N 可能已经是脏数据。传统的加锁的做法 (如 java 的 synchronized 和 Lock) 也没用, 因为这是分布式环境, 这个同步问题的救火队员也束手无策。在这危急存亡之秋, 分布式锁终于有用武之地了。
分布式锁可以基于很多种方式实现, 比如 zookeeper、redis…。不管哪种方式, 他的基本原理是不变的: 用一个状态值表示锁, 对锁的占用和释放通过状态值来标识。
这里主要讲如何用 redis 实现分布式锁。
Redis 为单进程单线程模式, 采用队列模式将并发访问变成串行访问, 且多客户端对 Redis 的连接并不存在竞争关系。redis 的 SETNX 命令可以方便的实现分布式锁。
语法:
SETNX key value
将 key 的值设为 value , 当且仅当 key 不存在。
若给定的 key 已经存在, 则 SETNX 不做任何动作。
SETNX 是『SET if Not eXists』(如果不存在, 则 SET)的简写
返回值:
例子:
redis> EXISTS job # job 不存在
(integer) 0
redis> SETNX job "programmer" # job 设置成功
(integer) 1
redis> SETNX job "code-farmer" # 尝试覆盖 job , 失败
(integer) 0
redis> GET job # 没有被覆盖
"programmer"
所以我们使用执行下面的命令
SETNX lock.foo
语法:
GETSET key value
将给定 key 的值设为 value , 并返回 key 的旧值(old value)。
当 key 存在但不是字符串类型时, 返回一个错误。
返回值:
语法:
GET key
返回值:
上面的锁定逻辑有一个问题: 如果一个持有锁的客户端失败或崩溃了不能释放锁, 该怎么解决?
我们可以通过锁的键对应的时间戳来判断这种情况是否发生了, 如果当前的时间已经大于 lock.foo 的值, 说明该锁已失效, 可以被重新使用。
发生这种情况时, 可不能简单的通过 DEL 来删除锁, 然后再 SETNX 一次(讲道理, 删除锁的操作应该是锁拥有这执行的, 这里只需要等它超时即可), 当多个客户端检测到锁超时后都会尝试去释放它, 这里就可能出现一个竞态条件, 让我们模拟一下这个场景:
C0 操作超时了, 但它还持有着锁, C1 和 C2 读取 lock.foo 检查时间戳, 先后发现超时了。
C1 发送 DEL lock.foo
C1 发送 SETNX lock.foo 并且成功了。
C2 发送 DEL lock.foo
C2 发送 SETNX lock.foo 并且成功了。
这样一来, C1, C2 都拿到了锁! 问题大了!
幸好这种问题是可以避免的, 让我们来看看 C3 这个客户端是怎样做的:
C3 发送 SETNX lock.foo 想要获得锁, 由于 C0 还持有锁, 所以 Redis 返回给 C3 一个 0
C3 发送 GET lock.foo 以检查锁是否超时了, 如果没超时, 则等待或重试。
反之, 如果已超时, C3 通过下面的操作来尝试获得锁:
GETSET lock.foo
通过 GETSET, C3 拿到的时间戳如果仍然是超时的, 那就说明, C3 如愿以偿拿到锁了。
如果在 C3 之前, 有个叫 C4 的客户端比 C3 快一步执行了上面的操作, 那么 C3 拿到的时间戳是个未超时的值, 这时, C3 没有如期获得锁, 需要再次等待或重试。留意一下, 尽管 C3 没拿到锁, 但它改写了 C4 设置的锁的超时值, 不过这一点非常微小的误差带来的影响可以忽略不计。
注意: 为了让分布式锁的算法更稳键些, 持有锁的客户端在解锁之前应该再检查一次自己的锁是否已经超时, 再去做 DEL 操作, 因为可能客户端因为某个耗时的操作而挂起, 操作完的时候锁因为超时已经被别人获得, 这时就不必解锁了。
如下面的方式, 把超时的交给 redis 处理:
lock(key, expireSec){
isSuccess = setnx key
if (isSuccess)
expire key expireSec
}
这种方式貌似没什么问题, 但是假如在 setnx 后, redis 崩溃了, expire 就没有执行, 结果就是死锁了。锁永远不会超时。
因为是分布式的环境下, 可以在前一个锁失效的时候, 有两个进程进入到锁超时的判断。如:
C0 超时了, 还持有锁, C1/C2 同时请求进入了方法里面
C1/C2 获取到了 C0 的超时时间
C1 使用 getSet 方法
C2 也执行了 getSet 方法
假如我们不加 oldValueStr.equals(currentValueStr) 的判断, 将会 C1/C2 都将获得锁, 加了之后, 能保证 C1 和 C2 只能一个能获得锁, 一个只能继续等待。
注意: 这里可能导致超时时间不是其原本的超时时间, C1 的超时时间可能被 C2 覆盖了, 但是他们相差的毫秒及其小, 这里忽略了。