插件实战:手写MyBatis乐观锁插件

悲观锁与乐观锁

这两种锁都是为了应对并发下访问和操作数据库中的数据而设计,乐观锁适用于并发量不高、数据冲突概率低的场景,通过版本号或者时间戳实现并发控制;而悲观锁适用于并发量较高、数据冲突概率较高的场景,通过加锁实现并发控制。在实际应用中,可以根据业务场景和性能要求选择合适的并发控制机制。

这两种锁的前提都是需要开启数据库事务,也可以设置手动提交方式。

悲观锁

悲观锁是数据库层面提供的加锁机制,也是最安全的保证数据库并发的方式,从数据库层面实现从并行到串行的转变,当前客户端锁住的行或者表,其他客户端要操作同一个目标数据的时候就会进入等待状态。

在 MySQL 中,表锁和行锁是两种常见的锁机制,用于控制对数据库中数据的并发访问和修改。它们有不同的工作方式和应用场景。

表级锁

表级锁是对整个表进行锁定,当一个事务对表进行操作时,会锁定整个表,其他事务无法同时对该表进行修改操作。表级锁具有以下特点:

  • 粒度粗:表级锁锁定的是整个表,因此锁定的粒度比较粗,会影响到整个表的并发访问。
  • 并发性低:由于锁定的粒度较大,当一个事务对表进行操作时,其他事务需要等待锁释放,导致并发性较低。
  • 适用场景:适用于对表进行全表扫描或者大量数据操作的场景,比如备份、导入导出等操作。
加锁方式
  1. 开始事务:首先,你需要在数据库中启动一个事务,可以使用 START TRANSACTION 或者其他相应的命令。
  2. 获取表锁LOCK TABLES 表名 WRITE;
  3. 处理数据:在获得锁定的数据上执行你需要的操作,比如更新、删除或者插入操作。
  4. 释放表锁UNLOCK TABLES;
  5. 提交或回滚事务:根据操作的结果,你可以选择提交事务或者回滚事务。
行级锁

行级锁是对数据库表中的行进行锁定,当一个事务对某行进行操作时,只会锁定该行,其他行不受影响,可以并发访问和修改。行级锁具有以下特点:

  • 粒度细:行级锁锁定的是单个行,因此锁定的粒度比较细,只会影响到被锁定的行。
  • 并发性高:由于锁定的粒度较小,多个事务可以同时对不同行进行操作,提高了并发性能。
  • 适用场景:适用于对表中特定行进行频繁操作的场景,比如在线交易、订单处理等。.

MySQL 会根据需要自动将行级锁升级为表级锁,以减少锁的数量和管理开销。例如,在执行一个范围查询时,MySQL 会将涉及的行级锁升级为表级锁。

  1. 锁等待冲突:当多个事务同时请求锁定相同数据行时,会发生锁等待冲突。如果锁等待时间过长,会导致性能下降。在这种情况下,MySQL 可能会自动升级为表锁,以减少锁冲突。
  2. 锁升级策略:MySQL 的优化器会根据事务的需求和锁定情况选择锁的级别。如果事务需要锁定多个行,MySQL 可能会选择升级为表锁,以减少锁管理开销。
  3. 表级操作:当事务需要执行一些需要锁定整个表的操作时,例如ALTER TABLETRUNCATE TABLE等,MySQL 会自动升级为表锁,以确保操作的原子性。
  4. 显式请求表锁:有时,开发人员可能会显式请求表锁,以确保一组操作的原子性,而不是逐行锁定数据。
  5. 存储引擎限制:某些 MySQL 存储引擎例如 MyISAM,只支持表级锁,因此无论如何都会使用表锁。
加锁方式
  1. 在开启事务的前提下,每次执行 INSERT、UPDATE、DELETE 都会为操作的行自动加上悲观锁。之后提交或者回滚后就会释放锁。
  2. 如果是执行查询 SELECT 操作,可以使用FOR UPDATE关键字来实现上锁。所以不一定只有增删改操作才需要使用事务,在前面介绍的 MyBatis 二级查询缓存也是需要开启事务,当前提交的时候才会将数据存储在缓存中。

以下是使用悲观锁的一般步骤:

  1. 开始事务:首先,你需要在数据库中启动一个事务,可以使用 START TRANSACTION 或者其他相应的命令。

  2. 执行查询并加锁:在事务中执行一个 SELECT 查询,并在需要锁定的行上添加 FOR UPDATE 关键字。例如:

    START TRANSACTION;
    SELECT * FROM your_table WHERE some_condition FOR UPDATE;
    

    这将会锁定满足 some_condition 条件的行,防止其他事务对它们进行修改。

  3. 处理数据:在获得锁定的数据上执行你需要的操作,比如更新、删除或者插入操作。

  4. 提交或回滚事务:根据操作的结果,你可以选择提交事务或者回滚事务。

  5. 释放锁:当事务结束后,MySQL 会自动释放所有由该事务持有的锁。


需要注意的是,悲观锁会导致其他事务对被锁定的行进行阻塞,直到锁定的事务完成。因此,在使用悲观锁时,要确保锁定的范围和时间尽可能短,以最小化对系统性能的影响,并且要避免死锁的发生。

悲观锁虽然最安全,但是对于效率来讲可能并不是那么好,试想如果执行SELECT * FROM your_table FOR UPDATE,那岂不是把整个表都锁住了,这样别的客户端或者线程想要操作这个表的数据都需要等待,无疑是大大降低了系统的性能。

乐观锁

乐观锁是从应用程序层面设计的锁,实际执行过程中并没有锁定数据库中的数据(当然,默认每次执行 INSERT、UPDATE、DELETE 都会为操作的行自动加上悲观锁),乐观锁的基本思想是假定在数据被修改时不会发生冲突,因此不会阻塞其他事务对数据的访问。它通过在数据上添加版本号或者时间戳等机制来实现,以便在更新时检查数据是否被其他事务修改过,这是一个非常典型的 CAS 算法的实现。

  • 工作原理:当一个事务要更新数据时,先读取数据并记录下版本号或者时间戳,然后在更新数据时检查这个版本号或者时间戳是否与之前读取的一致。如果一致,则说明数据没有被其他事务修改过,可以执行更新操作;如果不一致,则说明数据已经被其他事务修改过,更新操作失败,需要进行相应的处理(比如重试或者抛出异常)。
  • 优点
    • 不会阻塞其他事务的读操作,提高了并发性能。
    • 减少了数据库锁的使用,降低了系统开销。
  • 缺点
    • 需要额外的版本号或者时间戳字段来实现,增加了数据的存储空间和复杂度。
    • 在并发量较大时,由于需要对数据进行多次读取和比较,可能导致更新失败的概率增加,需要进行重试操作。

乐观锁的实现

程序的并发执行本身的概率其实非常小,所以我们在设计的时候优先使用乐观锁,心态乐观些。

定义比对字段

这里创建字段可以使用版本 version_code 维护一个数字,每次更新操作都让数字 +1,也可以使用一个时间戳字段 update_time,每次操作都更新。

我这里就以定义版本号的方式进行设计:即数据库中增加 version_code 字段,实体增加 versionCode 属性。

@Data
@NoArgsConstructor
@AllArgsConstructor
public class Account implements Serializable {

    private Integer id;

    private String name;

    private String password;

    private String sex;

    /**
     * 乐观锁版本号
     */
    private Integer versionCode;
}

定义拦截器

思路:拦截器需要拦截两类 SQL:

  1. INSERT:版本号这个属性应不需要程序员手动维护,应该插入数据的时候自动的设置为起始版本号 0,此处需要修改原始的插入 SQL,添加版本号为 0 的部分。
  2. UPDATE:要对数据进行更新操作,应该先拿当前对象的版本号和数据库中最新的版本号比较:
    • 如果一致:则正常更新,随后控制版本号递增,此处需要修改原始的更新语句,添加版本号底层的 SQL 部分。
    • 如果不一致:则抛出异常。

基于上面的第一种思路,我们细想一下真的好吗?我们在 UPDATE 之前要先执行一下 SELECT,那么有没有可能多个客户端都想更新这条数据,谁也都还没提交,所以拿到的相同的最新版本,也正好也自己原来拿到的版本是一致的,所以就先后进行了更新操作。这种显然还是有很大的问题,所以我们尽量还是不要再更新前去查询最新的版本,而是将版本递增的逻辑拼接到原来要执行的 SQL 语句上面,如下:

update account set name = ?, password = ?, sex = ?, version_code = version_code + 1  where id = ? and version_code = '获取的版本'

之后可以判断更新的结果是否为 1,如果为 1 表示正常更新,如果为 0 表示版本冲突,抛出异常!

package world.xuewei.plugin.lock;

import net.sf.jsqlparser.expression.Expression;
import net.sf.jsqlparser.expression.LongValue;
import net.sf.jsqlparser.expression.operators.conditional.AndExpression;
import net.sf.jsqlparser.expression.operators.relational.EqualsTo;
import net.sf.jsqlparser.expression.operators.relational.ExpressionList;
import net.sf.jsqlparser.parser.CCJSqlParserUtil;
import net.sf.jsqlparser.schema.Column;
import net.sf.jsqlparser.statement.insert.Insert;
import net.sf.jsqlparser.statement.update.Update;
import net.sf.jsqlparser.statement.update.UpdateSet;
import org.apache.ibatis.executor.statement.StatementHandler;
import org.apache.ibatis.mapping.SqlCommandType;
import org.apache.ibatis.plugin.*;
import org.apache.ibatis.reflection.MetaObject;
import org.apache.ibatis.reflection.SystemMetaObject;

import java.sql.Connection;
import java.sql.Statement;
import java.util.Properties;

/**
 * 乐观锁插件
 *
 * @author 薛伟
 */
@Intercepts({@Signature(type = StatementHandler.class, method = "prepare", args = {Connection.class, Integer.class}), @Signature(type = StatementHandler.class, method = "update", args = {Statement.class}),})
public class HappyLockPlugin implements Interceptor {

    @Override
    public Object intercept(Invocation invocation) throws Throwable {
        String name = invocation.getMethod().getName();
        if ("prepare".equals(name)) {
            MetaObject metaObject = SystemMetaObject.forObject(invocation);
            String sql = (String) metaObject.getValue("target.delegate.boundSql.sql");
            SqlCommandType commandType = (SqlCommandType) metaObject.getValue("target.delegate.mappedStatement.sqlCommandType");
            if (commandType == SqlCommandType.SELECT || commandType == SqlCommandType.DELETE) {
                // 跳过查询、删除类方法
                return invocation.proceed();
            }
            if (commandType == SqlCommandType.INSERT) {
                // 插入操作,设置默认的版本号为 0
                Insert insert = (Insert) CCJSqlParserUtil.parse(sql);
                ExpressionList<Expression> values = (ExpressionList<Expression>) insert.getValues().getExpressions();
                // 新增一列 version_code,默认为 0
                insert.getColumns().add(new Column("version_code"));
                values.add(new LongValue(0));
                metaObject.setValue("target.delegate.boundSql.sql", insert.toString());
                return invocation.proceed();
            }
            // 取出当前获取的版本
            Integer curVersion = (Integer) metaObject.getValue("target.delegate.parameterHandler.parameterObject.versionCode");
            // 更新操作
            Update update = (Update) CCJSqlParserUtil.parse(sql);
            // 添加更新版本的条件
            UpdateSet updateSet = new UpdateSet();
            updateSet.add(new Column("version_code"), new Column("version_code + 1"));
            update.getUpdateSets().add(updateSet);
            // 更新 Where 条件
            AndExpression expression = new AndExpression();
            expression.withLeftExpression(update.getWhere());
            EqualsTo equalsTo = new EqualsTo();
            equalsTo.setLeftExpression(new Column("version_code"));
            equalsTo.setRightExpression(new LongValue(curVersion));
            expression.withRightExpression(equalsTo);
            update.setWhere(expression);
            // 将 update 应用到 MyBatis
            metaObject.setValue("target.delegate.boundSql.sql", update.toString());
            return invocation.proceed();
        } else {
            Integer proceed = (Integer) invocation.proceed();
            if (proceed != 1) {
                throw new RuntimeException("乐观锁生效,版本过时!");
            }
            return proceed;
        }
    }

    @Override
    public Object plugin(Object target) {
        return Plugin.wrap(target, this);
    }

    @Override
    public void setProperties(Properties properties) {

    }
}

  • 4
    点赞
  • 9
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值