干翻Mybatis源码系列之第十二篇:基于Mybatis Plugins做一个乐观锁

本文涉及的产品
云数据库 RDS MySQL Serverless,0.5-2RCU 50GB
云数据库 RDS MySQL Serverless,价值2615元额度,1个月
简介: 干翻Mybatis源码系列之第十二篇:基于Mybatis Plugins做一个乐观锁


给自己的每日一句

不从恶人的计谋,不站罪人的道路,不坐亵慢人的座位,惟喜爱耶和华的律法,昼夜思想,这人便为有福!他要像一棵树栽在溪水旁,按时候结果子,叶子也不枯干。凡他所做的尽都顺利。

如何找到孙帅本人

本文内容整理自《孙哥说Mybatis系列视频课程》,老师实力十分雄厚,B站搜孙帅可以找到本人,视频中有老师的微信号。

一:前言

1:什么是乐观锁,解决开发中什么问题?

锁:保证多用户并发访问数据库数据安全的一种机制。这里与Java的锁是不一样的,程序中的锁主要是保证多线程访问过程中程序中数据的安全。

乐观锁是一种数据库中的锁,数据库中的锁一种是乐观锁,另外一种是悲观锁。

二:悲观锁

悲观锁是数据库底层为我们提供的锁,引入悲观锁保证数据并发访问的安全。

什么是并发请求:

并发请求指的是,同一时间,多个请求(事务)访问操作了相同的数据。

什么是同一时间:

站在计算机角度,一定有一个微小的前后关系,这个有可能是毫秒或者是纳秒级别。

一个事务过来之后,对一行数据进行操作,如果还没操作完,另外一个事务也进来了,恰好也拿到了这条数据,那么这样就是拿到了脏数据,是不允许的,这里就需要锁了。基于锁,把并行化的机制强行改外串行化。基于悲观锁强行把这条锁住(行数据被称为行锁)。

insert update delete 执行这样的操作,就会加入悲观锁。基于事务的隔离机制也能加上行锁。

MySQL中对于insert update delete 这样的操作会默认加上事务(事务一定会的),执行一个语句前就会开启一个事务,执行完毕后提交事务。这是MySQL默认的事务机制。

1:悲观锁测试

MySQL的事务具有自动处理机制,想要测试这个效果,需要把自动提交改为false set autocommit = false

左侧终端事务没有提交,锁没有释放,右侧终端更新同条数据,只能进行等待,以至于锁超时,需要重新启动事务。

那什么时候可以释放锁呢?事务提交之后。

上述场景补充:

左右两终端默认开启事务后执行同一个update语句,左侧执行后获取到行锁,事务不提交,右侧只能进行等待,所测提交之后,右侧获取到行锁并且执行成功,如果不提交,行锁也不会被释放,提交之后,行锁释放。

2:悲观行锁

查询当中也可以加入悲观锁,可以在select * from t_user where id = 7 for update;这样就可以给这条数据加入悲观行锁。

需要在select * from t_user where id = 7 for update; 后边执行commit之后,就可以释放锁了。

查询方法也是可以或者需要使用事务的

1:使用悲观锁查询锁住数据:for update 必须得有事务 这里是MySQL自己start transaction

2:Mybatis需要使用二级缓存,也是需要有事务。 这里是必须代码里边去start transaction

MySQL默认每一条DML语句都是一个单独的事务,每条语句会自动开启一个事务,语句执行完毕自动提交事务,MySQL默认是自动提交事务。

悲观锁这个事不是我们来操心的,是数据库底层为我们添加的一种锁,他有一种问题,影响并发访问的效率。如果查询的话我们想使用悲观锁,可以加for update 只不过这种没有Id的情况下会锁整张表,这样的话任何一个事务中但凡想要DML语句中到此表中,事务到此处都会停止,因为获取不到锁。

单纯的查询语句select MySQL底层是不会开启事务的。

三:乐观锁

乐观锁和数据库底层没有半毛钱关系,是我们通过程序添加的一种锁。他本质上是应用锁,并发效率很高,但是安全性比较低。我们要知道锁,永远是物料层的才是最保险、最安全的

1:乐观锁的实现原理

本质上就是版本的比对。

两个事务并发访问了某一个行数据,一定会产生并发的问题。乐观锁就是基于版本的比对,我们在一张表当中添加一个新的列。新的列叫做version。这一列当中会有version的初始值,初始值都是0,每更新成功一次这个值都会在这一个字段上+1。

这种机制如何解决并发问题呢?每一次更新的时候都要进行版本的比对,如果版本一致说明没有其他事务对这行数据进行操作,如果版本不一致,说明有其他事务操作了这行数据。

这里边有两个细节。细节1:表中必须得有一个version列,这个version列有一个初始值0,这一行数据每被更新成功一次就会+1。细节2:在实际运行过程中,如果我这个事务,要对这行数据进行更新,那么接下来不是直接去更新数据,而是先去比对版本,如果是版本一致说明没有事务和我并发处理这条数据,然后接下来就去更新。如果对比的版本的不一致。就放弃当前操作,后续再次执行版本比对操作。毕竟,实际开发过程中进行过并发的可能性很低,乐观锁性能很高,我们优先使用乐观锁,乐观锁有问题的话, 我们在切换到悲观锁。

总结:

悲观锁:数据库底层提供的锁,最安全的保证数据库并发机制,手动控制事务,存在并发效率问题

乐观锁:应用锁不涉及数据库底层,并发效率很高,安全性较低。

JPA和Hibernate是天然支持乐观锁的,Mybatis并不支持乐观锁,所以Mybatis当中对这个概念提到的很少。那么我们怎么去实现这个东西呢?

2:乐观锁实现思路

Mybatis原生自己不支持,但是我们自己需要使用,需要自己封装,思路是什么呢?

程序员Mybatis开发过程:

1:entity 添加version字段,存储版本值。

2:别名

3:表 添加vers字段,存储版本值。

4:Dao接口

5:Mapper文件注册

6:API使用

程序员,实现这两部,就可以实用我们的乐观锁了,那么对于封装乐观锁的我们如何去处理乐观锁呢?

1:保证version字段当中初始值为0

用户插入过程中保证数据插入,保证vers = 0

2:每一次更新的过程中,都要把对象中的version属性和表中的vers做对比

3:如果值一致,做更新且vers+1

4:如果值不一致,抛出异常。

我们封装以上这些操作的话,在那里干?

insert 语句,需要获取SQL语句并且实现vers = 0

update语句,需要获取SQL并且实现vers+1

这些显而易见,非常适合在拦截器当中进行使用。

四:实现乐观锁

1:准备一个代码架子

@Intercepts({
        @Signature(type = StatementHandler.class, method = "prepare", args = {Connection.class, Integer.class})
})
public class LockInterceptor extends MyMybatisInterceptorAdapter {
    private static final Logger log = LoggerFactory.getLogger(LockInterceptor.class);
    @Override
    public Object intercept(Invocation invocation) throws Throwable {
        return invocation.proceed();
    }
    @Override
    public void setProperties(Properties properties) {
    }
}

2:最终的成果

@Intercepts({
        @Signature(type = StatementHandler.class, method = "prepare", args = {Connection.class, Integer.class})
})
public class LockInterceptor extends MyMybatisInterceptorAdapter {
    private static final Logger log = LoggerFactory.getLogger(LockInterceptor.class);
    @Override
    public Object intercept(Invocation invocation) throws Throwable {
        if (log.isInfoEnabled())
            log.info("----LockInterceptor------");
        MetaObject metaObject = SystemMetaObject.forObject(invocation);
        String sql = (String) metaObject.getValue("target.delegate.boundSql.sql");
        MappedStatement mappedStatement = (MappedStatement) metaObject.getValue("target.delegate.mappedStatement");
        String id = mappedStatement.getId();
        /*
            在用户进行插入操作时,需要由拦截器 设置vers值0
            🤔 用户书写的Sql语句:insert into t_user (name) values (#{name});
               封装需要干的事    insert into t_user (name,vers) values (#{name},0)
               问题:如何获得 用户书写SQL ?
               解答:String sql = (String) metaObject.getValue("target.delegate.boundSql.sql");
               问题:如何修改sql语句 为其添加vers 值0 ?
               解决:涉及到对原有sql语句操作,JsqlParser
         */
        if (id.indexOf("save") != -1) {
            CCJSqlParserManager parserManager = new CCJSqlParserManager();
            Insert insert = (Insert) parserManager.parse(new StringReader(sql));
            //插入的列 vers  匹配对应的值 0
            //列名字 Columns
            List<Column> columns = insert.getColumns();
            columns.add(new Column("vers"));
            //列的值
            ExpressionList itemsList = (ExpressionList) insert.getItemsList();
            List<Expression> expressions = itemsList.getExpressions();
            expressions.add(new LongValue(0));
            insert.setSetExpressionList(expressions);
            //修改完成sql语句后 新的sql语句 交给Mybatis ---> 继续进行?替换
            metaObject.setValue("target.delegate.boundSql.sql", insert.toString());
        }
         /*
             update t_user set name =?,vers = vers+1 where id = ?
             如果进行update操作:
                1. 在提交update操作时,需要对比此时 对象中的version里面存储的值与数据库中vers字段中的值是否相等
                 1.1 如果不等
                       说明已经有其他用户进行了更新 (存在并发) 抛出异常
                 1.2 如果相等
                       可以进行更新操作,并把对应的vers+1
          */
        if (id.indexOf("update") != -1) {
            CCJSqlParserManager parserManager = new CCJSqlParserManager();
            Update update = (Update) parserManager.parse(new StringReader(sql));
            Table table = update.getTable();
            String tableName = table.getName();
            //id值 一定是更新操作中 User id属性存储
            Integer objectId = (Integer) metaObject.getValue("target.delegate.parameterHandler.parameterObject.id");
            Integer version = (Integer) metaObject.getValue("target.delegate.parameterHandler.parameterObject.version");
            Connection conn = (Connection) invocation.getArgs()[0];
            String selectSql = "select vers from " + tableName + " where id = ?";
            PreparedStatement preparedStatement = conn.prepareStatement(selectSql);
            preparedStatement.setInt(1, objectId);
            ResultSet resultSet = preparedStatement.executeQuery();
            int vers = 0;
            if (resultSet.next()) {
                vers = resultSet.getInt(1);
            }
            System.out.println();
            if (version.intValue() != vers) {
                throw new RuntimeException("版本不一致");
            } else {
                //vers+1
                //正常进行数据库更新
                List<Column> columns = update.getColumns();
                columns.add(new Column("vers"));
                List<Expression> expressions = update.getExpressions();
                expressions.add(new LongValue(vers + 1));
                update.setExpressions(expressions);
                metaObject.setValue("target.delegate.boundSql.sql", update.toString());
            }
        }
        return invocation.proceed();
    }
    @Override
    public void setProperties(Properties properties) {
    }
}

Mysql当中int类型不指定长度的时候,默认是11。

为什么乐观锁不一致的时候,需要抛异常出去,这样做的目的是让用户决定是否重新进行提交。比如,提示用户刷新页面后在提交数据即可。

3:程序bug

当前我们这个代码有没有bug?

事务是交由Mybatis来管理的。即使有多个操作,Mybatis也会根据他自己的操作进行处理。

实际问题存在于A,B线程查询版本号的时候,如果两个线程都没来得及更新,但是都去查版本号去了,那么在做更新的时候又出现了并发,那么怎么解决这个问题呢?

解决方式1:update t_user set name = ? ,vers = vers + 1 where id = ? and vers = version

当然这并不能百分之百解决并发。

五:JSqlParser

我们一定特别注意对JSQLParser的使用。对操作String类型的SQL语句具有很强的作用。

<dependency>
    <groupId>com.github.jsqlparser</groupId>
    <artifactId>jsqlparser</artifactId>
    <version>4.3</version>
</dependency>

六:日志引申

1:日志分类

开发日志:监控、平台 框架运行过程 Log4J logback log4j2

应用日志:业务相关的信息。

谁 在什么时间 做了什么事 成功 失败。

存储在数据库中,建立管理系统,帮助公司分析。

假定,让我们设计这个应用日志的话,应该书写在什么位置?代码应该书写在什么位置?

业务或者是应用日志应该写到Service当中。然后,我们写耦合或者是Aop都是可以的。

我们干预业务操作了几张表,操作了那些表的什么字段,字段值是啥。

最后落点一定是拦截器,基于JsqlParser来实现。

相关实践学习
基于CentOS快速搭建LAMP环境
本教程介绍如何搭建LAMP环境,其中LAMP分别代表Linux、Apache、MySQL和PHP。
全面了解阿里云能为你做什么
阿里云在全球各地部署高效节能的绿色数据中心,利用清洁计算为万物互联的新世界提供源源不断的能源动力,目前开服的区域包括中国(华北、华东、华南、香港)、新加坡、美国(美东、美西)、欧洲、中东、澳大利亚、日本。目前阿里云的产品涵盖弹性计算、数据库、存储与CDN、分析与搜索、云通信、网络、管理与监控、应用服务、互联网中间件、移动服务、视频服务等。通过本课程,来了解阿里云能够为你的业务带来哪些帮助 &nbsp; &nbsp; 相关的阿里云产品:云服务器ECS 云服务器 ECS(Elastic Compute Service)是一种弹性可伸缩的计算服务,助您降低 IT 成本,提升运维效率,使您更专注于核心业务创新。产品详情: https://www.aliyun.com/product/ecs
相关文章
|
4月前
|
安全 Java 应用服务中间件
阿里技术官架构使用总结:Spring+MyBatis源码+Tomcat架构解析等
分享Java技术文以及学习经验也有一段时间了,实际上作为程序员,我们都清楚学习的重要性,毕竟时代在发展,互联网之下,稍有一些落后可能就会被淘汰掉,因此我们需要不断去审视自己,通过学习来让自己得到相应的提升。
|
5月前
|
SQL Java 数据库连接
MyBatis【源码探究 01】mapper.xml文件内<if test>标签判断参数值不等于null和空(当参数值为0)时筛选条件失效原因分析
MyBatis【源码探究 01】mapper.xml文件内<if test>标签判断参数值不等于null和空(当参数值为0)时筛选条件失效原因分析
94 0
MyBatis【源码探究 01】mapper.xml文件内<if test>标签判断参数值不等于null和空(当参数值为0)时筛选条件失效原因分析
|
2月前
|
Java 数据库连接 mybatis
mybatis简单案例源码详细【注释全面】——实体层(User.java)
mybatis简单案例源码详细【注释全面】——实体层(User.java)
14 0
|
3天前
|
SQL Java 数据库连接
一文细说Mybatis八大核心源码
以上 是V哥给大家整理的8大核心组件的全部内容,为什么说选择 Java 就是选择未来,真正爱 Java 的人,一定喜欢深入研究,学习源码只是第一步,要有一杆子捅到操作系统才够刺激。
|
6天前
|
SQL XML Java
Mybatis源码解析
Mybatis源码解析
16 0
|
7天前
|
SQL 缓存 Java
|
20天前
|
XML Java 数据库连接
探秘MyBatis:手写Mapper代理的源码解析与实现
探秘MyBatis:手写Mapper代理的源码解析与实现
21 1
|
20天前
|
SQL Java 数据库连接
深入源码:解密MyBatis数据源设计的精妙机制
深入源码:解密MyBatis数据源设计的精妙机制
32 1
深入源码:解密MyBatis数据源设计的精妙机制
|
2月前
|
Java 数据库连接 mybatis
mybatis简单案例源码详细【注释全面】——Utils层(MybatisUtils.java)
mybatis简单案例源码详细【注释全面】——Utils层(MybatisUtils.java)
13 0
|
2月前
|
Java 数据库连接 mybatis
mybatis简单案例源码详细【注释全面】——测试层(UserMapperTest.java)
mybatis简单案例源码详细【注释全面】——测试层(UserMapperTest.java)
10 0