【分布式】分布式锁解决方案介绍、DBMS级别乐观、悲观、redis的SETNX实现分布式锁

分布式


分布式锁解决方案 介绍 – 基于数据库级别乐观、悲观锁实现、基于Redis实现


RabbitMQ作为高可用的分布式消息中间件,可以在模块解耦 【比如之前的用户登录的日志处理】、 接口限流(流量削峰)【请求不直接到达业务逻辑,而是由消息队列直接接收巨量的请求, 在消费者端再进行相关的业务逻辑】、异步通信、死信队列 等方面发挥重要作用,并且RabbitMQ节点本身是可以构成集群的

而为了更好的处理当前环境分布式的中间件的配置,为了保证分布式环境下的并发安全,需要引入分布式锁的解决方案

分布式锁intro

当前高并发、大数据量的时代,企业级应用大部分都是采用集群和分布式的方式进行部署,将 业务高度集中的传统企业级应用按照业务拆分为多个子系统,独立部署, 为了更好应对高并发【千万级流量,需要负载均衡,一个子系统部署多个实例(集群),均衡分摊前端的请求,应用、服务、文件都是分开的】, 但是高并发情况下的数据不一致问题就需要解决, 解决方案就是分布式锁🔒

传统的单机应用【Cfeng.net也是分布式思想开发的单机应用 — 随时可以分布式部署】,并发访问、操作共享资源,通常是通过加上同步锁实现, 但是分布式架构应用就不能使用这种方式了,因为单机应用 最多只是 单JVM进程多线程访问 — JVM的锁机制就可以保证线程的原子性、可见性、有序性; 但是分布式应用为 多JVM进程访问,【一台机器对应的一个独立的JVM进程】,传统锁机制失效【依赖JVM的monitor】

应用场景

分布式锁可以控制并发线程访问共享资源,在分布式架构项目中应用广泛,对共享资源加锁,达到互斥排他性质,当操作共享资源之前,需要先对该资源加锁,使用完之后再解锁

重复提交

前端用户注册的时候,用户疯狂点击按钮【 当然前台解决办法为点击后变灰】,导致提交了多次重复的、相同的用户信息到后台,后台相关接口如果没有处理并发,多个线程同时操作数据库,插入了多条相同的记录

A、B、C线程同时到达查询用户是否存在逻辑,不存在,则都进入了写入数据库操作【之前的高并发抢红包也是一个人抢多个红包的处理】,几个线程同时执行导致错误

                 开始
                 |
                 输入用户名  -- 疯狂点击--> 多个请求
                 						  |
                 						  后端
=============================================================
  接收信息 ---》 查询用户名 ----> 用户存在? --yes ---> 结束
  					|			|no
  					|		将信息插入数据库
  				    |			   | 
  					-------  数据库表

所以查询用户是否存在和 插入用户信息到数据库应该是一个完整的综合操作,分布式情况下,应该对这个综合操作加上分布式锁,保证同一时刻只有一个线程获取到锁,并且执行完成之后释放锁🔒

高并发抢XXX

RabbitMq可以再高并发秒杀操作中进行接口限流、异步解耦,从而实现高并发限流和流量削峰,由RabbitMQ进行承受大流量,除了大流量,最主要的也是安全问题: 比如超卖或者超买

                 开始
                 |
                 多个用户  -- 疯狂点击--> ,抢购,产生多个请求
                 						  |
                 						  后端
=============================================================
  接收信息并校验 ---》 获取库存 ----> 库存充足? --no ---> 结束
  					|			   |yes
  					|		 当前用户抢到
  					|              |
  					|            更新库存 -1
  				    |			   | 
  					-------  数据库表

可以分析该业务流程是没有问题的,主要就是判断库存,充足就减库存,多线程高并发操作可变变量库存,就会存在安全问题: 超卖就是两个线程同时读取库存,比如都是1,所以他们就meads都抢到了,丢失了其中一个修改,导致多卖了一份,所以需要使用分布式锁保证数据的一致性

传统的同步锁解决方案 — synchronized【non-final】

单体应用时代,高并发下多线程访问共享资源出现数据不一致,通常使用JDK提供的关键字synchronized — 同步互斥【1.6前为重量级锁】, 关于并发、并行的区别这里就不提示了,关键就是操作的是否为共享资源

共享资源 — 被多个线程、或者多个进程访问操作的数据或者代码块; 按照数据库级别的并发理解,主要就是数据的写操作会出现安全(如果都是read不会有任何问题), 读后写,写后写,写后读 都会存在一定的安全问题 【项目中多线程操作 有状态的Bean就会出现安全问题】, 状态也就是可修改的成员变量

这里可以以一个简单的场景来验证synchronized的使用: 对同一个数据Count, 一个线程执行10次加1操作,一个线程执行10次减1操

 * 验证传统的synchronized关键字的使用 同步锁的使用, 当然单机应用的高并发解决方案还有AQS、JUC等
 */

@Slf4j
public class ThreadTest {

    //这里就模拟传统的线程不安全的操作, 需要注意volatile不能保证原子性,只能保证可见性
    public static void main(String[] args) {
        //同时开启两个线程,一个对amount 连续加10次,一个减少 10 次; 按理结果不变,还是10
        Thread threadAdd = new Thread(new LockThread(10));

        Thread threadSub = new Thread(new LockThread(-10));

        //让两线程就绪
        threadAdd.start();
        threadSub.start();
    }


    //为了方便,直接使用内部类
    static class LockThread implements Runnable {

        //状态
        private  Integer count;

        public LockThread(int count) {
            this.count = count;
        }

        @Override
        public void run() {
            //该线程就是修改count的值
            for(int i = 0; i < 10; i ++) {
                //这里需要读取修改数据,所以加上对象锁
                synchronized (SysConstant.amount) {
                    SysConstant.amount = SysConstant.amount + count;
                    //打印现在的结果
                    log.info("现在Amount的值为: {}",SysConstant.amount);
                }
            }
        }
    }

    //定义一个内部类常量类
    static class SysConstant {
        //amount初始值为10, 这里不要设置为final,因为需要修改这个值
        //这里需要使用包装类型,因为synchronized为对象锁,锁的是对象
        public static Integer amount = 10;
    }
}

查看结果,除了最终结果不对,连同一线程连续执行都会出现问题

15:03:09.263 [Thread-0] INFO cfengMiddleware.server.ThreadTest - 现在Amount的值为: 20
15:03:09.270 [Thread-0] INFO cfengMiddleware.server.ThreadTest - 现在Amount的值为: 20
    ..........
15:07:37.547 [Thread-1] INFO cfengMiddleware.server.ThreadTest - 现在Amount的值为: 10
15:07:37.547 [Thread-0] INFO cfengMiddleware.server.ThreadTest - 现在Amount的值为: 20

最终的结果出现了错误,很容易理解,因为Cpu时间随机分配,当一个线程读取amout之后,还没有来得及修改,就失去了CPU,另外一个线程读取的数据还是之前未修改数据

这里的共享资源就是 amout,因为没有加锁,同一时间多个线程进入(切换很快,感知不到),同时获取了数据,导致了数据的不一致

这里就采用最easy的方式,加上同步锁🔒synchronized,加上之后就可以保证当一个线程获取到锁之后,其他的线程只能继续等待,当然是可重入的,释放之后,再进入; 为了不影响并行,所以锁的粒度要精确,这里就给amout加上锁即可 【对象锁】

  @Override
        public void run() {
            //该线程就是修改count的值
            for(int i = 0; i < 10; i ++) {
                //这里需要读取修改数据,所以加上对象锁, 只能同时一个线程获取该锁
                synchronized (SysConstant.amount) {
                    SysConstant.amount = SysConstant.amount + count;
                    //打印现在的结果
                    log.info("现在Amount的值为: {}",SysConstant.amount);
                }
            }
        }

加锁后数据就是正常的【但是这里有点问题,继续看】

15:18:35.177 [Thread-1] INFO cfengMiddleware.server.ThreadTest - 现在Amount的值为: 10

这里再解答一下为什么会出现这样的情况👇

15:18:35.169 [Thread-1] INFO cfengMiddleware.server.ThreadTest - 现在Amount的值为: 10
15:18:35.169 [Thread-0] INFO cfengMiddleware.server.ThreadTest - 现在Amount的值为: 20
15:18:35.176 [Thread-0] INFO cfengMiddleware.server.ThreadTest - 现在Amount的值为: 20
15:18:35.176 [Thread-0] INFO cfengMiddleware.server.ThreadTest - 现在Amount的值为: 30
15:18:35.176 [Thread-1] INFO cfengMiddleware.server.ThreadTest - 现在Amount的值为: 20

观察test代码, synchronized锁的对象为Sysconstant.amout, 这是一个static的可变变量; Synchronization on a non-final field 'SysConstant.amount“; — 这里就出现Warning

对一个变化的对象加锁,如果这个变量的引用发生了改变,不同的线程可能锁定不同的对象,都会成功获得各自的锁。

这个时候synchronized会失效

注意,锁的是对象,一个固定的对象对应的一个同步对象锁,这里以amout为变量,每次执行对象发生了变化呢

为什么修改之后对象会变化,这是因为包装类型和String类型一样,是final修饰的

public final class Integer extends Number

当修改之后,是产生了一个新的对象,不是原本的对象,所以其实每个线程获取了各自的锁,并没有真正达到同步互斥的效果, 所以上面其实是加锁失败的

所以这里定义一个final修饰的对象当作🔒来包裹,而为了减少对象创建消耗,将amout和count转为基本类型int

@Override
        public void run() {
            //该线程就是修改count的值
            for(int i = 0; i < 10; i ++) {
                //这里需要读取修改数据,所以加上对象锁, 只能同时一个线程获取该锁
                synchronized (SysConstant.LOCK) {
                    SysConstant.amount = SysConstant.amount + count;
                    //打印现在的结果
                    log.info("现在Amount的值为: {}",SysConstant.amount);
                }

            }
        }

        //定义一个内部类常量类
        static class SysConstant {
            //amount初始值为10, 这里不要设置为final,因为需要修改这个值
            public static int amount = 10;

            //设置加锁的对象,必须为静态的,只是当作锁对象使用,无意义
            public final static Integer LOCK = 1;
        }
    }

这样就OK了,同步块中同时只能一个线程执行

但是不管是Synchronized还是JDK提供的Lock类,控制并发线程对于共享资源的访问,只适用于单体应用【单一部署】,对于分布式部署的服务,不够,因为此类型锁依赖应用系统所在JDK, 都是JVM进程控制的

分布式锁方案

分布式锁是一种锁机制、解决方案,分布式锁是指在分布式部署的环境下,通过锁机制让多个进程、多个客户端互斥地对共享资源进行访问, 分布式锁解决方案的基本要求:

  • 排它性: 单体应用时代的锁的基本要求, 被共享的资源在同一时刻只能被一台机器上面的一个线程执行
  • 避免死锁: 死锁也是锁机制的重要问题,互相等待资源不释放; 所以必须要求当线程获取到🔒后,经过一段时间(执行业务逻辑)之后,一定要释放锁(正常、异常情况)
  • 高可用: 获取锁、释放锁的机制必须高可用且性能佳
  • 可重入: 可重入锁means 当前机器的当前线程在彼时没有获取到锁( 获取失败),那么等待一段时间后,一定要保证可以再次获取到锁
  • 公平锁: 不同机器的不同线程获取锁的几率最好是均等的,并发线程公平获取到锁

分布式锁方案的基本要求: 高可用、可重入、排他性、公平、避免死锁

分布式锁成熟的实现方案有很多,下面主要介绍:

* 基于数据库级别的乐观锁

* 基于数据库级别的悲观锁

* 基于Redis的原子操作 ---- SETNX 和 EXPIRE 原子操作

* 基于Zookeeper的互斥排他锁 --- 通过创建临时的有序节点 + Wather机制

* 基于Rdession的分布式锁
  • 乐观锁 【乐观锁和数据库本身的三段锁协议不同的就是乐观悲观是we主观控制】: 乐观锁 ----- 乐观人为并发冲突少,手动控制并发操作权限,并发操作不必每次都加锁,不采用DBMS本身的锁机制, 而是使用程序实现, 适合读多写少的场景, 程序实现就是操作共享资源时加 一个标识version, 使用version控制每次的操作
  • 悲观锁:【认为并发冲突多,写多读少, 持悲观态度】 使用DBMS本身的锁机制, 以InnoDB为例, 在查询之前加 For Update, 表示该记录被当前线程锁住(行级,表级), 只有线程 操作完成,其他线程才能获取
  • 基于Redis的原子性实现: Redis的SETNX和EXPIRE命令实现,SETNX表示只有Key在Redis中不存在才设置成功,设置为与共享资源有关系,作为lock,通过EXPIRE释放锁
  • 基于Zookeeper的互斥锁: Zookeeper在指定的表示字符串【也和共享资源有关系,间接】下维护一个临时有序的列表NodeList,保证同一时刻并发线程访问共享资源时只能有一个最小序号的节点, 该节点对应的线程操作共享资源

基于DBMS实现分布式锁

基于DBMS的乐观锁和悲观锁都可以实现分布式锁

乐观锁: 使用无锁结构,不加显式的锁,而是通过version或者时间戳对比保证有序性,这种方式阻止不了其他程序对数据库数据直接更新,(因为是在程序中进行的对比操作),如果版本号不一致就回滚整个过程重来 , 但是这里Cfeng介绍的程序是”不可重入的“(重入是锁的概念,乐观锁是无锁);如果线程没有获取到资源就永久结束了

悲观锁: 适合写多读少【因为会线程阻塞会响应变慢】,总认为会发生数据冲突,会提前上锁,(饿汉式加锁方式),悲观锁具有强烈的排他性和独占性,会导致线程阻塞,影响执行效率(安全性更高) 典型的悲观锁就是独占锁、重量级锁 synchronized ---- 加锁和解锁自动进行,但是不灵活,一旦线程获取不到锁的资源,就会一直等待,死锁; ReentrantLock: 较灵活的锁,加锁和解锁手动完成,可以响应终端,手动设置等待时长

死锁: 多个并发的进程(线程也可,数据库级别事务也可, 一个线程同一时刻只能执行一个事务,两个是不同的概念)争夺共享资源产生的相互等待的现象; 原因就是系统资源有限并且推进的顺序不合理

比如有资源A、B,进程A,B,执行过程加上独占锁,A获取顺序A,B, 进程B获取资源顺序B,A, 这样就产生了等待队列{Pa, Pb}, A需要获得资源B,但是不会释放A,(DB的两端锁协议不能解决死锁),进程B请求A,但是不释放B,相互等待,死锁

从上可以看出死锁的四个必要条件

  1. 互斥:共享资源一次只能一个进程(线程、T)访问,直到释放
  2. ​ 请求与保持: 一个进程在请求资源的同时,保持所占有的资源(不会释放)直到显性释放
  3. 不可剥夺 : 其余进程占有的资源,只能由占有者释放,不能被其他进程剥夺
  4. 环路等待: 两个以及以上的进程(线…)构成一个等待链,形成环路,都在等待后者释放资源

死锁的处理方法

  1. 鸵鸟策略: 假装没看到,Linux等操作系统都是忽略的,因为发生进程死锁的概率极低,【进程通信共享内存信号量出错才…】
  2. 死锁预防 ------ 破坏必要条件:
    • 破环请求保持 ---- 运行前一次性申请所有的资源(会让其他的Process饥饿)、运行过程中逐步释放保持的资源再请求
    • 破坏 不可剥夺 — 在请求资源时必须释放所有资源(代价大,不简易,延长周期、影响吞吐量)
    • 破坏 环路等待: 可以给资源编号,P按照编号顺序请求资源、有序请求
    • 破坏互斥条件: 不推荐,实现复杂,eg假脱机打印机技术允许多P同时输出,真正输出的为守护P
  3. 死锁避免 — 使用前alg判断,只允许不死锁P申请资源
    • 如果一个进程的请求会导致死锁,则不启动进程
    • 如果一个进程增加资源请求会导致死锁,则拒绝申请
    • 银行家算法具体实现死锁避免 【每个进程最开始都不被标记,执行过程可能被标记,当算法结束时,任何没有被标记的进程都是死锁进程: 寻找一个没有标记的进程Pi,请求资源小于A,如果找到,将C矩阵第i行向量加入A,标记,转回1,如果没有,算法中止
  4. 死锁检测与恢复 — 检测运行中系统出现死锁,进行恢复
    • 允许系统死锁(不advise,处理代价大)
    • 抢占恢复: 从一个或者多个进程抢占足够多资源分配给死锁进程,解决死锁
    • rollback撤销恢复: 撤销之前的操作,恢复死锁前的状态
    • 杀死进程恢复: 终止系统的一个或者多个死锁进程,直到解除死锁

乐观锁

乐观锁🔒就是programmer持乐观态度,主观认为并发冲突很小,不采用数据库本身的锁协议【X锁、S锁、各种锁协议】,而是使用程序的方式进行控制 ---- 其中的一种实现方式就是version版本号

同时CAS(CompareAndSwep)也是乐观锁的一种实现,通过循环判断资源的访问情况,包含3个操作数【主存值、old原值,new新值】,主要就是从主存获取old原址,计算new值与其比较,如果相同就修改,不相同不处理【和version类似】,AtomicInteger和AtomicReference

每次从数据库中获取数据时总认为不会有其他的线程对数据进行修改,也就是不会不可重复度或者读脏,因此不会上锁,但是在更新时会判断其他线程在之前有没有对数据进行修改, 使用version记录

版本号Version机制: 线程取出数据记录,会同时取出Version,最后更新Version时,这个version会作为更新条件,更新成功后,会将Version ++, 而其他同时获取到该数据记录的线程 因为Version发生改变,数据更新失败,避免多线程访问共享数据出现数据不一致

update table set key=value,version = version + 1 where id = #{id} and version = #{version}

在这里插入图片描述

可以看到当多个线程企图同时修改数据时,都会携带verison = 1作为查询条件,更新条件也为verison =1; 但是如果线程A修改了数据,version ++; 那么线程2就不能再修改数据

获取数据时需要将version取出,再更新数据时需要以version作为匹配条件,同时version + 1, version趋势递增

这里将按照乐观锁的理论实现一个具体的场景,就是查询一个Amount,PC端应用,当用户账户有余额的时候,点击提现申请就可以进入提现余额的申请界面,输入提现的金额和提现的账户,点击提现就可以提现到账户

                 开始
                 |
                 用户多次点击提现  
                 |            |
                线程1        线程2		
                 		后端
=============================================================
  接收信息校验 ---》 获取账户余额 ----> 余额充足? --no ---> 结束
  					|			   |yes
  					|		   可以提现
  					|              |
  					|            更新账户余额Amount
  				    |			   | 
  					-------  数据库表

当用户多次点击按钮(可能恶意),可能出现同一用户信息的多个发起提现的请求,正常情况会获取用户余额,判断如果充足则提现,但是如果用户明知自己没有余额,多次点击,可能出现账户余额变为负数

这里简单演示demo, 用户的账户表需要携带一个version字段用来实现乐观锁

DROP TABLE IF EXISTS `user_account`;
CREATE TABLE `user_account` (
  `id` int(11) NOT NULL AUTO_INCREMENT COMMENT '主键',
  `user_id` int(11) NOT NULL COMMENT '用户账户id',
  `amount` decimal(10,4) NOT NULL COMMENT '账户余额',
  `version` int(11) DEFAULT '1' COMMENT '版本号字段',
  `is_active` tinyint(11) DEFAULT '1' COMMENT '是否有效(1=是;0=否)',
  PRIMARY KEY (`id`),
  UNIQUE KEY `idx_user_id` (`user_id`) USING BTREE
) ENGINE=InnoDB AUTO_INCREMENT=7 DEFAULT CHARSET=utf8 COMMENT='用户账户表';

DROP TABLE IF EXISTS `user_account_record`;
CREATE TABLE `user_account_record` (
  `id` int(11) NOT NULL AUTO_INCREMENT COMMENT '主键',
  `account_id` int(11) NOT NULL COMMENT '账户表主键id',
  `money` decimal(10,4) DEFAULT NULL COMMENT '提现成功时记录的金额',
  `create_time` datetime DEFAULT NULL,
  PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=422 DEFAULT CHARSET=utf8 COMMENT='用户每次提现时的金额记录表';

INSERT INTO `db_middleware`.`user_account` (`id`, `user_id`, `amount`, `version`, `is_active`) VALUES ('1', '10012766', '100', '1', '1');

之后使用逆向工程生成entity和mapper,这里乐观锁查询更新手动写sql

<!-- 根据id更新-->
    <update id="updateAmount">
        update user_account set amount = amount - #{money} where id = #{id} and acount > 0 and (amount - #{money}) > 0
    </update

<!-- 根据主键id/和version更新记录,-version乐观锁-->
    <update id="updateByPKVersion">
        update user_account set amount = amount - #{money},version=version + 1 where id = #{id} and version=#{version} and acount > 0 and (amount - #{money}) > 0
    </update>

前后台数据传输,使用的UserAccountDto,【JSON格式】

@Data
@AllArgsConstructor
@NoArgsConstructor
public class UserAccountDto {

    private Integer userId; //用户名

    private Double account; //用户体现金额
}

之后编写核心业务逻辑,分别编写不加锁和加乐观锁的逻辑

这里可以看出如果两个进程同时读取同一个余额修改,那么只有一个成功,如果是先后的,那么就都可以成功,如果后者也只是读取没有任何影响

//数据库表连接对象
    private final UserAccountMapper userAccountMapper;

    private final UserAccountRecordMapper accountRecordMapper;

    /**
     * 无所方式进行提现,用户发起请求后,首先查询账户,如果够提现就进行提现,提现成功后就可以进行异步打款,rabbitMQ
     */
    @Override
    public void takeMoney(UserAccountDto userAccountDto) throws Exception {
        UserAccount userAccount = userAccountMapper.selectByUserId(userAccountDto.getUserId());
        //校验
        if(!Objects.isNull(userAccount) && userAccount.getAmount().doubleValue() >= userAccountDto.getAccount()) {
            //提现
            int res = userAccountMapper.updateAmount(userAccountDto.getAccount(),userAccount.getId());
            //记录
            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("当前待提现金额为:{},账户的余额为: {}, 成功",userAccountDto.getAccount(),userAccount.getAmount());
            } else {
                throw new Exception("账户余额不足");
            }
        } else {
            throw new Exception("账户不存在或者余额不足!");
        }
    }

    //乐观锁方式,主要就是查询账户的时候会一起查询出来
    @Override
    public void takeMoneyWithVersion(UserAccountDto userAccountDto) throws Exception {
        //乐观锁方式会校验version
        UserAccount userAccount = userAccountMapper.selectByUserId(userAccountDto.getUserId());

        if(!Objects.isNull(userAccount) && userAccount.getAmount().doubleValue() >= userAccountDto.getAccount()) {
            //更新以最开始查询的version为准,如果发现已经被更新了就会更新失败,最先到达的更新成功
           int res =  userAccountMapper.updateByPKVersion(userAccountDto.getAccount(),userAccount.getId(),userAccount.getVersion());
            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("乐观锁-当前待提现金额:{}, 账户总金额:{}",userAccountDto.getAccount(),userAccount.getAmount());
            } else {
                throw new Exception("乐观锁 - 账户余额不足");
            }
        } else {
            throw new Exception("乐观锁 - 账户不存在或者余额不足");
        }
    }

之后编写控制器接口,直接调用这个服务即可

    private final UseAccountService accountService;

    @GetMapping("/getMoney")
    public ResponseEntity<String> getMoney(@Validated @RequestBody UserAccountDto userAccountDto, BindingResult result) {
        if(result.hasErrors()) {
            return new ResponseEntity<>("参数错误",HttpStatus.BAD_REQUEST);
        }
        try {
            accountService.takeMoney(userAccountDto);
            return new ResponseEntity<>("提现成功", HttpStatus.OK);
        } catch (Exception e) {
            return new ResponseEntity<>("出现错误",HttpStatus.NOT_FOUND)
        }
    }

之后就可以使用JMeter高并发测试该用户1s内发起10次提现请求【10次请求就有3次提现成功】,这不合理,再次测试乐观锁,发起1000次请求(同一用户,攻击)

悲观锁 【for update 行锁,X锁】

悲观锁是一种消息的处理方式,总是认为其他线程会对数据进行修改,所以每次获取数据的时候都会上锁,只有当前线程释放该共享资源的锁,其他线程才可以获取, 在传统的关系型数据库就有很多悲观锁,比如行锁、表锁、X锁、S锁,都是在操作之前先上锁,还有synchronized和ReentrantLock工具类都是参照悲观锁的思想【上显式锁,mysql的锁基本都是 InnoDB引擎基本都是行锁 — X、S(当sql匹配条件不带有索引时,使用表锁,有索引,行锁,MyISAM是表锁】forupdate也是一样,如果没有索引,那么就是锁表,其为独占排他锁,其他线程事务不能写不能读,也就是X锁

<!-- 悲观锁方式, 在查询时就加锁for update-->
    <select id="selectByUserIdLock" resultType="CfengMiddleWare.model.entity.UserAccount">
        select id, user_id, amount, version, is_active from user_account
        where user_id = #{userId} for update 
    </select>
    
@Override
    public void takeMoneyWithLock(UserAccountDto userAccountDto) throws Exception {
        //悲观锁在查询时加上for update,开始操作时加锁,直到操作结束事务释放
        UserAccount userAccount = userAccountMapper.selectByUserIdLock(userAccountDto.getUserId());

        if(userAccount != null && userAccount.getAmount().doubleValue() >= userAccountDto.getAccount()) {
            //提现
            int res = userAccountMapper.updateAmount(userAccountDto.getAccount(),userAccount.getId());
            //和之前的是相同的,现在IDEA都不提倡重复的代码,最好能够复用
            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("悲观锁 -当前待提现金额为:{},账户的余额为: {}, 成功",userAccountDto.getAccount(),userAccount.getAmount());
            } else {
                throw new Exception("悲观锁 - 账户余额不足");
            }
        } else {
            throw new Exception("悲观锁 - 账户不存在或者余额不足");
        }
    }

这种方式就是读取前加X锁,直到事务结束,这封锁的级别很高,安全性测试1000个线程,符合预期,但是出现的问题就是: 悲观锁是建立在数据库底层的引擎的基础上,采用for update加上独占锁,当高并发的读请求到达,如果全部是读取操作,那就有点over了,一次只能一个线程操作,所以悲观锁适合写多读少的请求,悲观锁(显式锁)使用不当,会出现死锁(互斥,请求保持、不被剥夺、环路等待),虽然可以选择预防或者避免,使用银行家算法检测死锁也可

基于redis实现分布式锁

分布式锁除了可以依靠数据库级别的乐观锁和悲观锁,还可以使用基于Redis的原子操作实现分布式锁,当然后面还会分享基于注册中心Zookeeper的临时节点和Watcher机制实现分布式锁

RedisRedis可以实现热点数据存储、并发访问控制(原子性)、排行榜、队列等应用场景,基于Redis实现分布式锁,实现高并发场景下的数据的最终的一致性

热点数据的展示和存储: 大部分用户频繁访问的数据

最近访问的数据:  用户访问的最新数据(List)

并发访问: Redis可以预加载并发访问量的数据(高并发抢红包先将库存加入redis)

排行榜: 代替基于数据库的Order By (Sorted Set)

队列: Redis也可以像STOMP协议一样 基于主题式的订阅发布、Queue特性实现队列

Redis的底层设计架构实现 后面会进行intro还有其他的源码,原子操作可以实现分布式锁,因为Redis的核心模块的单线程机制,不管有N个线程,每个线程都需要使用原子操作时,那么就需要进行排队等待, 底层架构中国,同一时刻、同一部署节点只有一个线程执行某种原子操作

实现分布式的主要原子操作时 SET和EXPIRE

Redis的SET命令的格式:

SET key Value [EX seconds] [PX milliseconds] [NX|XX]

EX是指Key的存活时间,NX表示只有当Key不存在才会设置其值,XX则表示当Key存在时才会设置Key的值

NX机制就是分布式的核心,就是不存在的时候,就可以获取🔒,SETNX

  • 使用SETNX获取锁,如果返回0,代表对应的锁存在,已经被获取,那么就会获取锁失败,这个时候进行锁等待,Redis的原子性保证其他的线程在排队【可重入】
  • 为了防止并发线程获取锁之后,程序出现异常情况,导致其他线程一直获取不到锁,进入死锁状态,所以锁一定要设置过期时间
  • 成功获取到锁并执行操作之后,需要立刻释放锁,也就是执行DEL命令删除, 并且保证删除的锁是当前线程 获取的,通过value和get的value比较,防止误删

在这里插入图片描述

基于Redis的原子操作主要是3个核心流程:

  1. 设计和共享资源相关联的lock锁
  2. 调用template执行SETNX和EXPIRE操作 ,获取锁和设置锁的存活时间
  3. 调用template执行DEL方法,删除当前线程获取的锁

这里还是以上面的提现场景为参照,利用分布式锁解决用户重复提交导致的安全问题

//redis方式实现分布式锁主要就是依靠的SETNX和EXPIRE,具体到代码就是setIfAbsent,之前使用过,lock锁的名称最好与资源相关,而value可以生成时间戳nanoTime和UUID
    @Override
    public void takeMoneyWithRedisLock(UserAccountDto userAccountDto) throws Exception {
        ValueOperations valueOperations = redisTemplate.opsForValue();
        //读取操作,修改操作之前需要获取lock锁,也就是redis的一个key
        //首先还是需要先进行读取,读取大家都可以,修改操作就是一个一个来
        UserAccount userAccount = userAccountMapper.selectByUserId(userAccountDto.getUserId());
        //之后进行判断
        if(!Objects.isNull(userAccount) && userAccount.getAmount().doubleValue() - userAccountDto.getAccount() >= 0) {
            //如果余额够,那么就进行提现,写操作,加分布式锁【redis的lock锁】,操作的是元组,所以主要是避免同一个用户的不同线程
            final String key = "redis:account:" + userAccountDto.getUserId() + "-lock";
            //value就是一个唯一确定的,所以UUID可以选择,加上纳秒级时间戳即可
            final String value = System.nanoTime() + "" + UUID.randomUUID();
            //调用SETNX获取锁
            boolean lock = valueOperations.setIfAbsent(key, value);
            //本身原子性
            if (lock) {
                //为了表面死锁,一般还需要设置Key的过期时间,这里就先20s,因为只是为了应对瞬时高并发
                redisTemplate.expire(key, 20, TimeUnit.SECONDS);
                //获取到锁后执行提现操作
               //同时为了保证不出现死锁,分布式锁方案(互斥、公平、不出现死锁、可重入、高可用),为了高可用、不死锁,使用finally确保锁释放
                try {
                    //提现
                    int res = userAccountMapper.updateAmount(userAccountDto.getAccount(), userAccount.getId());
                    //和之前的是相同的,现在IDEA都不提倡重复的代码,最好能够复用
                    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("redis分布式锁 -当前待提现金额为:{},账户的余额为: {}, 成功", userAccountDto.getAccount(), userAccount.getAmount());
                    } else {
                        throw new Exception("redis分布式锁 -- 余额不足");
                    }
                } catch (Exception e) {
                    throw e;
                } finally {
                    //不管发生什么异常情况,一定要释放锁,避免死锁,当然释放的是当前对象的锁,确保key和value对应
                    if(Objects.equals(value,valueOperations.get(key).toString())) {
                        redisTemplate.delete(key); //删除key,释放资源锁
                    }
                }
            }
        }
    }

接下来为了模拟实际环境,进行JMeter压力测试,1000个线程,执行完成查看数据库,只是成功执行了一次,符合预期

用户重复提交场景使用的分布式锁为一次性锁,也就是同一时刻并发线程携带的是相同的数据, 只能允许一个线程通过,其他线程将获取锁失败,结束自身流程

如果是其他的场景,还需要更加精细化的设计,这里因为是同一个用户,所以锁的名称就是关联了用户的ID; 接下来将介绍Zookeeper注册中心,基于Dubbo和Zookeeper也可以实现SpringCloud + Euraka的效果,主要介绍基于Zookeeper实现分布式锁以及再次综合 实现 典型高并发场景: 书籍抢购模块的设计实现🎄

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值