Mysql乐观锁实战

文章首先介绍乐观锁的概念,然后介绍乐观锁的实现原理,最后用一个springboot项目演示乐观锁的实现方式。

目录

什么是乐观锁

乐观锁实现原理

实战


什么是乐观锁

在进行数据库操作的时候,乐观锁总是假设查询不会修改数据,因此不会对查询到的数据上锁,只有在真正更新数据的时候再去检测是否有冲突,如果有冲突则更新失败。

有的小伙伴会问:为什么要使用乐观锁?因为在处理并发时,我们经常需要面对竞态条件,即某一方法的返回值取决于运行在线程中操作的交替执行方式(下一节会举栗),这是线程不安全的。乐观锁就是为了保证线程安全性,且提高并发访问的效率。(ps:所谓线程安全性,指的是当多个线程访问某个类时,不管运行环境采用何种调度方式或者线程如何交替执行,这个类始终都能表现出正确的行为)(pps:何为正确的行为:所见即所知we know it when we see it)。

乐观锁实现原理

乐观锁的实现原理是,在表中新增一个version字段,每次更新数据库的时候,都去检查version字段是否符合预期值,如果符合则更新,否则不更新。

举栗:

有一张用户的存款表account,里面有一条小明同学的存款记录,显示账户里有1000块。表结构非常简单:

iduser_name    account_numupdate_time
1小明   1000null

现在小明要从自己的账户里取50块钱,如果不使用锁,后台的逻辑会是这样:

a1、先查出小明的存款记录select * from account where user_name="小明",查询出余额为account_num1

a2、存款余额减50后试图更新表update account set account_num=account_num1-50 where user_name="小明"

看起来这样似乎没什么问题,但其实不然。

就在小明操作自己账户的同时,小华也正在给小明还钱,数额100:

b1、先查出小明的存款记录select * from account where user_name="小明",查询出余额为account_num2

b2、存款余额加100后试图更新表update account set account_num=account_num2+100 where user_name="小明"

小明取50,小华还100,理论上小明账户里应该还有1050。

但是因为没有加锁,且以上的a1,a2,b1,b2执行顺序存在随机性,导致结果可能出错。

我们假设执行的顺序是a1,b1,a2,b2,小明和小华查到的余额都是1000,小明成功取了钱,余额设置成了950,但是由于b2最后更新,小明账户的余额会是1100(小明高兴了,银行不乐意);如果执行的顺序是a1,b1,b2,a2,由于a2最后更新,小明的账户余额会是950(小华不高兴了,钱白还了)。

乐观锁正是用来解决上面的并发问题,我们来看看如何解决。

在表中增加一个字段version(名称无所谓):

iduser_nameaccount_numupdate_timeversion
1小明1000null1

小明仍然取50块钱:

a1、先查出小明的存款记录select * from account where user_name="小明",查出余额为account_num1,version为version1

a2、存款余额减50后试图更新表update account set account_num=account_num1-50, version = version+1 where user_name="小明" and version=version1

小华存100:

b1、先查出小明的存款记录select * from account where user_name="小明",查出余额为account_num2,version为version2

b2、存款余额加100后试图更新表update account set account_num=account_num2+100, version=version+1 where user_name="小明" and version=version2

注意在更新记录的时候加了一个where条件version,并同时更新version+1。

1、假如执行顺序还是a1,b1,a2,b2,由于a2更新成功后,version+1变为2,那么b2在试图更新的时候,由于where条件中version=1不符合,则该条更新语句不执行,小明的余额变为950,小华还钱失败;

2、同理,假如执行顺序是a1,b1,b2,a2,小明取钱失败,小华还钱成功,余额变为1100;

3、或者执行顺序是a1,a2,b1,b2,那么小明取钱后余额变为950,version变为2,此时小华还钱,更新仍旧成功,余额变为1050,version变为3,两个人都更新成功。

有人可能会问,情况1和情况2中,都有人未更新成功啊,这怎么办。需要声明的是乐观锁的作用是防止并发时产生数据更新不一致的问题,这里其实已经实现了。至于更新失败后怎么处理,那就需要后台去实现一个重试机制(下一节会展示),这就不在乐观锁的功能范围内了。

实战

下面以一个springboot项目为例,看看乐观锁具体是怎么实现的,其中也会提供一种重试机制。

建一张account表:

CREATE TABLE `account_wallet` (
  `id` int(11) NOT NULL COMMENT '用户钱包主键',
  `user_open_id` varchar(64) DEFAULT NULL COMMENT '用户中心的用户唯一编号',
  `user_amount` decimal(10,5) DEFAULT NULL,
  `create_time` datetime DEFAULT NULL,
  `update_time` datetime DEFAULT NULL,
  `pay_password` varchar(64) DEFAULT NULL,
  `is_open` int(11) DEFAULT NULL COMMENT '0:代表未开启支付密码,1:代表开发支付密码',
  `check_key` varchar(64) DEFAULT NULL COMMENT '平台进行用户余额更改时,首先效验key值,否则无法进行用户余额更改操作',
  `version` int(11) DEFAULT NULL COMMENT '基于mysql乐观锁,解决并发访问',
  PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;

表中插入一条记录:

INSERT INTO `account_wallet` (`id`, `user_open_id`, `user_amount`, `create_time`, `update_time`, `pay_password`, `is_open`, `check_key`, `version`)
VALUES
	(1, '1', 1000.00000, NULL, NULL, NULL, NULL, 'haha', 1);

项目结构如下:

配置信息如下:注意修改数据库连接信息。

# 应用名称
spring.application.name=optimiclock

# 应用服务 WEB 访问端口
server.port=8087

spring.datasource.url=jdbc:mysql://IP:port/demo?characterEncoding=utf8&useUnicode=true&useSSL=false&serverTimezone=GMT%2B8
spring.datasource.username=username
spring.datasource.password=password
spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver

#实体类别名
mybatis.type-aliases-package=com.example.demo.model
#映射文件的位置
mybatis.mapper-locations=classpath:mapper/*.xml

mybatis.configuration.map-underscore-to-camel-case=true

dao层:

我这里使用的mybatis-generator插件直接生成数据库表的mapper,具体使用方法请自行google。

User实体类如下。

@Data
public class User {
    String openId; //账户
    String userName; //用户
    String amount; //存取的数额
    Boolean openType; //true存 false取
}

service层:

public interface TestService {

    AccountWallet selectByOpenId(String openId);

    int updateAccountWallet(AccountWallet record);

    List<User> initUsers();

    void process(User user) throws InterruptedException;
}

 其中selectByOpenId方法用于查询存款记录:

<select id="selectByOpenId" resultType="com.example.demo.model.AccountWallet">
    select
    <include refid="Base_Column_List" />
    from account_wallet
    where user_open_id = #{openId,jdbcType=VARCHAR}
  </select>

updateAccountWallet用于更新存款记录:

<update id="updateAccountWallet">
    <![CDATA[
          	update account_wallet set 
          	user_amount = #{userAmount,jdbcType=DECIMAL}, 
          	version = version + 1 
          	where id =#{id,jdbcType=INTEGER} and version = #{version,jdbcType=INTEGER}
           ]]>
  </update>

initUsers用于初始化用户。我这里为了演示,初始化了10个用户,并随机指定了用户是存或取,金额也随机指定。

public List<User> initUsers() {
        List<User> res = new ArrayList<>();
        Random random = new Random();

        for (int i = 0; i < 10; i++) {
            User user = new User();
            user.setUserName(i + "");
            user.setAmount((String.valueOf(random.nextInt(10) * 5)));//随机指定用户存取的金额
            user.setOpenId("1");
            res.add(user);
            user.setOpenType(random.nextBoolean());//随机指定用户是存还是取
        }

        return res;
    }

process用于模拟存取款操作。

这里介绍下重试机制。首先给用户设定一个重试时长,我这里设定的是35秒,用户在这个时间段内会重复尝试更新数据直到成功或者超时结束。

public void process(User user) throws InterruptedException {
        //用户开抢时间
        long startTime = System.currentTimeMillis();
        Boolean success = false;
        String message = "";
        //while时间内会不断尝试更新直到成功
        while ((startTime + 35000L) >= System.currentTimeMillis()) {
            AccountWallet accountWallet = selectByOpenId("1");
            //cash为用户要存入或取出的金额
            BigDecimal cash = BigDecimal.valueOf(Double.parseDouble(user.getAmount()));
            cash.doubleValue();
            cash.floatValue();
            String add = "+";//+表示存入,-表示取出
            BigDecimal original = accountWallet.getUserAmount();
            if (user.getOpenType()) {
                accountWallet.setUserAmount(accountWallet.getUserAmount().add(cash));
            } else {
                add = "-";
                accountWallet.setUserAmount(accountWallet.getUserAmount().subtract(cash));
            }

            //尝试更新数据库
            int res = updateAccountWallet(accountWallet);
            if (res == 1) {
                success = true;
                message = "成功" + " 基数: " + original + add + cash + " 更新后:" + accountWallet.getUserAmount();
                break;
            }

            //休息后再次尝试更新
            Thread.sleep(10L);
        }
        if (success) {
            System.out.println(message);
        } else {
            System.out.println("失败!");
        }

    }

controller层:这里使用了parallelStream的方式模拟并发。

@RestController
@Slf4j
public class TestController {
    @Autowired
    TestService accountWalletService;

    @PostMapping(value="/test")
    @ResponseBody
    public void test() {
        List<User> users = accountWalletService.initUsers();
        //模拟并发
        users.parallelStream().forEach(b -> {
            try {
                accountWalletService.process(b);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        });

    }
}

程序将模拟10个用户并发操作数据库中的同一条记录,运行程序并调用test接口:

 IDE中打印的消息如下:

 从打印的消息可以看出,10个用户的并发访问都成功了,并且都正确的更新了存款余额。

查看数据库中的记录:

能够看到存款余额正确更新,并且version成功更新了10次。 

好的,关于乐观锁的介绍就到这里,源码在此lisz112/optimicLock

MySQL乐观锁是一种常见的并发控制机制,常用于处理库存相关的业务场景。在这种场景下,经常会有多个用户同时查询和修改库存数量,为了保证数据的准确性和一致性,可以采用乐观锁机制。 乐观锁的原理是,在进行数据更新操作前,先进行数据版本的判断。在库存表中,可以添加一个版本号字段,每次对库存进行修改时,版本号会进行更新。当用户进行库存修改操作时,先查询当前库存数量以及对应的版本号,然后进行修改时,判断查询结果的版本号是否和当前库存表中版本号一致。若一致,则可以执行修改操作,同时更新版本号;若不一致,则说明数据已经被其他用户修改,需要进行相应的处理,如重新查询库存数量或提示用户无法进行修改操作。 在实际实现中,可以使用乐观锁的方式来避免库存并发修改带来的问题。例如,用户A和用户B同时查询到库存为10,并开始执行库存减1的操作。当用户A执行更新操作时,会将版本号更新为1,并将库存减1,此时库存为9。而当用户B执行更新操作时,由于库存表中的版本号已变为1,而查询结果中的版本号仍未0,因此判断版本号不一致,这时用户B可以根据实际情况选择重新查询库存数量或进行其他操作。 乐观锁在处理库存并发修改时,能够有效地保障数据的准确性和一致性,避免了并发操作引起的问题。但需要注意的是,使用乐观锁机制时,要合理处理并发冲突,例如设置重试机制或采用其他的处理策略,以保证数据的正确性和业务的正常进行。
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值