SpringBoot整合MyBatis实现乐观锁和悲观锁的示例

本文以转账操作为例,实现并测试乐观锁和悲观锁。

全部代码:https://github.com/imcloudfloating/Lock_Demo

GitHub Page:https://cloudli.top

死锁问题

当 A, B 两个账户同时向对方转账时,会出现如下情况:

时刻 事务 1 (A 向 B 转账) 事务 2 (B 向 A 转账)
T1 Lock A Lock B
T2 Lock B (由于事务 2 已经 Lock A,等待) Lock A (由于事务 1 已经 Lock B,等待)

由于两个事务都在等待对方释放锁,于是死锁产生了,解决方案:按照主键的大小来加锁,总是先锁主键较小或较大的那行数据。

建立数据表并插入数据(MySQL)

create table account
(
  id   int auto_increment
    primary key,
  deposit decimal(10, 2) default 0.00 not null,
  version int      default 0  not null
);

INSERT INTO vault.account (id, deposit, version) VALUES (1, 1000, 0);
INSERT INTO vault.account (id, deposit, version) VALUES (2, 1000, 0);
INSERT INTO vault.account (id, deposit, version) VALUES (3, 1000, 0);
INSERT INTO vault.account (id, deposit, version) VALUES (4, 1000, 0);
INSERT INTO vault.account (id, deposit, version) VALUES (5, 1000, 0);
INSERT INTO vault.account (id, deposit, version) VALUES (6, 1000, 0);
INSERT INTO vault.account (id, deposit, version) VALUES (7, 1000, 0);
INSERT INTO vault.account (id, deposit, version) VALUES (8, 1000, 0);
INSERT INTO vault.account (id, deposit, version) VALUES (9, 1000, 0);
INSERT INTO vault.account (id, deposit, version) VALUES (10, 1000, 0);

Mapper 文件

悲观锁使用 select ... for update,乐观锁使用 version 字段。

<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
    "http://mybatis.org/dtd/mybatis-3-mapper.dtd" >
<mapper namespace="com.cloud.demo.mapper.AccountMapper">
  <select id="selectById" resultType="com.cloud.demo.model.Account">
    select *
    from account
    where id = #{id}
  </select>
  <update id="updateDeposit" keyProperty="id" parameterType="com.cloud.demo.model.Account">
    update account
    set deposit=#{deposit},
      version = version + 1
    where id = #{id}
     and version = #{version}
  </update>
  <select id="selectByIdForUpdate" resultType="com.cloud.demo.model.Account">
    select *
    from account
    where id = #{id} for
    update
  </select>
  <update id="updateDepositPessimistic" keyProperty="id" parameterType="com.cloud.demo.model.Account">
    update account
    set deposit=#{deposit}
    where id = #{id}
  </update>
  <select id="getTotalDeposit" resultType="java.math.BigDecimal">
    select sum(deposit) from account;
  </select>
</mapper>

Mapper 接口

@Component
public interface AccountMapper {
  Account selectById(int id);
  Account selectByIdForUpdate(int id);
  int updateDepositWithVersion(Account account);
  void updateDeposit(Account account);
  BigDecimal getTotalDeposit();
}

Account POJO

@Data
public class Account {
  private int id;
  private BigDecimal deposit;
  private int version;
}

AccountService

在 transferOptimistic 方法上有个自定义注解 @Retry,这个用来实现乐观锁失败后重试。

@Slf4j
@Service
public class AccountService {

  public enum Result{
    SUCCESS,
    DEPOSIT_NOT_ENOUGH,
    FAILED,
  }

  @Resource
  private AccountMapper accountMapper;

  private BiPredicate<BigDecimal, BigDecimal> isDepositEnough = (deposit, value) -> deposit.compareTo(value) > 0;

  /**
   * 转账操作,悲观锁
   *
   * @param fromId 扣款账户
   * @param toId  收款账户
   * @param value 金额
   */
  @Transactional(isolation = Isolation.READ_COMMITTED)
  public Result transferPessimistic(int fromId, int toId, BigDecimal value) {
    Account from, to;

    try {
      // 先锁 id 较大的那行,避免死锁
      if (fromId > toId) {
        from = accountMapper.selectByIdForUpdate(fromId);
        to = accountMapper.selectByIdForUpdate(toId);
      } else {
        to = accountMapper.selectByIdForUpdate(toId);
        from = accountMapper.selectByIdForUpdate(fromId);
      }
    } catch (Exception e) {
      log.error(e.getMessage());
      TransactionAspectSupport.currentTransactionStatus().setRollbackOnly();
      return Result.FAILED;
    }

    if (!isDepositEnough.test(from.getDeposit(), value)) {
      TransactionAspectSupport.currentTransactionStatus().setRollbackOnly();
      log.info(String.format("Account %d is not enough.", fromId));
      return Result.DEPOSIT_NOT_ENOUGH;
    }

    from.setDeposit(from.getDeposit().subtract(value));
    to.setDeposit(to.getDeposit().add(value));

    accountMapper.updateDeposit(from);
    accountMapper.updateDeposit(to);

    return Result.SUCCESS;
  }

  /**
   * 转账操作,乐观锁
   * @param fromId 扣款账户
   * @param toId  收款账户
   * @param value 金额
   */
  @Retry
  @Transactional(isolation = Isolation.REPEATABLE_READ)
  public Result transferOptimistic(int fromId, int toId, BigDecimal value) {
    Account from = accountMapper.selectById(fromId),
        to = accountMapper.selectById(toId);

    if (!isDepositEnough.test(from.getDeposit(), value)) {
      TransactionAspectSupport.currentTransactionStatus().setRollbackOnly();
      return Result.DEPOSIT_NOT_ENOUGH;
    }

    from.setDeposit(from.getDeposit().subtract(value));
    to.setDeposit(to.getDeposit().add(value));

    int r1, r2;

    // 先锁 id 较大的那行,避免死锁
    if (from.getId() > to.getId()) {
      r1 = accountMapper.updateDepositWithVersion(from);
      r2 = accountMapper.updateDepositWithVersion(to);
    } else {
      r2 = accountMapper.updateDepositWithVersion(to);
      r1 = accountMapper.updateDepositWithVersion(from);
    }

    if (r1 < 1 || r2 < 1) {
      // 失败,抛出重试异常,执行重试
      throw new RetryException("Transfer failed, retry.");
    } else {
      return Result.SUCCESS;
    }
  }
}

使用 Spring AOP 实现乐观锁失败后重试

自定义注解 Retry

@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface Retry {
  int value() default 3; // 重试次数
}

重试异常 RetryException

public class RetryException extends RuntimeException {
  public RetryException(String message) {
    super(message);
  }
}

重试的切面类

tryAgain 方法使用了 @Around 注解(表示环绕通知),可以决定目标方法在何时执行,或者不执行,以及自定义返回结果。这里首先通过 ProceedingJoinPoint.proceed() 方法执行目标方法,如果抛出了重试异常,那么重新执行直到满三次,三次都不成功则回滚并返回 FAILED。

@Slf4j
@Aspect
@Component
public class RetryAspect {

  @Pointcut("@annotation(com.cloud.demo.annotation.Retry)")
  public void retryPointcut() {

  }

  @Around("retryPointcut() && @annotation(retry)")
  @Transactional(isolation = Isolation.READ_COMMITTED)
  public Object tryAgain(ProceedingJoinPoint joinPoint, Retry retry) throws Throwable {
    int count = 0;
    do {
      count++;
      try {
        return joinPoint.proceed();
      } catch (RetryException e) {
        if (count > retry.value()) {
          log.error("Retry failed!");
          TransactionAspectSupport.currentTransactionStatus().setRollbackOnly();
          return AccountService.Result.FAILED;
        }
      }
    } while (true);
  }
}

单元测试

用多个线程模拟并发转账,经过测试,悲观锁除了账户余额不足,或者数据库连接不够以及等待超时,全部成功;乐观锁即使加了重试,成功的线程也很少,500 个平均也就十几个成功。

所以对于写多读少的操作,使用悲观锁,对于读多写少的操作,可以使用乐观锁。

完整代码请见 Github:https://github.com/imcloudfloating/Lock_Demo

@Slf4j
@SpringBootTest
@RunWith(SpringRunner.class)
class AccountServiceTest {

  // 并发数
  private static final int COUNT = 500;

  @Resource
  AccountMapper accountMapper;

  @Resource
  AccountService accountService;

  private CountDownLatch latch = new CountDownLatch(COUNT);
  private List<Thread> transferThreads = new ArrayList<>();
  private List<Pair<Integer, Integer>> transferAccounts = new ArrayList<>();

  @BeforeEach
  void setUp() {
    Random random = new Random(currentTimeMillis());
    transferThreads.clear();
    transferAccounts.clear();

    for (int i = 0; i < COUNT; i++) {
      int from = random.nextInt(10) + 1;
      int to;
      do{
        to = random.nextInt(10) + 1;
      } while (from == to);
      transferAccounts.add(new Pair<>(from, to));
    }
  }

  /**
   * 测试悲观锁
   */
  @Test
  void transferByPessimisticLock() throws Throwable {
    for (int i = 0; i < COUNT; i++) {
      transferThreads.add(new Transfer(i, true));
    }
    for (Thread t : transferThreads) {
      t.start();
    }
    latch.await();

    Assertions.assertEquals(accountMapper.getTotalDeposit(),
        BigDecimal.valueOf(10000).setScale(2, RoundingMode.HALF_UP));
  }

  /**
   * 测试乐观锁
   */
  @Test
  void transferByOptimisticLock() throws Throwable {
    for (int i = 0; i < COUNT; i++) {
      transferThreads.add(new Transfer(i, false));
    }
    for (Thread t : transferThreads) {
      t.start();
    }
    latch.await();

    Assertions.assertEquals(accountMapper.getTotalDeposit(),
        BigDecimal.valueOf(10000).setScale(2, RoundingMode.HALF_UP));
  }

  /**
   * 转账线程
   */
  class Transfer extends Thread {
    int index;
    boolean isPessimistic;

    Transfer(int i, boolean b) {
      index = i;
      isPessimistic = b;
    }

    @Override
    public void run() {
      BigDecimal value = BigDecimal.valueOf(
          new Random(currentTimeMillis()).nextFloat() * 100
      ).setScale(2, RoundingMode.HALF_UP);

      AccountService.Result result = AccountService.Result.FAILED;
      int fromId = transferAccounts.get(index).getKey(),
          toId = transferAccounts.get(index).getValue();
      try {
        if (isPessimistic) {
          result = accountService.transferPessimistic(fromId, toId, value);
        } else {
          result = accountService.transferOptimistic(fromId, toId, value);
        }
      } catch (Exception e) {
        log.error(e.getMessage());
      } finally {
        if (result == AccountService.Result.SUCCESS) {
          log.info(String.format("Transfer %f from %d to %d success", value, fromId, toId));
        }
        latch.countDown();
      }
    }
  }
}

MySQL 配置

innodb_rollback_on_timeout='ON'
max_connections=1000
innodb_lock_wait_timeout=500

以上就是本文的全部内容,希望对大家的学习有所帮助,也希望大家多多支持我们。

(0)

相关推荐

  • SpringBoot集成MyBatis的分页插件PageHelper实例代码

    昨天给各位总结了本人学习springboot整合mybatis第一阶段的一些学习心得和源码,主要就算是敲了一下SpringBoot的门儿,希望能给各位的入门带给一点儿捷径,今天给各位温习一下MyBatis的分页插件PageHelper和SpringBoot的集成,它的使用也非常简单,开发更为高效.因为PageHelper插件是属于MyBatis框架的,所以相信很多哥们儿都已经用烂了,下面带着各位吃一下回头草. 首先说说MyBatis框架的PageHelper插件吧,它是一个非常好用的分页插件,通

  • 详解在springboot中使用Mybatis Generator的两种方式

    介绍 Mybatis Generator(MBG)是Mybatis的一个代码生成工具.MBG解决了对数据库操作有最大影响的一些CRUD操作,很大程度上提升开发效率.如果需要联合查询仍然需要手写sql.相信很多人都听说过微服务,各个微服务之间是松耦合的.每个微服务仅关注于完成一件任务并很好地完成该任务.在一个微服务的开发过程中很可能只关注对单表的操作.所以MBG在开发过程中可以快速的生成代码提升开发效率. 本文将说到在springboot的项目中如何去配置(XML形式和Java配置类形式)和使用M

  • SpringBoot整合Mybatis使用Druid数据库连接池

    本文实例为大家分享了SpringBoot整合Mybatis使用Druid数据库连接池的方法,具体内容如下 在SpringBoot项目中,增加如下依赖 <!-- spring mybatis --> <dependency> <groupId>org.mybatis.spring.boot</groupId> <artifactId>mybatis-spring-boot-starter</artifactId> <version

  • springboot+springmvc+mybatis项目整合

    介绍: 上篇给大家介绍了ssm多模块项目的搭建,在搭建过程中spring整合springmvc和mybatis时会有很多的东西需要我们进行配置,这样不仅浪费了时间,也比较容易出错,由于这样问题的产生,Pivotal团队提供了一款全新的框架,该框架使用了特定的方式来进行配置,从而使开发人员不再需要定义样板化的配置.通过这种方式,Spring Boot致力于在蓬勃发展的快速应用开发领域(rapid application development)成为领导者. 特点: 1. 创建独立的Spring应用

  • 基于SpringBoot与Mybatis实现SpringMVC Web项目

    一.热身 一个现实的场景是:当我们开发一个Web工程时,架构师和开发工程师可能更关心项目技术结构上的设计.而几乎所有结构良好的软件(项目)都使用了分层设计.分层设计是将项目按技术职能分为几个内聚的部分,从而将技术或接口的实现细节隐藏起来. 从另一个角度上来看,结构上的分层往往也能促进了技术人员的分工,可以使开发人员更专注于某一层业务与功能的实现,比如前端工程师只关心页面的展示与交互效果(例如专注于HTML,JS等),而后端工程师只关心数据和业务逻辑的处理(专注于Java,Mysql等).两者之间

  • springboot + mybatis配置多数据源示例

    在实际开发中,我们一个项目可能会用到多个数据库,通常一个数据库对应一个数据源. 代码结构: 简要原理: 1)DatabaseType列出所有的数据源的key---key 2)DatabaseContextHolder是一个线程安全的DatabaseType容器,并提供了向其中设置和获取DatabaseType的方法 3)DynamicDataSource继承AbstractRoutingDataSource并重写其中的方法determineCurrentLookupKey(),在该方法中使用Da

  • 详解SpringBoot和Mybatis配置多数据源

    目前业界操作数据库的框架一般是 Mybatis,但在很多业务场景下,我们需要在一个工程里配置多个数据源来实现业务逻辑.在SpringBoot中也可以实现多数据源并配合Mybatis框架编写xml文件来执行SQL.在SpringBoot中,配置多数据源的方式十分便捷, 下面开始上代码: 在pom.xml文件中需要添加一些依赖 <!-- Spring Boot Mybatis 依赖 --> <dependency> <groupId>org.mybatis.spring.b

  • springboot 多模块将dao(mybatis)项目拆分出去

    前言: 以前我们在建项目的时候, 要么将所有的package建在一个项目里面, 在处理引用的时候, 真的很方便. 不用担心, 有些东西配置不到或者读取不到. 或者, 将package独立出去, 到一个项目中或者子项目中. 这时候, 项目中的引用处理, 还是有些麻烦的. 不过好处更多, 不再表述. 在 idea 里面, 推荐使用 多模块 建项目, 而不再是 eclipse 里面的那种方式. 那这里, 就试着将一个springboot 的项目拆分到子模块中去, 看看效果如何. 项目拆分: 1. 目录

  • springboot与mybatis整合实例详解(完美融合)

    简介 从 Spring Boot 项目名称中的 Boot 可以看出来,Spring Boot 的作用在于创建和启动新的基于 Spring 框架的项目.它的目的是帮助开发人员很容易的创建出独立运行和产品级别的基于 Spring 框架的应用.Spring Boot 会选择最适合的 Spring 子项目和第三方开源库进行整合.大部分 Spring Boot 应用只需要非常少的配置就可以快速运行起来. Spring Boot 包含的特性如下: 创建可以独立运行的 Spring 应用. 直接嵌入 Tomc

  • SpringBoot Mybatis Plus公共字段自动填充功能

    一.应用场景 平时在建对象表的时候都会有最后修改时间,最后修改人这两个字段,对于这些大部分表都有的字段,每次在新增和修改的时候都要考虑到这几个字段有没有传进去,很麻烦.mybatisPlus有一个很好的解决方案.也就是公共字段自动填充的功能.一般满足下面条件的字段就可以使用此功能: 这个字段是大部分表都会有的. 这个字段的值是固定的,或则字段值是可以在后台动态获取的. 常用的就是last_update_time,last_update_name这两个字段. 二.配置MybatisPlus 导包:

随机推荐