【分布式】基于Zookeeper实现分布式锁、秒杀问题复盘

分布式


分布式锁解决方案 — 乐观锁和悲观锁的选择、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的实现思路来理解,核心过程:

  1. 操作共享资源前加上锁 ----- 创建临时顺序节点,利用Watcher监听器不断监听到最小序号的节点
  2. 获取锁成功就可以进行操作
  3. 操作完成 当前线程释放锁 — 删除对应的最小序号的临时节点,让其他的节点序号变为最小,其对应的线程获取到锁 【可重入、高可用、不死锁、互斥、公平】

在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内缓存的脏数据】

后面具体遇到强一致性业务场景再详解🎄

评论 5
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值