首页 > 其他分享 >分布式锁笔记

分布式锁笔记

时间:2023-11-21 13:12:38浏览次数:36  
标签:提现 dto 账户 getAmount 笔记 userAccount id 分布式

分布式锁

基于上课和教材(分布式中间件技术实战-java版-钟林森)的笔记

1 概述

  • 因为集群、分布式部署的服务实例一般是部署在不同机器上的,在分布式系统架构下,此种资源共享将不再是传统的线程共享,而是跨JVM进程之间资源的共享了。因此,为了解决这种问题,我们引入了“分布式锁”。

1.1 锁机制

  • 在单体应用时代,传统企业级Java应用为了解决“高并发下多线程访问共享资源时出现数据不一致”的问题,通常是借助JDK自身提供的关键字或者并发工具类Synchronized、Lock和RetreenLock等加以实现,这种访问控制机制业界普遍亲切地称之为“锁”。
  • “共享资源”的含义:可以被多个线程、进程同时访问并进行操作的数据或者代码块
    • 比如春运期间抢票时的“车票”、电商平台抢购时的“商品”,以及超市举办商品促销活动时的“商品”等
  • 不管是采用Synchronized关键字还是并发操作类Lock等工具的方式,只适用于单体应用或者是单一部署的服务实例。因为这种方式的“锁”很大程度上需要依赖应用系统所在的JDK,像Synchronized、并发操作工具类Lock等都是Java提供给开发者的关键字或者工具。而在分布式系统时代,许多服务实例或者系统是分开部署的,它们将拥有自己独立的Host(主机),独立的JDK,导致应用系统在分布式部署的情况下,这种控制“并发线程访问共享资源”的机制将不再起作用。

1.2 分布式锁概念

  • 本质上,分布式锁并不是一种中间件,而只是一种机制或实现方式。
  • 在分布式应用中,往往会存在一类共享资源,它们在并行程序中也必须执行串行操作,否则可能出现数据不一致性。(在分布式部署的环境下,通过锁机制让多个客户端或者多个服务进程互斥地对共享资源进行访问,从而避免出现并发安全、数据不一致等问题。)
  • 诸如:
    • 商品下单如果是分布式部署的,多个用户同时购买同一个商品时,就可能导致商品出现库存超卖 (数据不一致) 现象。
    • 多个用户同时操作同一个银行帐号,可能导致数据丢失。

1.3 常用的锁与分布式锁

  • 常用的单机锁机制

    • 使用JDK提供的synchronized、Lock、ReentrantLock、ReentrantReadWriteLock等实现锁。
      • 基本原理:同一个临界区的互斥锁
  • 但在分布式环境下,“同一临界区”往往很难在程序代码本身实现。由此,分布式锁应运而生。

  • 分布式锁的设计几点准则

    • 互斥性
      • 需要保证分布式部署、服务集群部署的环境下,被共享的资源如数据或者代码块在同一时间内只能被一台机器上的一个线程执行。
    • 避免死锁
      • 指的是当前线程获取到锁之后,经过一段有限的时间(该时间一般用于执行实际的业务逻辑),一定要被释放(正常情况或者异常情况下释放)。
    • 高可用
      • 指的是获取或释放锁的机制必须高可用而且性能极佳。
    • 可重入
      • 指的是该分布式锁最好是一把可重入锁,即当前机器的当前线程在彼时如果没有获取到锁,那么在等待一定的时间后一定要保证可以再被获取到。
    • 公平锁(可选)
  • 常用的分布式锁

  • 典型应用场景

    • 重复提交
    • 商城高并发抢单
      • 库存超卖

2 基于数据库实现分布式锁

2.1乐观锁

  • 总是假设最好的情况,每次去拿数据的时候都认为别人不会修改,所以不会上锁,只在更新的时候会判断一下在此期间别人有没有去更新这个数据。
  • 本质上,乐观锁并不算是分布式锁。
  • 通常是采用“版本号version”机制进行实现。
    • “版本号version”机制的执行流程:当前线程取出数据记录时,会顺带把版本号字段version的值取出来,最后在更新该数据记录时,将该version的取值作为更新的条件。当更新成功之后,同时将版本号version的值加1(最终实现version趋势递增的行为->即该机制的核心步骤),而其他同时获取到该数据记录的线程在更新时由于version已经不是当初获取的那个数据记录,因而将更新失败,从而避免并发多线程访问共享数据时出现数据不一致的现象。
  • 实战例子:用户账户余额提现
    1. 数据库
      1. 在“用户账户余额提现”这一业务场景中,数据库设计添加两个表,分别为“用户账户余额记录表”和“申请提现记录表”
        1. “用户账户余额记录表”
        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=4 DEFAULT CHARSET=utf8 COMMENT='用户账户余额记录表';
        
        1. “申请提现记录表”
        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=360 DEFAULT CHARSET=utf8 COMMENT='用户每次成功提现时的金额记录表';
        
      2. 采用MyBatis逆向工程生成两个数据表对应的Entity实体类、Mapper操作接口以及对应的Mapper.xml。在该Mapper操作接口中开发了“没有加锁的情况”与“加了锁的情况”下的更新账户余额的功能
        //用户账户余额实体操作Mapper接口
        public interface UserAccountMapper {
            //根据主键id查询
            UserAccount selectByPrimaryKey(Integer id);
            //根据用户账户Id查询
            UserAccount selectByUserId(@Param("userId") Integer userId);
        
            //更新账户金额——即不加锁
            int updateAmount(@Param("money") Double money,@Param("id") Integer id);
            //根据主键id跟version进行更新——即加锁
            //还加上了“账户余额需要大于0”的判断
            int updateByPKVersion(@Param("money") Double money,@Param("id")Integer id,@Param("version") Integer version);
        }
        
    2. 我们采用MVC的开发模式开始开发该业务场景的整体核心流程。
      1. 首先开发用于接收前端用户发起“账户余额提现”的请求Controller,即DataBaseLockController类,关键源代码如下:
       /**
       * 基于数据库的乐观悲观锁
       * @Author:debug (SteadyJack)
       **/
       @RestController
       public class DataBaseLockController {
           //定义日志
           private static final Logger log= LoggerFactory.getLogger(DataBaseLockController.class);
           //定义请求前缀
           private static final String prefix="db";
           //定义核心逻辑处理服务类
           @Autowired
           private DataBaseLockService dataBaseLockService;
           /**
           * 用户账户余额提现申请
           * @param dto
           * @return
           */
           @RequestMapping(value = prefix+"/money/take",method = RequestMethod.GET)
       public BaseResponse takeMoney(UserAccountDto dto){
               //判断参数的合法性
               if (dto.getAmount()==null || dto.getUserId()==null){
                   return new BaseResponse(StatusCode.InvalidParams);
               }
               //定义响应接口实例
               BaseResponse response=new BaseResponse(StatusCode.Success);
               try {
                       //开始调用核心业务逻辑处理方法-不加锁
                   dataBaseLockService.takeMoney(dto);
      
                       //开始调用核心业务逻辑处理方法-加锁
                   //dataBaseLockService.takeMoneyWithLock(dto);
               }catch (Exception e){
                   response=new BaseResponse(StatusCode.Fail.getCode(),e.getMessage());
               }
               //返回响应结果
               return response;
           }}
      
      1. Controller接收前端用户请求的信息,是采用实体类UserAccountDto进行封装接收的,部分代码如下:
          import java.io.Serializable;
          /**
          * 用户账户提现申请dto
          * @Author:debug (SteadyJack)
          **/
          @Data
          @ToString
          public class UserAccountDto implements Serializable{
              private Integer userId;     //用户账户id
              private Double amount;      //提现金额
          }
        
      2. 开发用于处理核心业务逻辑的服务类DataBaseLockService,发了两个功能,一个是不加锁的情况下(即非version机制)的处理,另一个是加了锁(即包含version机制)的处理,其部分代码如下:
      /**
      * 基于数据库级别的乐观、悲观锁服务
      * @Author:debug (SteadyJack)
      **/
      @Service
      public class DataBaseLockService {
          //定义日志
          private static final Logger log= LoggerFactory.getLogger(DataBase LockService.class);
          //定义“用户账户余额实体”Mapper操作接口
          @Autowired
          private UserAccountMapper userAccountMapper;
          //定义“用户成功申请提现时金额记录”Mapper操作接口
          @Autowired
          private UserAccountRecordMapper userAccountRecordMapper;
          /**
          * 用户账户提取金额处理——不加锁
          * @param dto
          * @throws Exception
          */
          public void takeMoney(UserAccountDto dto) throws Exception{
              //查询用户账户实体记录
              UserAccount userAccount=userAccountMapper.selectByUserId(dto.getUserId());
              //判断实体记录是否存在,以及账户余额是否足够被提现
              if (userAccount!=null && userAccount.getAmount().doubleValue()-dto.getAmount()>0){
                  //如果足够被提现,则更新现有的账户余额
                  userAccountMapper.updateAmount(dto.getAmount(),userAccount.getId());
                  //同时记录提现成功时的记录
                  UserAccountRecord record=new UserAccountRecord();
                  //设置提现成功时的时间
                  record.setCreateTime(new Date());
                  //设置账户记录主键id
                  record.setAccountId(userAccount.getId());
                  //设置成功申请提现时的金额
                  record.setMoney(BigDecimal.valueOf(dto.getAmount()));
                  //插入申请提现金额历史记录
                  userAccountRecordMapper.insert(record);
                  //打印日志
                  log.info("当前待提现的金额为:{} 用户账户余额为:{}",dto.getAmount(),userAccount.getAmount());
              }else {
                  throw new Exception("账户不存在或账户余额不足!");
              }
          }
          /**
          *用户账户提取金额处理-乐观锁处理方式
          * @param dto
          * @throws Exception
          */
          public void takeMoneyWithLock(UserAccountDto dto) throws Exception{
              //查询用户账户实体记录
              UserAccount userAccount=userAccountMapper.selectByUserId(dto.getUserId());
              //判断实体记录是否存在,以及账户余额是否足够被提现
              if (userAccount!=null && userAccount.getAmount().doubleValue()-dto.getAmount()>0){
                  //如果足够被提现,则更新现有的账户余额——采用version版本号机制
                  int res=userAccountMapper.updateByPKVersion(dto.getAmount(),userAccount.getId(),userAccount.getVersion());
                  //只有当更新成功时(此时res=1,即数据库执行更新语句之后数据库受影响的记录行数)
                  if (res>0){
                      //同时记录提现成功时的记录
                      UserAccountRecord record=new UserAccountRecord();
                      //设置提现成功时的时间
                      record.setCreateTime(new Date());
                      //设置账户记录主键id
                      record.setAccountId(userAccount.getId());
                      //设置成功申请提现时的金额
                      record.setMoney(BigDecimal.valueOf(dto.getAmount()));
                      //插入申请提现金额历史记录
                      userAccountRecordMapper.insert(record);
                      //打印日志
                      log.info("当前待提现的金额为:{} 用户账户余额为:{}",dto.getAmount(),userAccount.getAmount());
                  }
              }else {
                  throw new Exception("账户不存在或账户余额不足!");
              }
          }
      }
      

2.2 悲观锁

  • 总是假设最坏的情况,每次去拿数据的时候都认为别人会修改,所以每次在拿数据的时候都会上锁,这样别人想拿这个数据就会阻塞,直到它拿到锁(共享资源每次只给一个线程使用,其它线程阻塞,用完后再把资源转让给其它线程)。
  • 传统的关系型数据库里边就用到了很多这种锁机制,比如行锁,表锁等,读锁,写锁等,都是在做操作之前先上锁。
  • Java中synchronized和ReentrantLock等独占锁就是悲观锁思想的实现。
  • 实战例子:同乐观锁的例子一样——用户账户余额提现
    1. 在UserAccountMapper操作接口中添加与“悲观锁”相关的功能方法,包括了两个功能方法,一个是在查询数据记录时加锁的方法,另一个是用户成功提现时更新账户余额的方法。主要代码如下:
      public interface UserAccountMapper {
           //此处省略了其他的功能方法 – 同上面的乐观锁例子
           //根据用户id查询记录时使用-for update-加悲观锁的方式
           UserAccount selectByUserIdLock(@Param("userId") Integer userId);
           //更新账户金额时加悲观锁的方式
           int updateAmountLock(@Param("money") Double money,@Param("id") Integer id);
       }
      
    2. 在DataBaseLockService类中采用“悲观锁”的方式开发余额申请提现的核心处理功能,主要代码:
      /**
      * 悲观锁处理方式
      * @param dto
      * @throws Exception
      */
      public void takeMoneyWithLockNegative(UserAccountDto dto) throws Exception{
       //第一处可以加悲观锁的地方
       //即在查询用户账户实体记录时使用 - for update的方式加锁
       UserAccount userAccount=userAccountMapper.selectByUserIdLock(dto.getUserId());
       //判断实体记录是否存在,以及账户余额是否足够被提现
       if (userAccount!=null && userAccount.getAmount().doubleValue()-dto.getAmount()>0){
           // 第二处可以加悲观锁的地方
           //如果足够被提现,则更新现有的账户余额 - 采用version版本号机制
           int res=userAccountMapper.updateAmountLock(dto.getAmount(),userAccount.getId());
           //只有当更新成功时(此时res=1,即数据库执行更细语句之后数据库受影响的记录行数)
           if (res>0){
               //同时记录提现成功时的记录
               UserAccountRecord record=new UserAccountRecord();
               //设置提现成功时的时间
               record.setCreateTime(new Date());
               //设置账户记录主键id
               record.setAccountId(userAccount.getId());
               //设置成功申请提现时的金额
               record.setMoney(BigDecimal.valueOf(dto.getAmount()));
               //插入申请提现金额历史记录
               userAccountRecordMapper.insert(record);
               //打印日志
               log.info("悲观锁处理方式-当前待提现的金额为:{} 用户账户余额为:{}",dto.getAmount(),userAccount.getAmount());
           }
       }else {
            throw new Exception("悲观锁处理方式-账户不存在或账户余额不足!");
        }
      }
      
    3. 调整DataBaseLockController调用“处理用户账户余额提现申请”的方法。其他和乐观锁一致。
  • 乐观锁当高并发的多线程的时候,需要频繁“写”数据库时,是会严重影响数据库性能的;而悲观锁当产生高并发多线程请求,需要频繁进行“读”请求时,将对数据库的性能带来严重的影响。因此,乐观锁比较适合于“写少读多”的业务场景,更适用于“读少写多”的业务场景。

分析小结

  • 乐观锁和悲观锁从字面意义上来看,就是对一件事情的看法是乐观还是悲观(即当一个线程在获取某个数据的时候,乐观认为不会有别人不会打扰,而悲观就认为一定会有人来打扰)。这样就很好理解,悲观锁就是让每个并发线程都会认为其他线程都会对这个数据修改,所以每次获取数据都会上锁,以免其他线程打扰;而乐观锁就认为没有其他线程会打扰它当前这个线程,所以获取数据的时候不加锁,等要修改数据了才去看其他人有没有人改过这个数据。

标签:提现,dto,账户,getAmount,笔记,userAccount,id,分布式
From: https://www.cnblogs.com/qq286442936/p/17846355.html

相关文章

  • Datewhale学习笔记1
    $\textcolor{blue}{Datewhale学习笔记}$$\textcolor{red}{chap1}$第一行代码LanguageC#include<stdio.h>intmain(){printf("Hello,World");return0;}In[3]print("聪明办法学Python")聪明办法学PythonHelloWorld的由来main(){pri......
  • 《信息安全系统设计与实现》第十二周学习笔记
    第13章TCP/IP和网络编程TCP/IP协议具体来说,IP或ICMP、TCP或UDP、TELNET或FTP、以及HTTP等都属于TCP/IP协议。他们与TCP或IP的关系紧密,是互联网必不可少的组成部分。TCP/IP一词泛指这些协议,因此,有时也称TCP/IP为网际协议群。互联网进行通信时,需要相应的网络协......
  • 聊聊分布式 SQL 数据库Doris(二)
    Doris中,Leader节点与非Leader节点和Observer节点之间的元数据高可用和一致性,是通过bdbje(全称:OracleBerkeleyDBJavaEdition)的一致性和高可用实现的。元数据与同步流程元数据主要存储四类数据:用户数据信息.包括数据库,表的schema,分片信息等各类作业信息.如导入作业......
  • Python学习笔记-Schema数据结构及类型校验
    Python学习笔记-Schema数据结构及类型校验使用schema库来执行数据结构的校验。schema是一个简单而强大的库,用于定义和验证Python数据结构的约束AndAnd代表必选,数据结构里必须包含这个schema,如下方声明了name,则代表这个name必须存在与字典中fromschemaimportSc......
  • Linux操作系统 I/O重定向读书笔记
    1.理解I/O重定向的基本概念1.1输入重定向在Linux系统中,输入重定向是指将命令的输入从键盘改变为来自文件或其他命令的输出。使用<符号可以实现输入重定向,例如:$command<input.txt这将使command命令从input.txt文件中读取输入而不是从键盘。1.2输出重定向输出重定......
  • 【Redis使用】一年多来redis使用笔记md文档,第(2)篇:命令和数据库操作
    Redis是一个高性能的key-value数据库。本文会让你知道:什么是nosql、Redis的特点、如何修改常用Redis配置、写出Redis中string类型数据的增删改查操作命令、写出Redis中hash类型数据的增删改查相关命令、说出Redis中list保存的数据类型、使用StrictRedis对象对string类型数据......
  • 分布式事务 Seata 集群搭建
    Seata是蚂蚁金服和阿里巴巴共同开源的一款分布式事务项目,致力于在微服务架构下提供高性能和简单易用的分布式事务解决方案。自诞生以来就备受国内开发人员推崇,在实际工作中使用者甚多。Seata提供了四种不同的分布式事务解决方案:XA模式:强一致性分阶段事务模式,牺牲了一定的可用......
  • Unity学习笔记--数据持久化XML文件(1)
    XML相关Xml是可拓展标记语言,一种文件格式。我们使用xml来完成对数据持久化的存储。等待我们有一程序运行结束之后,将内存中的数据进行保存,(保存在硬盘/服务器)实现对数据的持久化存储。xml文件的读取和保存以及修改要点:XMl文件的加载XML文件节点的查找访问XML文件节点内......
  • 阅读笔记
    第三篇:适当人选     这一篇主要讨论了如何雇佣并留住优秀的员工问题。     对于有战略眼光的经理而言,这样的方法将激励你的成功:即雇佣合适的人、使他们觉得开心(这样他们就不想离开)、宽松对待他们。与聪明的人在一起共事,经理们几乎可以从起点开始就可以毫不费力地前......
  • Dom事件基础(pink老师课程笔记)
    事件监听(绑定)事件和事件监听事件是在编程时系统内发生的动作或者发生的事情,比如用户在网页上单击一个按钮让程序检测是否有事件产生,一旦有事件触发,就立即调用一个函数做出响应,也称为绑定事件或者注册事件比如鼠标经过显示下拉菜单,比如点击可以播放轮播图等等语法元素对象......