分布式
内容管理
分布式锁解决方案 — 乐观锁和悲观锁的选择、tomcat并发、缓存一致、基于Zookeeper实现分布式锁、高并发抢红包系统复盘
还要加快一点,时间来不及了… redis、 mq、锁🔒redisson等技术重构cfeng.net 成为一个可以分布式部署的项目
乐观锁和悲观锁 选择
前面的分布式锁分别使用了数据库级别的乐观锁、悲观锁和redis实现,谈了实现,这里cfeng补充一点乐观锁、悲观锁的选择问题,解释他们各自的问题(当然基本的乐观锁适合读多写少,悲观锁适合写多读少基本的都know】
首先最基本的需要明确: 乐观锁是无锁结构,悲观锁是一种思想,加上显式的锁解决冲突问题,比如锁代码块的java提供的synchronized,锁数据的数据库提供的行锁、表锁、X锁、S锁等
乐观锁 —海量写请求大量失败
乐观锁的两种基本实现 CAS (ABA还是要version才能解决)和 version版本号机制,本身是没有加锁的,就像之前Cfeng的提现请求(重复提交问题),当然可以很好解决,也就是只有最先到达的就更新成功,同时操作的系统的线程就可能失败
而如果是并发秒杀, 1000个不同的用户秒杀100件商品,如果不是使用提前到redis缓存中处理减库存,直接在数据库中操作,使用Jmeter测试,如果加上乐观锁,虽然可以防止超卖,但是售出的远远不到100个 【explain: 如果同时有10个线程查询到版本号为1,其中一个线程更新成功,变为2,那么其余9个都失败了,所以1/10的请求有效】 大量的请求都返回抢购失败,影响用户体验,也影响商家(没卖完)
所以乐观锁适合的是并发量较小的、修改少的传统系统,可以防止几个人同时更改数据, 乐观锁因为无锁,不需要额外的锁操作开销,同时也可以提高并发量,吞吐量
需要注意,乐观锁为无锁,使用乐观锁需要注意不要乱用数据库事务@Transactional, 比如mysql默认的READ_REPEATEABALE,本身就调用了相关的数据库锁; 所以要么就使用最低的事务隔离级别read_uncommitted; 或者不使用事务,去掉@Transactional
同时解释事务和 锁的关系: 事务是一种粗粒度的对于数据库表的并发限制,保证数据操作正确,但是不能保证业务操作正确,事务ACID
悲观锁 ---- 海量请求 单线程依次执行 响应慢
悲观锁无论是代码级别的锁,还是数据库排他锁for update,都是只能单线程操作,当前线程操作的时候,其他线程都是不能操作的,处于阻塞状态,这样可能让后面的请求进行长时间的阻塞等待,在用户视角就是一直等待页面响应(explain:如果同时1000个线程同时操作一个悲观锁加持的片段,获取到释放需要20ms,那可能需要等待1000 * 20 = 20s,长时间的阻塞等待、假死】,所以需要使用redis缓存加快访问速度
悲观锁主要就是主键情况下加行锁排他锁锁数据,保证同时只有一个线程更新,为了提高用户体验,可以在请求达到后台时,进行限流,比如让请求全部进入rabbitMQ,逐步被操作; 或者可以使用令牌桶进行限流
令牌桶限流
rabbitMQ限流的方式就是Producer将所有的请求发送给消费者逐步处理, 而令牌桶算法就是防止阻塞,进行流量限制,让流量均匀发送。
大小固定的令牌桶以恒定的速率不断产生令牌,如果令牌没有消耗,或者小于产生的速率,那么令牌就会将桶填满,再产生的令牌就会从桶中溢出,保持最大量有限
而在项目中,可以直接借助Guava提供的RateLimiter实现令牌桶限流接口
Guava是Google开源工具类,提供了限流工具类RateLimiter进行令牌桶算法方式的限流,可以直接引入依赖,使用
<dependency>
<groupId>com.google.guava</groupId>
<artifactId>guava</artifactId>
<version>${guava.version}</version>
</dependency>
之后在后台的请求接口中使用RateLimiter进行限流
@RequestMapping("/account")
public class UserAccountController {
private final UseAccountService accountService;
//令牌桶方式限流,这里借助工具类,每s放行10个请求
RateLimiter rateLimiter = RateLimiter.create(10);
//方便测试,这里就直接使用参数的方式接收
@GetMapping("/getMoney")
public ResponseEntity<String> getMoney(UserAccountDto userAccountDto) {
//阻塞式获取令牌
// log.info("等待时间" + rateLimiter.acquire());
//非阻塞式获取令牌
if(!rateLimiter.tryAcquire(1000, TimeUnit.MILLISECONDS)) {
log.warn("被限流了,直接返回失败,说明没库存了");
return new ResponseEntity<>("获取数据失败,被限流了",HttpStatus.OK);
}
if(Objects.isNull(userAccountDto.getAccount()) || Objects.isNull(userAccountDto.getUserId())) {
return new ResponseEntity<>("参数错误",HttpStatus.BAD_REQUEST);
}
try {
// accountService.takeMoney(userAccountDto);
// accountService.takeMoneyWithVersion(userAccountDto);
// accountService.takeMoneyWithLock(userAccountDto);
// accountService.takeMoneyWithRedisLock(userAccountDto);
accountService.takeMoneyWithZookeeper(userAccountDto);
return new ResponseEntity<>("提现成功", HttpStatus.OK);
} catch (Exception e) {
return new ResponseEntity<>("出现错误",HttpStatus.NOT_FOUND);
}
}
}
RateLimiter rateLimiter = RateLimiter.create(10);
初始化令牌桶类,每s放行10个请求, 而请求获取 令牌也有两种方式:
- 阻塞式获取令牌: 请求线程进入后,如果令牌桶内没有足够多的令牌,就在此处阻塞,等待令牌的发放
- 非阻塞式获取令牌: 请求线程进入后,如果令牌桶没有足够的令牌,会尝试等待设置好的时间(这里1000ms),如果还是没有拿到令牌,那么直接返回请求失败(因为前面已经发行了足够多的请求进入服务参与秒杀, 这些请求就算进入都是失败的),如果timeout设置0,那么就式阻塞式
【相关的原理源码后续看情况解释】
测试发现1000个线程/1s,在配合Zookeeper分布式锁 + RateLimiter接口限流 (虽然因为机器原因还是5%的请求挂了),大部分请求直接在controller限流位置非阻塞位置返回,没有进入service执行,最终只有一个请求执行成功
Zookeeper注册中心 intro
分布式架构项目 — 系统拆分、独立部署、服务模块解耦;随之而来的分布式事务、数据一致性、分布式架构下的并发安全问题; 除了可以利用数据库级别的乐观、悲观锁;以及借助redis原子性SETNX实现分布式锁,还可以利用注册中心Zookeeper实现分布式锁
Zookeeper的功能特性:
- 顺序一致性: 从同一个客户端发起的请求,最终严格按照顺序应用到ZooKeeper
- 原子性: 所有的事务请求结果在整个集群所有机器上的应用情况一致,整个集群中所有机器的状态一致
- 单一系统映像:ZooKeeper集群中每一台ZooKeeper机器中数据模型相同(冗余)
- 可靠性 : 一旦更改被应用,更改结果就会持久化,直到下一次覆盖
Zookeeper是 分布式的服务协调中间件(注意其他的分布式服务minIO、rabbitMQ),采用同一的协作方式管理各个子系统(之前Cfeng的博客简单提过RPC框架Dubbo服务发现), 最终使分布式系统像一个动物园一样
节点: Zookeeper中节点分为 构成集群的机器的 机器节点 以及 数据模型中的数据单元的数据节点; 不管是机器节点还是数据节点都是ZNode
ZooKeeper将所有的数据存储在内存中,最终的数据模型就构成了一颗树,和linux的文件系统一样,构成一棵文件树,节点 对应的就是路径 ; 每个节点保存着相关的信息
临时节点: 生命周期和客户端会话绑定在一起,客户端会话失效,节点就会删除,创建ZNode可以带上标识(整型数字)
持久节点: ZNode被创建了,除非主动移除ZNode,否则就一直都在Zookeeper,和客户端会话没有关系
Watcher监听器: 事件监听器,ZooKeeper允许用户在指定的ZNode创建监听事件,一旦事件触发,Zookeeper就会通知到相关的Client
Zookeeper是高可用的,Zookeeper集群,一般部署奇数个实例,当其中leader挂掉,就会重新选举产生新的Leader【所以Zookeeper和Redis有很多相似之处,包括分布式锁】
Zookeeper作为 典型的分布式数据一致性的解决方案,可以基于ZooKeeper实现数据发布/订阅、负载均衡、命名服务、分布式协调通知、集群管理、分布式锁、分布式队列
统一配置管理: 每一个子系统都需要的配置文件统一放置到Zookeeper的ZNode节点
统一命名服务: 通过ZNode进行统一命名,各个子系统可以通过名字获取到节点上相应的资源
分布式锁: 通过创建与该 共享资源 相关的 顺序临时节点 与 动态监听机制, 控制多线程对共享资源的并发访问 ( 临时节点是核心)
集群状态: 可以动态感知节点的CRUD,保证集群下 的相关节点的主、副本的一致性
Zookeeper的典型应用场景就是配合Dubbo作为注册中心; 服务生产者将子集提供的服务注册到ZooKeeper中心,服务消费者进行服务调用时先在Zookeeper中查找服务,获取到服务生产者的详细信息后,在去调用服务生产者提供的接口
|==========--------------> 注册中心Zookeeper <-- 发布暴露服务,接口方法签名--------------------|
消费者 <-----------------异步通知-| 直接通过HTTP调用,或者RPC远程协议(DUBBO) 生产者(注册发布服务系统)
【订阅调用服务系统】 |
| |
=============================> 监控器 <======================================================
虽然目前使用SpringCloud较多,但是Dubbo也还是有可用之处,后期Cfeng可能会分享基于Dubbo重构微服务项目(不使用SpringCloud)
SpringBoot整合ZooKeeper
整合Zookeeper首先需要安装Zookeeper,直接到官网下载即可,如果是Windows版本,那么就直接安装到本地,点击bin下埋你的zkServer.cmd就可以运行ZooKeeper服务端 【和redis-Windows类似,mongoDB需要以命令行方式指定dbpath启动】
首先需要引入相关的依赖
<!-- zookeeper依赖 还有curator框架-->
<dependency>
<groupId>org.apache.zookeeper</groupId>
<artifactId>zookeeper</artifactId>
<version>3.6.2</version>
<exclusions>
<exclusion>
<groupId>org.slf4j</groupId>
<artifactId>slf4j-log4j12</artifactId>
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>org.apache.curator</groupId>
<artifactId>curator-framework</artifactId>
<version>4.2.0</version>
</dependency>
<dependency>
<groupId>org.apache.curator</groupId>
<artifactId>curator-recipes</artifactId>
<version>4.2.0</version>
</dependency>
springboot要使用zookeeper服务,也是通过网络进行连接该服务,引入依赖之后,就在yml中配置相关的依赖项
#### zookeeper的配置 -- 主机和命名空间, 命名空间用于区分不同的机器
zk:
host: 127.0.0.1:2181
namespace: cfengHost
这里是自动配置的,高度封装的Curator框架,使用该框架实例可以解决底层的问题: 重连、反复注册、NodeExistsException
在SpringBoot项目中,可以自定义注入CuratorFramework实例 对Zookeeper进行相应的操作,创建一个Zookeeper的配置类管理配置
//这里也是使用Environment对象来获取配置文件中的内容
* 自定义配置CuratorFramework对象用来实现Zookeeper的快速操作使用
*/
@Configuration
@RequiredArgsConstructor
public class ZooKeeperConfig {
private final Environment environment;
/**
* 自定义注入Bean-Zookeeper高度封装的客户端Curator实例
* 采用工厂模式进行创建,指定客户端连接到Zookeeper服务端的策略: 采用重试机制 5次,每次间隔1s 100ms
*/
@Bean
public CuratorFramework curatorFramework() {
CuratorFramework curatorFramework = CuratorFrameworkFactory.builder().connectString(Objects.requireNonNull(environment.getProperty("zk.host"))).namespace(Objects.requireNonNull(environment.getProperty("zk.namespace")))
.retryPolicy(new RetryNTimes(5,1000)).build();
curatorFramework.start();
return curatorFramework;
}
}
配置Zookeeper操作对象CuratorWork就可以借助该对象进行节点的各种操作完成Zookeeper的功能
启动项目,可以看到ZooKeeper的相关的日志信息
rg.apache.zookeeper.ZooKeeper : Client environment:zookeeper.version=3.6.2--803c7f1a12f85978cb049af5e4ef23bd8b688715, built on 09/04/2020 12:44 GMT
2022-09-21 15:29:55.690 INFO 9176 --- [ main] org.apache.zookeeper.ZooKeeper : Client environment:host.name=DESKTOP-4A4BD0R
2022-09-21 15:29:55.690 INFO 9176 --- [ main] org.apache.zookeeper.ZooKeeper : Client environment:java.version=16
Zookeeper分布式锁
Zookeeper作为注册中心进行服务发布、订阅,还可以利用创建 与 共享资源 相关的 顺序临时节点, 采用Zookeeper提供的监听器Watcher机制,控制多线程的共享资源的并发访问
ZooKeeper架构通过冗余实现高可用性,当其中一个Zookeeper无应答,就询问另外一个主机,Zookeeper将数据存储在一个分层的命名空间(类似linux的文件系统),类似前缀树结构,客户端可以在节点进行读取
其实可以按照redis的实现思路来理解,核心过程:
- 操作共享资源前加上锁 ----- 创建临时顺序节点,利用Watcher监听器不断监听到最小序号的节点
- 获取锁成功就可以进行操作
- 操作完成 当前线程释放锁 — 删除对应的最小序号的临时节点,让其他的节点序号变为最小,其对应的线程获取到锁 【可重入、高可用、不死锁、互斥、公平】
在zookeeper指定节点locks下面创建临时节点node_n
获取locks下面的所有的子节点children
对子节点按照自增序号从小到大排序
判断本节点是否为第一个节点(序号最小),是则获取锁,不是就监听Watcher比其小节点的删除时间
若删除事件发生,回到前面重新判断,直到获取到锁
【所以和redis一样存在一个等待队列,所以是可重入的】
核心就是创建临时顺序节点和采用Watcher机制监听临时节点的增减和删除
而在具体实现中,还可以直接使用ZooKeeper的InterProcessMutex(进程间 互斥)实现分布式锁【 InterProcessMutext为公平的可重入锁】
InterProcessMutex 不依赖JVM,所以可以用作分布式锁
//基于zookeeper实现分布式锁
@Override
public void takeMoneyWithZookeeper(UserAccountDto userAccountDto) throws Exception {
//主要依赖的是ZooKeeper的InterProcessMutex类进程互斥
//Znode的节点路径
final String pathPrefix = "/middleware/zkLock";
//创建Zookeeper的进程互斥锁实例,尝试获取锁的最长的事件为20s,这里创建和资源相关的锁
InterProcessMutex mutex = new InterProcessMutex(zookeeperClient,pathPrefix + userAccountDto.getUserId() + "-lock");
try {
//当前线程对应的节点尝试获取锁,获取的最大等待时间20s
if(mutex.acquire(10L,TimeUnit.SECONDS)) {
//当前线程获取到锁
UserAccount userAccount = userAccountMapper.selectByUserId(userAccountDto.getUserId());
if(!Objects.isNull(userAccount) && userAccount.getAmount().doubleValue() > userAccountDto.getAccount()) {
//提现
int res = userAccountMapper.updateAmount(userAccountDto.getAccount(),userAccountDto.getUserId());
if(res > 0) {
//提现成功
UserAccountRecord record = new UserAccountRecord();
record.setCreateTime(LocalDateTime.now());
record.setAccountId(userAccount.getId());
record.setMoney(BigDecimal.valueOf(userAccountDto.getAccount()));
accountRecordMapper.insert(record);
log.info("zookeeper分布式锁 -当前待提现金额为:{},账户的余额为: {}, 成功", userAccountDto.getAccount(), userAccount.getAmount());
//成功后结束
} else {
throw new Exception("更新出现异常");
}
} else {
throw new Exception("账户余额不足");
}
} else {
throw new Exception("获取分布式锁失败Zookeeper");
}
} catch (Exception e) {
throw e;
} finally {
//为了保证不死锁,不管正常还是异常,都需要释放锁
mutex.release();
}
}
这里最关键首先就是创建一个Mutext节点,使用当前节点获取锁,获取到锁之后使用之后一定要正常释放,使用finally即可,和redis类似
ZooKeeper的分布式锁主要就是创建节点ZNode,所以这种方式具有时间开销,需要耗费一定时间进行节点的CRUD, 这种开销可以通过集群部署多个ZooKeeper缓解
高并发抢红包使用Zookeeper分布式锁
之前高并发抢红包为了避免同一个用户抢到多个红包,使用redis的SETNX作为分布式锁,这里可以再次使用ZooKeeper分布式锁来代替
为了能够高效的进行相应,红包生成后是直接放在缓存中,异步方式将相关记录计入数据库
/**
* 抢红包核心业务,需要首先在缓存中查找红包并且进行记录,抢到红包后需要调用DetailService写入数据库
* @param userId
* @param redId
* @return
* @throws Exception
*/
@Override
public BigDecimal rob(Integer userId, String redId) throws Exception {
//Redis操作组件
ValueOperations valueOperations = redisTemplate.opsForValue();
//抢红包前先判断是否抢过红包,如果已经抢过了,就直接返回红包金额,前台显示即可 redId : userID : rob 这里是抢到的ID
Object object = valueOperations.get(redId + ":" + userId + ":rob");
if(object != null) {
return new BigDecimal(object.toString());
}
//点红包 --- 判断缓存系统中是否有红包剩余,> 0
Boolean res = clik(redId);
//有红包则进入拆红包业务逻辑
if(res) {
//分布式锁,每一个人一次只能抢到一次红包,一对一关系
// final String lockKey = redId + ":" + userId + "-lock";
final String lockKey = "/middleware/zkLock/" + redId + "-" + userId + "-lock";
InterProcessMutex mutex = new InterProcessMutex(zookeeperClient,lockKey);
//从小红包列表中拆一个红包
//调用setIfAbsent方法,实现分布式锁,也就是这里pop是只能一次弹一个
// Boolean lock = valueOperations.setIfAbsent(lockKey,redId);
//设置分布式锁过期时间24小时
// redisTemplate.expire(lockKey,24L,TimeUnit.HOURS);
try {
//判断当前线程是否获取到了锁
// if(lock) {
//这里采用zookeeper获取锁
if(mutex.acquire(10L,TimeUnit.SECONDS)) {
Object value = redisTemplate.opsForList().rightPop(redId);
//如果红包金额不为空,说明有红包,有钱
if(value != null) {
//抢到一个,总数减少
String redTotalKey = redId + ":total";
//获取当前总数,如果为空则总数为0, 同时更新; 但这里的操作不是但命令,不具有原子性,会出现安全问题,需要修改
// Integer currentTotal = valueOperations.get(redTotalKey) != null ? (Integer)valueOperations.get(redTotalKey) : 0;
// valueOperations.set(redTotalKey,currentTotal - 1);
valueOperations.increment(redTotalKey,-1);
//处理红包金额为元
BigDecimal result = new BigDecimal(value.toString()).divide(new BigDecimal(100));
//抢到红包的信息记录进入数据库
redDetailService.recordRobedPacket(userId,redId,new BigDecimal(value.toString()));
//当前用户抢过红包了,使用Key进行标识,设置过期时间为1天
valueOperations.set(redId + ":" + userId + ":rob",result,24L, TimeUnit.HOURS);
log.info("当前用户抢到红包了:剩余红包数量:{},userId={} key={} 金额={}",valueOperations.get(redTotalKey),userId,redId,result);
return result;
}
}
} catch (Exception e) {
throw new Exception("加分布式锁失败");
} finally {
//分布式锁必须释放
// if(Objects.equals(redId,valueOperations.get(lockKey))) {
// //释放当前线程的redis的锁key
// redisTemplate.delete(lockKey);
// }
mutex.release();
}
}
//null表示当前用户没有抢到红包
return null;
}
/**
* 点红包业务逻辑: 判断缓存系统是否有红包
*/
private Boolean clik(String redId) throws Exception {
ValueOperations valueOperations = redisTemplate.opsForValue();
//获取红包总数 key:total
String redTotalKey = redId + ":total";
//获取剩余个数
Object total = valueOperations.get(redTotalKey);
//判断剩余个数是否大于0
if(total != null && Integer.valueOf(total.toString()) > 0) {
//还有红包
return true;
}
return false;
}
当然实际的项目中,第一次查询的时候会先将库存加载到缓存中
Tomcat并发量有限制
这里发起13000线程/1s, 出现的结果是部分线程报错:
org.apache.http.conn.HttpHostConnectException: Connect to 127.0.0.1:8081 [/127.0.0.1] failed: Connection refused: connect
at org.apache.http.impl.conn.DefaultHttpClientConnectionOperator.connect(DefaultHttpClientConnectionOperator.java:156)
只有一半的请求请求成功,虽然那一半的请求确实也是正确的(10个人抢到了红包),且符合使用缓存的情况,1453个请求每秒 ,比不使用缓存性能提升了好几倍
而至于为什么一半的请求全部挂掉了,就是因为SpringBoot内置的Tomcat 最大并发数有限(Tomcat本身也是一个应用,需要考虑它能够抗住高并发),不然请求挂在Tomcat,进入不了应用也白忙活了【RabbitMQ也有心无力,都没有进入】
可以修改yml配置文件,手动”天真的“ 设置最大并发数和最大连接数,手动调优【因为确实还是扛不住】 — 所以这里就引出 进一步的处理方式 ---- Nginx控制流量
server:
tomcat:
threads:
max: 13000 #tomcat可承受最大并发数,默认200 【再大可能机器扛不住】
max-connections: 13000 #最大连接数,默认8972
事实证明,这也确实很天真,再次发起请求,还是崩了,Error的达到了59%,吞吐量还是1400左右,还是只有少部分请求请求成功,一个Tomcat扛不住,搭建集群还是不可能抗住上百万的流量,所以Nginx必须限流 — 保证到达Tomcat容器的流量在可承受范围内
再给个解决方案: Cache aside
缓存与数据库双写一致性
缓存虽然确实可以提高访问速度、增大系统吞吐量,降低数据库压力,保证应用平稳运行,但是也可能带来问题, 其中一个问题: 数据库和缓存的一致性
延时双删: 先淘汰缓存、写数据库,休眠1s,再次淘汰Cache; 【可以删除1s内缓存的脏数据】
后面具体遇到强一致性业务场景再详解🎄