跳转到内容

数据库锁

乐观锁简介

乐观锁是一种很 “佛系”的实现方式,总是认为不会产生并发的问题,故而每次从数据库获取数据时总认为不会有其他线程对该数据进行修改,因此不会上锁,但是在更新时会判断其他线程在只之前有没有对该数据进行修改,通常是采用版本号: 机制。

这种机制的执行流程如下:

假设有三个线程:A,B,C

  • 当前线程去取该数据记录时,会顺带把版本version的值取出来。最后在更新该数据记录时,将该version的取值作为更新的条件。

    public void getMemony(String userid,String money){
    // 查询用户信息
    UserAccount userAccount = UserAccountMapper.getId(userid);// B版本 0 A版本 0 C 版本 0
    // 用户开始体现
    UserAccountMapper.updateMinusMoney(userAccount.getTotalPrice -money);
    //update UserAccount set version = userAccount.getVerion+1 where version = userAccount.getVerion
    // 用户体现记录表
    UserRecordMapper.saveRecord(userId,money);//1 80
    }
  • 当更新成功后,同时将version的值 + 1,

  • 从而其他同时获取该数据记录的线程在更新时由于version的值以及不是当初获取的那个值,故而将更新失败。

  • 从而避免了并发多线程访问共享数据时出现的数据不一致现象,

乐观锁案例 - 余额提现

在很多的网站比如:微信,支付宝,网易支付等都有余额提现,比如:网易支付。

当用户在在用户账户余额的情况下,点击“提现申请”,即可申请的余额提现到指定的账户中,如下图:

image-20220409195204854

当前用户在前端多次点击:“提现”按钮,将很有可能出现典型的并发现象,同一时刻产生多个提现余额的并发请求,当这些请求到达后端接口时,正常情况下,接口会查询账户的余额,最终账户的余额的值为:“账户剩下的金额” 减去 “申请提现的金额”。

理想情况是这样,也没有问题,然后显示在高并发的情况下,当用户明明知道自己的账户余额不够提取时,缺恶意的疯狂的点击:“提现”按钮,导致同一时刻产生了大量并发线程,由于后端接口在处理每一个请求时,需要先取出当前账户的剩余余额,再去判断是否能够被提取,如果足够被提取,则在当前账余额的基础上减去提现金额。最终将剩余的金额更新到:“账户余额” 字段,这种逻辑处理很容易产生并发安全问题,会出现”数据不一致”的现象,最终出现是账户月字段的值变成:负数。

新建一个项目springboot

1、依赖

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.5.12</version>
<relativePath/> <!-- lookup parent from repository -->
</parent>
<groupId>com.pug.lock</groupId>
<artifactId>xq_pugs_middle_lock</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>xq_pugs_middle_lock</name>
<description>xq_pugs_middle_lock</description>
<properties>
<java.version>1.8</java.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
<version>3.5.1</version>
</dependency>
<!--guava-->
<dependency>
<groupId>com.google.guava</groupId>
<artifactId>guava</artifactId>
<version>31.0.1-jre</version>
</dependency>
<!--mysql-->
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<version>8.0.27</version>
</dependency>
<!--druid-->
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>druid-spring-boot-starter</artifactId>
<version>1.2.8</version>
</dependency>
<!-- redis -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
<exclusions>
<exclusion>
<groupId>io.lettuce</groupId>
<artifactId>lettuce-core</artifactId>
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>redis.clients</groupId>
<artifactId>jedis</artifactId>
<version>3.8.0</version>
</dependency>
<!-- https://mvnrepository.com/artifact/org.apache.commons/commons-pool2 -->
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-pool2</artifactId>
<version>2.10.0</version>
</dependency>
<!--rabbitmq-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-amqp</artifactId>
</dependency>
<!--zookeeper-->
<dependency>
<groupId>org.apache.zookeeper</groupId>
<artifactId>zookeeper</artifactId>
<version>3.6.3</version>
<exclusions>
<exclusion>
<artifactId>slf4j-log4j12</artifactId>
<groupId>org.slf4j</groupId>
</exclusion>
</exclusions>
</dependency>
<!-- https://mvnrepository.com/artifact/org.apache.curator/curator-framework -->
<dependency>
<groupId>org.apache.curator</groupId>
<artifactId>curator-framework</artifactId>
<version>4.3.0</version>
</dependency>
<dependency>
<groupId>org.apache.curator</groupId>
<artifactId>curator-recipes</artifactId>
<version>4.3.0</version>
</dependency>
<!--redisson-->
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson</artifactId>
<version>3.16.8</version>
</dependency>
<dependency>
<groupId>com.github.xiaoymin</groupId>
<artifactId>knife4j-spring-boot-starter</artifactId>
<version>2.0.7</version>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<excludes>
<exclude>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</exclude>
</excludes>
</configuration>
</plugin>
</plugins>
</build>
</project>

2、application.properties配置

#指定应用访问的上下文以及端口
server.context-path=/middle
server.port=8887
#数据库访问配置
spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver
spring.datasource.url=jdbc:mysql://127.0.0.1:3306/xq_pug_travels?serverTimezone=GMT%2b8&useUnicode=true&characterEncoding=utf-8&useSSL=false
spring.datasource.username=root
spring.datasource.password=mkxiaoer
# mybatis-plus配置
mybatis-plus.configuration.log-impl=org.apache.ibatis.logging.stdout.StdOutImpl
mybatis-plus.mapper-locations=classpath*:/mapper/*.xml

3、添加注解

package com.pug.lock;
import org.mybatis.spring.annotation.MapperScan;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
@MapperScan(basePackages = "com.pug.lock.mapper")
public class XqPugsMiddleLockApplication {
public static void main(String[] args) {
SpringApplication.run(XqPugsMiddleLockApplication.class, args);
}
}

新建一个用户余额表和用户提现记录表

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='用户账户表'

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=423 DEFAULT CHARSET=utf8 COMMENT='用户每次提现时的金额记录表'

创建对应entity,mapper,mapper.xml、service、controller

entity

/**
* 用户账户实体
*/
@Data
@ToString
@TableName("user_account")
public class UserAccount {
@TableId(type = IdType.AUTO)
private Integer id; //主键Id
private Integer userId;//用户账户id
private BigDecimal amount;//账户余额
private Integer version; //版本号
private Byte isActive; //是否有效账户
}
/**
* 用户每次提现时金额记录实体
*/
@Data
@ToString
@TableName("user_account_record")
public class UserAccountRecord {
@TableId(type = IdType.AUTO)
private Integer id; //主键id
private Integer accountId; //账户记录主键id
private BigDecimal money; //提现金额
private Date createTime; //提现成功时间
}

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进行更新
int updateByPKVersion(@Param("money") Double money, @Param("id") Integer id, @Param("version") Integer version);
//根据用户id查询记录-for update方式
UserAccount selectByUserIdLock(@Param("userId") Integer userId);
//更新账户金额-悲观锁的方式
int updateAmountLock(@Param("money") Double money, @Param("id") Integer id);
}
public interface UserAccountRecordMapper {
//插入记录
int insert(UserAccountRecord record);
//根据主键id查询
UserAccountRecord selectByPrimaryKey(Integer id);
}

mapper.xml

<?xml version="1.0" encoding="UTF-8" ?>
<!--xml版本与命名空间定义-->
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd" >
<!--定义所在的命名空间-->
<mapper namespace="com.pug.lock.mapper.UserAccountMapper" >
<!--查询结果集映射-->
<resultMap id="BaseResultMap" type="com.pug.lock.model.UserAccount" >
<id column="id" property="id" jdbcType="INTEGER" />
<result column="user_id" property="userId" jdbcType="INTEGER" />
<result column="amount" property="amount" jdbcType="DECIMAL" />
<result column="version" property="version" jdbcType="INTEGER" />
<result column="is_active" property="isActive" jdbcType="TINYINT" />
</resultMap>
<!--查询的sql片段-->
<sql id="Base_Column_List" >
id, user_id, amount, version, is_active
</sql>
<!--根据主键id查询-->
<select id="selectByPrimaryKey" resultMap="BaseResultMap" parameterType="java.lang.Integer" >
select
<include refid="Base_Column_List" />
from user_account
where id = #{id,jdbcType=INTEGER}
</select>
<!--根据用户账户id查询记录-->
<select id="selectByUserId" resultType="com.pug.lock.model.UserAccount">
SELECT <include refid="Base_Column_List"/>
FROM user_account
WHERE is_active=1 AND user_id=#{userId}
</select>
<!--根据主键id更新账户余额-->
<update id="updateAmount">
UPDATE user_account SET amount = amount - #{money}
WHERE is_active=1 AND id=#{id}
</update>
<!--根据主键id跟version更新记录-->
<update id="updateByPKVersion">
update user_account set amount = amount - #{money},version=version+1
where id = #{id} and version=#{version} and amount >0 and (amount - #{money})>=0
</update>
<!--根据用户id查询-用于悲观锁-->
<select id="selectByUserIdLock" resultType="com.pug.lock.model.UserAccount">
SELECT <include refid="Base_Column_List"/>
FROM user_account
WHERE user_id=#{userId} FOR UPDATE
</select>
<!--根据主键id更新账户余额-悲观锁的方式-->
<update id="updateAmountLock">
UPDATE user_account SET amount = amount - #{money}
WHERE is_active=1 AND id=#{id} and amount >0 and (amount - #{money})>=0
</update>
</mapper>
<?xml version="1.0" encoding="UTF-8" ?>
<!--xml版本与命名空间定义-->
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd" >
<!--定义所在的命名空间-->
<mapper namespace="com.pug.lock.mapper.UserAccountRecordMapper" >
<!--查询结果集映射-->
<resultMap id="BaseResultMap" type="com.pug.lock.model.UserAccountRecord" >
<id column="id" property="id" jdbcType="INTEGER" />
<result column="account_id" property="accountId" jdbcType="INTEGER" />
<result column="money" property="money" jdbcType="DECIMAL" />
<result column="create_time" property="createTime" jdbcType="TIMESTAMP" />
</resultMap>
<!--查询的sql片段-->
<sql id="Base_Column_List" >
id, account_id, money, create_time
</sql>
<!--根据主键id查询-->
<select id="selectByPrimaryKey" resultMap="BaseResultMap" parameterType="java.lang.Integer" >
select
<include refid="Base_Column_List" />
from user_account_record
where id = #{id,jdbcType=INTEGER}
</select>
<!--插入记录-->
<insert id="insert" parameterType="com.pug.lock.model.UserAccountRecord" >
insert into user_account_record (id, account_id, money, create_time)
values (#{id,jdbcType=INTEGER}, #{accountId,jdbcType=INTEGER}, #{money,jdbcType=DECIMAL},
#{createTime,jdbcType=TIMESTAMP})
</insert>
</mapper>

service

/**
* 基于数据库级别的乐观、悲观锁服务
**/
@Service
public class DataBaseLockService {
//定义日志
private static final Logger log= LoggerFactory.getLogger(DataBaseLockService.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("账户不存在或账户余额不足!");
}
}
/**
* 悲观锁处理方式
* @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("悲观锁处理方式-账户不存在或账户余额不足!");
}
}
}

controller

/**
* 基于数据库的乐观悲观锁
**/
@RestController
@Api(tags="数据库的乐观锁和悲观锁")
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 R takeMoney(UserAccountDto dto) {
if (dto.getAmount() == null || dto.getUserId() == null) {
return new R(StatusCode.InvalidParams);
}
R response = new R(StatusCode.Success);
try {
//不加锁的情况
dataBaseLockService.takeMoney(dto);
//加乐观锁的情况
//dataBaseLockService.takeMoneyWithLock(dto);
//加悲观锁的情况
//dataBaseLockService.takeMoneyWithLockNegative(dto);
} catch (Exception e) {
response = new R(StatusCode.Fail.getCode(), e.getMessage());
}
return response;
}
}

jmeter压力测试

启动

image-20220410002130431

汉化

image-20220410002227675

外观

image-20220410002320512

新建线程组

image-20220410002602882

添加并发请求的接口

image-20220410002737244

压力测试以后可以得出结论属于重复提现

image-20220410004006049

悲观锁概述

悲观锁:是一种“消极,悲观”的处理方式,它总是假设事情的发生是在:“最坏的情况”,即每次并发线程在获取数据的时候会认为其他线程会对数据进行修改,故而每次获取数据时都会上锁,而其他线程的防卫数据的时候就会发生阻塞的想象。最终只有当前线程是否该共享资源的锁,其他线程才能获取锁,并对共享资源进行操作。

具体实现

在传统关系型数据库中就用到了很多类似悲观锁的机制,如:行锁,表锁和共享锁和排它锁等,都是在进行操作之前先上锁,Java中的Synchorinzed和Lock也都参考了数据库的悲观锁来设计的。对于数据库级别的悲观锁而言,目前Oracle和MYSQL数据库都是采用如下SQL的方式进行实现。

select 字段列表 from 数据表 for update

假设这个时候高并发产生了多个线程比如A,B,C时,3个线程同时前往数据库查询共享的数据记录,由于数据库引擎的作用,同一时刻将只会有一个线程如A线程获取该数据库记录的锁(在MYSQL中属于“行”级别的锁),其他的两个线程B和C将处在一直等待的状态,直到A线程对该数据库记录操作完毕,并提交事务之后才会释放锁。之后B和C的其中一个线程才能成功获取锁,并执行相应的操作,数据库级别的悲观锁实现流程如下:

image-20220409194731344

从上图可以看出来,当请求量很大的时候,由于产生了的每个线程都在查询数据库的时候都需要上锁,而且同一时刻也只能有一个线程上锁成功,只有当该线程对该共享资源操作完毕并释放该锁之后,其他正在等待的线程才能获取到锁。

采用这种方式将会造成大量的先发生堵塞现象,在某种成都上会给数据库服务器造成一定的压力,从这个角度上思考,基于数据库的悲观锁适合于并发量不大的情况,特别是在:“读”请求数据量不大的情况。

上锁的目的

让多线程变成 一种有序执行的方式。性能减低,保护数据的共享资源数据的一致性问题。

小结

  • 乐观锁,,故而在高并发产生多线程时,同一时刻只有一个线程能获取到”锁“并成功操作共享资源,而其他的线程将获取失败,而且是永久性地失败,从这个角度来看,这种方式虽然可以控制并发线程共享资源访问,但是却牺牲了系统的吞吐性能。 另外,乐观锁,主要是通过version字段对共享数据进行跟踪和控制,其最终一个实现步骤带上version进行匹配,同时执行version+1的更新操作,故而在并发多线程需要频繁”写“数据库时,是会严重影响数据库的性能的,从这个角度上看,乐观锁比较适合 ”写少读多“的业务场景。

  • 悲观锁,,并使用 的查询语句对共享资源加锁,故而在高并发多线程请求,特别是 读 的请求时,将对数据的性能带来严重的影响,因为在同一时刻产生的多线程中将只有一个线程获取到锁,而其他的线程将处于堵塞状态,直到该线程释放了锁, 使用悲观锁要注意的是,如果使用不恰当很可能产生死锁的现象,即两个或者多个线程同时处于等待获取对方的资源的锁的状态,故而”悲观锁“更适用于读少写多的业务场景。