分布式锁
基于上课和教材(分布式中间件技术实战-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等实现锁。
- 基本原理:同一个临界区的互斥锁
- 使用JDK提供的synchronized、Lock、ReentrantLock、ReentrantReadWriteLock等实现锁。
-
但在分布式环境下,“同一临界区”往往很难在程序代码本身实现。由此,分布式锁应运而生。
-
分布式锁的设计几点准则
- 互斥性
- 需要保证分布式部署、服务集群部署的环境下,被共享的资源如数据或者代码块在同一时间内只能被一台机器上的一个线程执行。
- 避免死锁
- 指的是当前线程获取到锁之后,经过一段有限的时间(该时间一般用于执行实际的业务逻辑),一定要被释放(正常情况或者异常情况下释放)。
- 高可用
- 指的是获取或释放锁的机制必须高可用而且性能极佳。
- 可重入
- 指的是该分布式锁最好是一把可重入锁,即当前机器的当前线程在彼时如果没有获取到锁,那么在等待一定的时间后一定要保证可以再被获取到。
- 公平锁(可选)
- 互斥性
-
常用的分布式锁
-
典型应用场景
- 重复提交
- 商城高并发抢单
- 库存超卖
2 基于数据库实现分布式锁
2.1乐观锁
- 总是假设最好的情况,每次去拿数据的时候都认为别人不会修改,所以不会上锁,只在更新的时候会判断一下在此期间别人有没有去更新这个数据。
- 本质上,乐观锁并不算是分布式锁。
- 通常是采用“版本号version”机制进行实现。
- “版本号version”机制的执行流程:当前线程取出数据记录时,会顺带把版本号字段version的值取出来,最后在更新该数据记录时,将该version的取值作为更新的条件。当更新成功之后,同时将版本号version的值加1(最终实现version趋势递增的行为->即该机制的核心步骤),而其他同时获取到该数据记录的线程在更新时由于version已经不是当初获取的那个数据记录,因而将更新失败,从而避免并发多线程访问共享数据时出现数据不一致的现象。
- 实战例子:用户账户余额提现
- 数据库
- 在“用户账户余额提现”这一业务场景中,数据库设计添加两个表,分别为“用户账户余额记录表”和“申请提现记录表”
- “用户账户余额记录表”
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='用户账户余额记录表';
- “申请提现记录表”
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='用户每次成功提现时的金额记录表';
- 采用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); }
- 在“用户账户余额提现”这一业务场景中,数据库设计添加两个表,分别为“用户账户余额记录表”和“申请提现记录表”
- 我们采用MVC的开发模式开始开发该业务场景的整体核心流程。
- 首先开发用于接收前端用户发起“账户余额提现”的请求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; }}
- Controller接收前端用户请求的信息,是采用实体类UserAccountDto进行封装接收的,部分代码如下:
import java.io.Serializable; /** * 用户账户提现申请dto * @Author:debug (SteadyJack) **/ @Data @ToString public class UserAccountDto implements Serializable{ private Integer userId; //用户账户id private Double amount; //提现金额 }
- 开发用于处理核心业务逻辑的服务类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等独占锁就是悲观锁思想的实现。
- 实战例子:同乐观锁的例子一样——用户账户余额提现
- 在UserAccountMapper操作接口中添加与“悲观锁”相关的功能方法,包括了两个功能方法,一个是在查询数据记录时加锁的方法,另一个是用户成功提现时更新账户余额的方法。主要代码如下:
public interface UserAccountMapper { //此处省略了其他的功能方法 – 同上面的乐观锁例子 //根据用户id查询记录时使用-for update-加悲观锁的方式 UserAccount selectByUserIdLock(@Param("userId") Integer userId); //更新账户金额时加悲观锁的方式 int updateAmountLock(@Param("money") Double money,@Param("id") Integer id); }
- 在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("悲观锁处理方式-账户不存在或账户余额不足!"); } }
- 调整DataBaseLockController调用“处理用户账户余额提现申请”的方法。其他和乐观锁一致。
- 在UserAccountMapper操作接口中添加与“悲观锁”相关的功能方法,包括了两个功能方法,一个是在查询数据记录时加锁的方法,另一个是用户成功提现时更新账户余额的方法。主要代码如下:
- 乐观锁当高并发的多线程的时候,需要频繁“写”数据库时,是会严重影响数据库性能的;而悲观锁当产生高并发多线程请求,需要频繁进行“读”请求时,将对数据库的性能带来严重的影响。因此,乐观锁比较适合于“写少读多”的业务场景,更适用于“读少写多”的业务场景。
分析小结:
- 乐观锁和悲观锁从字面意义上来看,就是对一件事情的看法是乐观还是悲观(即当一个线程在获取某个数据的时候,乐观认为不会有别人不会打扰,而悲观就认为一定会有人来打扰)。这样就很好理解,悲观锁就是让每个并发线程都会认为其他线程都会对这个数据修改,所以每次获取数据都会上锁,以免其他线程打扰;而乐观锁就认为没有其他线程会打扰它当前这个线程,所以获取数据的时候不加锁,等要修改数据了才去看其他人有没有人改过这个数据。