PGPool-II 主要的三种模式是连接池,水平分库,查询负载均衡。pg_shard 的一些问题:无法支持事务完整性,不支持JSON操作。无法实现跨shard的约束。架构很新只有2年时间。试想一下,在一个RDBMS的数据库中,有一个表挂着数十台服务器还可以无限扩展。在2015年中国数据库技术大会上来自神州立诚科技的技...
详细解读 和小伙伴们一起来吐槽
Spring Cloud 基于Spring Boot 为我们提供了配置管理、服务发现、断路器、路由网关、负载均衡等我们在做分布式开发时常用问题的解决方案。
项目搭建过程:
在 Redis 3.0 之前,集群方案一般为两种:
3.0 之后官方提供了专有的集群方案 Redis Cluster。
将数据集分散到多个节点上,每个节点负责整体的一部分,即为数据分区。分区就会涉及到分区规则,Redis 常用的是哈希分区规则,哈希分区规则比较常见的有
也叫客户端分片(Smart Client)如下图所示,为一个客户端分区方案。
通过 sentinel 实现集群高可用,分区逻辑在客户端实现
优点是:分区逻辑可控。
缺点是:需要自己处理数据路由、高可用、故障转移等问题。
分区规则可用 节点取余 hash
节点取余方式优点:
节点取余方式缺点:
代理分区方案一般由中间件实现 例如早已开源的 Codis,下图是 Codis 的架构图:
codis-proxy 是无状态的,可以比较容易的搭多个实例,达到高可用性和横向扩展。
对 Java 用户来说,可以使用基于 Jedis 的实现 Jodis ,来实现 proxy 层的 HA:
这种方案有很多优点,因为支持原生 redis 协议,所以客户端不需要升级,对业务比较友好。并且升级相对平滑,可以起多个 Proxy 后,逐个进行升级。
Codis 是一个分布式 Redis 解决方案,对于上层的应用来说,连接到 Codis Proxy 和连接原生的 Redis Server 没有显著区别 (不支持的命令列表), 上层应用可以像使用单机的 Redis 一样使用,Codis 底层会处理请求的转发,不停机的数据迁移等工作,所有后边的一切事情,对于前面的客户端来说是透明的,可以简单的认为后边连接的是一个内存无限大的 Redis 服务。
但是缺点是,因为会多一次跳转,会有性能开销。
这里我们再讨论另外一种 分区规则:一致性 hash 算法
上面讨论的节点取余分区方式的主要缺点是:数据节点伸缩时,导致数据迁移
,换句话说,当缓存服务器数量发生变化时,可能会导致大量缓存同一时间失效,几乎所有缓存的位置都会发生改变。 所以 迁移数量和添加节点数据有关,建议翻倍扩容
一致性 hash 算法在一定程度上解决了这个问题,它的实现思路是:为系统中每个节点分配一个 token, 范围是 0 到 2 的 32 次方,这些 token 构成一个哈希环,如下图所示。
每一个数据节点分配一个 token 范围值,这个节点就负责保存这个范围内的数据。数据读写执行节点查找操作时,先根据 key 计算 hash 值,然后顺时针找到第一个大于等于该哈希值的 token 节点(沿顺时针方向遇到的第一个服务器)。
优点:服务器的数量如果发生改变,并不是所有缓存都会失效,而是只有部分缓存会失效
缺点:
上述缺点中第二、三点尤其重要,原因是缓存分布的极度不均匀(负载不均衡),这种情况被称之为 hash 环的偏斜
应该怎样防止 hash 环的偏斜呢?一致性 hash 算法中使用“虚拟节点”解决了这个问题。
“虚拟节点”是”实际节点”(实际的物理服务器)在 hash 环上的复制品,一个实际节点可以对应多个虚拟节点。
例如:我们以 2 个副本 NodeA、NodeB 为例,为每台服务器计算三个虚拟节点,于是可以分别计算 “Node A#1”、“Node A#2”、“Node A#3”、“Node B#1”、“Node B#2”、“Node B#3”的哈希值,于是形成六个虚拟节点:
当然,如果你需要,也可以虚拟出更多的虚拟节点。引入虚拟节点的概念后,缓存的分布就均衡多了。hash 环上的节点就越多,缓存被均匀分布的概率就越大。
在简介 Redis Cluster 之前,先聊一聊它采用的分区规则,即虚拟槽分区
Redis Cluster 采用虚拟槽分区,所有的键根据哈希函数映射到 0~16383
个整数槽内,计算公式 slot = CRC16(key) & 16383
,每个节点负责维护一部分槽以及槽所映射的键值数据。采用大范围槽的主要目的是为了方便数据拆分和集群扩展
当前集群有 5 个节点,每个节点平均大约负责 3276 个槽。由于采用高质量的哈希算法,每个槽所映射的数据通常比较均匀,将数据平均划分到 5 个节点进行数据分区。
虚拟槽分区特点:
集群由 N 组主从 Redis Instance 组成。主可以没有从,但是没有从 意味着主宕机后主负责的 Slot 读写服务不可用。一个主可以有多个从,主宕机时,某个从会被提升为主,具体哪个从被提升为主,协议类似于 Raft。
如何检测主宕机?
Redis Cluster 采用 quorum+心跳的机制。从节点的角度看,节点会定期给其他所有的节点发送 Ping,cluster-node-timeout(可配置,秒级)时间内没有收到对方的回复,则单方面认为对端节点宕机,将该节点标为 PFAIL 状态。通过节点之间交换信息收集到 quorum 个节点都认为这个节点为 PFAIL,则将该节点标记为 FAIL,并且将其发送给其他所有节点,其他所有节点收到后立即认为该节点宕机。从这里可以看出,主宕机后,至少 cluster-node-timeout 时间内该主所负责的 Slot 的读写服务不可用。
与 Sentinal 的区别?
CAP是 Consistency、Availability、Partition tolerance三个词语的缩写,分别表示一致性、可用性、分区容忍性
Consistency 一致性
代表数据在任何时刻、任何分布式节点中所看到的都是符合预期的。写操作后的读操作可以读取到最新的数据状态,当数据分布在多个节点上,从任意结点读取到的数据都是最新的状态。
Availability 可用性
可用性是指任何事务操作都可以得到响应结果,且不会出现响应超时或响应错误。
Partition tolerance 分区容忍性
通常分布式系统的各各结点部署在不同的子网,这就是网络分区,不可避免的会出现由于网络问题而导致结点之间通信失败,此时仍可对外提供服务,这叫分区容忍性。
总结:
一个分布式系统最多只能同时满足一致性(Consistency)、可用性(Availability)和分区容忍性(Partition tolerance)这三项中的两项。它可以作为我们进行架构设计、技术选型的考量标准。对于多数大型互联网应用的场景,结点众多、部署分散,而且现在的集群规模越来越大,所以节点故障、网络故障是常态,而且要保证服务可用性达到N个9(99.99…%),并要达到良好的响应性能来提高用户体验,因此一般都会做出如下选择:保证P和A,舍弃C强一致,保证最终一致性。
1. 理解强一致性和最终一致性
CAP理论告诉我们一个分布式系统最多只能同时满足一致性(Consistency)、可用性(Availability)和分区容忍性(Partition tolerance)这三项中的两项,其中AP在实际应用中较多,AP即舍弃一致性,保证可用性和分区容忍性,但是在实际生产中很多场景都要实现一致性,比如前边我们举的例子主数据库向从数据库同步数据,即使不要一致性,但是最终也要将数据同步成功来保证数据一致,这种一致性和CAP中的一致性不同,CAP中的一致性要求在任何时间查询每个结点数据都必须一致,它强调的是强一致性,但是最终一致性是允许可以在一段时间内每个结点的数据不一致,但是经过一段时间每个结点的数据必须一致,它强调的是最终数据的一致性。
2. BASE 理论介绍
BASE 是 Basically Available(基本可用)、Soft state(软状态)和 Eventually consistent (最终一致性)三个短语的缩写。BASE理论是对CAP中AP的一个扩展,通过牺牲强一致性来获得可用性,当出现故障允许部分不可用但要保证核心功能可用,允许数据在一段时间内是不一致的,但最终达到一致状态。满足BASE理论的事务,我们称之为“柔性事务”。
Raft 是一种一致性协议,相对于Paxos 相对简单一些。
主要分为3个子问题解决:
Raft的所有节点分为三种状态,Leader、Follower 和 Candidate。
如何触发选举
选举流程
Raft 的日志记录了操作内容,每一个模块的数据结构是一个 entry,包括三个部分。
复制过程
其实就类似于一个二阶段提交的过程。
分布式锁的基本原理,就是向同一个地方获取锁,如果能获取则可以继续访问。
使用 Redis 分布式锁的基本,就是将 Redis 中使用 SET 命令存放一个一个key,使用这个命令时,库中没有该键则插入成功,有的话则返回失败,意味着没有占到锁。
一般使用该命令进行操作,设置 SET 一个键值 NX 表示原库中没有则加入成功。并且可以原子性的设置过期时间。
SET key value [EX seconds] [PX milliseconds] [NX|XX]
设置过期时间是因为,如果加锁成功之后服务器宕机,则无法删除锁造成死锁,所以要设置过期时间。
对于的 Java 描述如下:
String value = UUIDUtil.uuid() +Thread.currentThread().getName();
redisTemplate.opsForValue().setIfAbsent(KEY, value , 10, TimeUnit.SECONDS);
解锁的时候,需要先检查是否是自己的锁,如果是则删除。
但是以下这种方式,显然是错误的,因为获取值,比较和删除,这三个操作不是原子操作,可能在获取和比较的时候是当前 value 但是删除的时候,已经改变了。
String lockValue = (String) redisTemplate.opsForValue().get(KEY);
if (lockValue.equals(value)) {
redisTemplate.delete(KEY);
}
所以要通过 Redis 和 LUA 脚本进行一个原子操作。Redis 官网也演示了该解锁脚本:可以添加两个参数,一个是 KEYS[1] 表示想要删除的键,ARGV[1] 表示如果该键对应的 value 是这个参数的值才进行删除。
正确的删除写法:
String script =
"if redis.call('get',KEYS[1]) == ARGV[1]" +
"then" +
"return redis.cal1 ('del',KEYS[1])" +
"else" +
"return 0" +
"end";
/**
* 传入的参数 1. RedisScript<T> script 构造一个 DefaultRedisScript 传入执行的脚本和返回值类型。
* 2. List<K> keys, 代表脚本中的 KEYS[1] 参数,是一个链表,表示删除的键
* 3. Object... args,代表 ARGV[1] 参数,是一个动态数组和 keys 一一对应,表示要删除的值
*/
redisTemplate.execute(new DefaultRedisScript<Long>(script, Long.class),
Arrays.asList(KEY),
value);
这种方法也存在问题,就是当执行业务时间很长的情况下,锁会过期,会导致多个进程进入,并且锁也不能重入。
Redisson 相当于实现了分布式环境下的JUC。
使用可以参照官方文档:https://github.com/redisson/redisson/
public void test() {
RLock lock = redissonClient.getLock(KEY);
try {
lock.lock();
// ...
}catch (Exception e) {
e.printStackTrace();
}finally {
lock.lock();
}
}
public interface RLock extends Lock
lock( ) 方法
<T> RFuture<T> tryLockInnerAsync(long waitTime, long leaseTime, TimeUnit unit, long threadId, RedisStrictCommand<T> command) {
internalLockLeaseTime = unit.toMillis(leaseTime);
return evalWriteAsync(getName(), LongCodec.INSTANCE, command,
"if (redis.call('exists', KEYS[1]) == 0) then " +
"redis.call('hincrby', KEYS[1], ARGV[2], 1); " +
"redis.call('pexpire', KEYS[1], ARGV[1]); " +
"return nil; " +
"end; " +
"if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then " +
"redis.call('hincrby', KEYS[1], ARGV[2], 1); " +
"redis.call('pexpire', KEYS[1], ARGV[1]); " +
"return nil; " +
"end; " +
"return redis.call('pttl', KEYS[1]);",
Collections.singletonList(getName()), internalLockLeaseTime, getLockName(threadId));
}
private <T> RFuture<Long> tryAcquireAsync(long waitTime, long leaseTime, TimeUnit unit, long threadId) {
// 指定超时时间则走这个if,也就是直接设置一个超时时间,不会续期
if (leaseTime != -1) {
return tryLockInnerAsync(waitTime, leaseTime, unit, threadId, RedisCommands.EVAL_LONG);
}
// 没指定超时时间就通过 getLockWatchdogTimeout() 获取超时时间
// 也就是 private long lockWatchdogTimeout = 30 * 1000; 【30s】
// 然后通过 Redis LUA 脚本设置30s的过期时间
RFuture<Long> ttlRemainingFuture = tryLockInnerAsync(waitTime,
commandExecutor.getConnectionManager().getCfg().getLockWatchdogTimeout(),
TimeUnit.MILLISECONDS, threadId, RedisCommands.EVAL_LONG);
ttlRemainingFuture.onComplete((ttlRemaining, e) -> {
if (e != null) {
return;
}
// 如果时间到期了,会执行该方法
// 该方法中又主要有一个 renewExpiration();方法
// 这个方法会创建一个 TimeTask 定时任务,每 internalLockLeaseTime / 3 【10s】进行一次,给该锁续期。
if (ttlRemaining == null) {
scheduleExpirationRenewal(threadId);
}
});
return ttlRemainingFuture;
}
unlock( ) 方法
就是简单的运行一个异步任务,使用 LUA 脚本删除该键值对。
另外,Redisson 还实现了很多JUC包下的组件,例如 ReadWriteLock,CountDownLatch,Semaphore等,这些组件原本在 jdk 中采用AQS,在分布式环境中就用 Redis 的键值对代替了原本的 state 变量,另外,因为采用LUA脚本所以能保证操作的原子性。
Redis 实现分布式锁,主要就是让所有进程都去同一个地方抢占锁,如果抢到就能继续执行程序。
利用 ZooKeeper 实现分布式锁的方式和 Redis 类似,在 Zookeeper 中加入相同前缀的临时顺序节点。
如果是顺序最小的节点,则可以获取锁,如果不是,则注册Watcher,监听比自己序号小的节点,如果序号小的节点删除,则监听他的节点可以被唤醒获取锁。
import org.apache.curator.framework.CuratorFramework;
import org.apache.curator.framework.CuratorFrameworkFactory;
import org.apache.curator.framework.api.CuratorEvent;
import org.apache.curator.framework.api.CuratorEventType;
import org.apache.curator.framework.api.CuratorListener;
import org.apache.curator.retry.ExponentialBackoffRetry;
import org.apache.zookeeper.WatchedEvent;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.env.Environment;
/**
* @Description
* @Date 2021/9/10 20:18
* @author: A.iguodala
*/
@Configuration
public class CuratorFrameworkConfig {
/**
* 创建操作 Zookeeper 客户端框架
* @return
*/
@Bean
public CuratorFramework curatorFramework() {
// ExponentialBackoffRetry是种重连策略,每次重连的间隔会越来越长,1000毫秒是初始化的间隔时间,3代表尝试重连次数。
ExponentialBackoffRetry retry = new ExponentialBackoffRetry(1000, 3);
// 创建client
CuratorFramework curatorFramework = CuratorFrameworkFactory.newClient("121.196.166.231:2181", retry);
// 添加watched 监听器
curatorFramework.getCuratorListenable().addListener(new CuratorListener() {
@Override
public void eventReceived(CuratorFramework curatorFramework, CuratorEvent curatorEvent) throws Exception {
CuratorEventType type = curatorEvent.getType();
if (type == CuratorEventType.WATCHED) {
WatchedEvent watchedEvent = curatorEvent.getWatchedEvent();
String path = watchedEvent.getPath();
System.out.println(watchedEvent.getType() + " -- " + path);
// 重新设置改节点监听
if (null != path) {
curatorFramework.checkExists().watched().forPath(path);
}
}
}
});
curatorFramework.start();
return curatorFramework;
}
}
import lombok.extern.slf4j.Slf4j;
import org.apache.curator.framework.CuratorFramework;
import org.apache.curator.framework.recipes.locks.InterProcessSemaphoreMutex;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
/**
* @Description
* @Date 2021/9/10 20:36
* @author: A.iguodala
*/
@RestController
@Slf4j
public class LockTestController {
/**
* 加锁节点
*/
private final String lockPath = "/lock/test";
/**
* 操作 Zookeeper 客户端
*/
@Autowired
private CuratorFramework curatorFramework;
@GetMapping("/test01")
public String test() {
// 创建锁
InterProcessSemaphoreMutex lock = new InterProcessSemaphoreMutex(curatorFramework, lockPath);
try {
// 获取锁
lock.acquire();
} catch (Exception e) {
e.printStackTrace();
}finally {
try {
lock.release();
} catch (Exception e) {
e.printStackTrace();
}
}
return "OK";
}
}
在分布式系统中,各个节点之间在物理上相互独立,通过网络进行沟通和协调。由于存在事务机制,可以保证每个独立节点上的数据操作可以满足ACID。但是,相互独立的节点之间无法准确的知道其他节点中的事务执行情况。所以不知道该事务到底应该提交还是回滚。常规的解决办法就是引入一个事务协调器的组件来统一调度所有分布式节点的执行。
二阶段提交的算法思路可以概括为:执行事务程序将操作成败通知事务管理器,再由管理器根据所有参与事务者的反馈情况决定各参与者是否要提交操作还是混滚操作。
两阶段分为:
准备阶段
提交阶段
缺点:
同步阻塞
:执行过程中,所有参与节点都是事务阻塞型的。单点故障
:由于事务管理器十分重要,如果在执行过程中,事务管理器宕机,那么每个节点的事务就会一直阻塞。数据不一致
:如果在事务管理器发送提交请求之后,由于网络原因没有到达某个事务参与者,则该事务就没有提交数据而造成的数据的不一致。三阶段提交主要就是对二阶段提交的改进,主要改动了两个方面:
三阶段提交主要分为三个阶段:
CanCommit阶段
:
PreCommit阶段
doCommit阶段
Seata 主要有三个组件:
TC - 事务协调者
TM - 事务管理器
RM - 资源管理器
大致工作流程:
AT 模式使用
AT 即,auto, 自动事务提交回滚的模式。只需要在总方法上加上一个 @GlobalTransactional
注解就能完成需求。
CREATE TABLE `undo_log` (
`id` bigint(20) NOT NULL AUTO_INCREMENT,
`branch_id` bigint(20) NOT NULL,
`xid` varchar(100) NOT NULL,
`context` varchar(128) NOT NULL,
`rollback_info` longblob NOT NULL,
`log_status` int(11) NOT NULL,
`log_created` datetime NOT NULL,
`log_modified` datetime NOT NULL,
`ext` varchar(100) DEFAULT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `ux_undo_log` (`xid`,`branch_id`)
) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8;
下载 seata 和修改配置导入依赖。
让 seata 代理自己的数据源
import com.zaxxer.hikari.HikariDataSource;
import io.seata.rm.datasource.DataSourceProxy;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.autoconfigure.jdbc.DataSourceProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.util.StringUtils;
import javax.sql.DataSource;
/**
* @Description
* @Date 2021/9/2 13:54
* @author: A.iguodala
*/
@Configuration
public class SeataConfig {
/**
* 首先获取到数据源的默认配置信息
*/
@Autowired
private DataSourceProperties dataSourceProperties;
@Bean
public DataSource dataSource() {
// 构造对应数据源的DataSource
HikariDataSource dataSource = dataSourceProperties.initializeDataSourceBuilder().type(HikariDataSource.class).build();
if (StringUtils.hasText(dataSourceProperties.getName())) {
dataSource.setPoolName(dataSourceProperties.getName());
}
// 返回包装后的代理对象
return new DataSourceProxy(dataSource);
}
}
TCC 是另一种常见的分布式事务机制,它是“Try-Confirm-Cancel”三个单词的缩写。
就是 3PC 三阶段提交的一种具体实现。
Try
:尝试执行阶段,完成所有业务可执行性的检查(保障一致性),并且预留好全部需用到的业务资源(保障隔离性)。Confirm
:确认执行阶段,不进行任何业务检查,直接使用 Try 阶段准备的资源来完成业务处理。Confirm 阶段可能会重复执行,因此本阶段所执行的操作需要具备幂等性。Cancel
:如果发生异常或者需要回滚,则取消执行阶段,释放 Try 阶段预留的业务资源。Cancel 阶段可能会重复执行,也需要满足幂等性。后两个阶段都是必须成功的阶段,所以在失败后会进行重试,所以要保证幂等性。
SAGA 事务主要是为了解决 TCC 事务的业务侵入性很强的问题,例如在美团点了外卖想使用支付宝付款,但是支付宝不可能让美团对其代码进行侵入,所以 try 阶段可能就无法实施。
SAGA 模式将一个大事务差分成很多个小事务,并且通过补偿的机制来代替回滚:
正向恢复(Forward Recovery)
:如果 Ti事务提交失败,则一直对 Ti进行重试,直至成功为止(最大努力交付)。这种恢复方式不需要补偿,适用于事务最终都要成功的场景,譬如在别人的银行账号中扣了款,就一定要给别人发货。正向恢复的执行模式为:T1,T2,…,Ti(失败),Ti(重试)…,Ti+1,…,Tn。反向恢复(Backward Recovery)
:如果 Ti事务提交失败,则一直执行 Ci对 Ti进行补偿,直至成功为止(最大努力交付)。这里要求 Ci 必须(在持续重试后)执行成功。反向恢复的执行模式为:T1,T2,…,Ti(失败),Ci(补偿),…,C2,C1。可靠消息最终一致性方案是指当事务发起方执行完成本地事务后并发出一条消息,事务参与方(消息消费者)一定能够接收消息并处理事务成功,此方案强调的是只要消息发给事务参与方最终事务要达到一致。一般采用消息中间件来完成。
例如,商品消费扣款的操作和生成订单的操作:(两个操作的运行顺序通常安排成最容易出错的最先进行,可以减少执行次数和占用资源。)
分布式系统中,每个本地事务可以保证自己的ACID,但是对于其他事务的执行情况是不可知的,所以需要分布式事务的解决方案,一般会采用加入一个事务协调器来进行统一协调。
具体的解决方案主要包括:2PC
、3PC
、TCC
、SAGA
和 可靠事件队列
等方式实现。
接幂等性就是用户对于同一操作发起的一次请求或者多次请求的结果是一致的,不会因为多次点击而产生了副作用。比如说支付场景,用户购买了商品支付扣款成功,但是返回结果的时候网络异常,此时钱已经扣了,用户再次点击按钮,此时会进行第二次扣款,返回结果成功,用户查询余额返发现多扣钱了,流水记录也变成了两条,这就没有保证接口的幂等性。
通过分析哪些业务是存在幂等问题的,就需要在执行业务之前获取令牌,服务器将令牌保存在 Redis 中,第一次调用时,会删除该令牌,之后的操作发现 Redis 中已经不存在该令牌则直接返回,典型的该机制实现就是验证码。
对于令牌的删除应该采用先删除令牌再执行逻辑的顺序,因为如果先执行业务,则可能造成多个请求都验证通过而执行业务,另外,令牌从 Redis 的取,比较,删除三个操作应该是原子操作。所以应该采用LUA脚本来实现。
if redis.call('get',KEYS[1]) == ARGV[1]
then
return redis.call('del', KEYS[1])
else
return 0
end
数据库锁
分布式锁
唯一约束
防重表
全局请求唯一ID
具体使用哪种要根据具体的业务具体判断。
参考:
PGPool-II 主要的三种模式是连接池,水平分库,查询负载均衡。pg_shard 的一些问题:无法支持事务完整性,不支持JSON操作。无法实现跨shard的约束。架构很新只有2年时间。试想一下,在一个RDBMS的数据库中,有一个表挂着数十台服务器还可以无限扩展。在2015年中国数据库技术大会上来自神州立诚科技的技...
详细解读 和小伙伴们一起来吐槽
转载于:https://my.oschina.net/u/856019/blog/420176