优秀的编程知识分享平台

网站首页 > 技术文章 正文

Mysql事务和锁相关(mysql事物和锁)

nanyue 2024-07-25 05:54:17 技术文章 14 ℃

1.事务相关

事务定义:事务是必须满足4个条件(ACID):原子性(Atomicity,或称不可分割性)、一致性(Consistency)、隔离性(Isolation,又称独立性 )、持久性(Durability)。

  • 原子性:一个事务(transaction)中的所有操作,要么全部完成,要么全部不完成,不会结束在中间某个环节。事务在执行过程中发生错误,会被回滚(Rollback)到事务开始前的状态,就像这个事务从来没有执行过一样。
  • 一致性:在事务开始之前和事务结束以后,数据库的完整性没有被破坏。这表示写入的资料必须完全符合所有的预设规则,这包含资料的精确度、串联性以及后续数据库可以自发性地完成预定的工作。
  • 隔离性:数据库允许多个并发事务同时对其数据进行读写和修改的能力,隔离性可以防止多个事务并发执行时由于交叉执行而导致数据的不一致。事务隔离分为不同级别,包括读未提交(Read uncommitted)、读提交(read committed)、可重复读(repeatableread)和串行化(Serializable)。
  • 持久性:事务处理结束后,对数据的修改就是永久的,即便系统故障也不会丢失。

事务并发时会发生什么问题?(在不考虑事务隔离情况下)

  • 脏读:脏读是指在一个事务处理过程里读取了另一个未提交的事务中的数据。
  • 不可重读:不可重复读是指在对于数据库中的某个数据,一个事务范围内多次查询却返回了不同的数据值,这是由于在查询间隔,被另一 个事务修改并提交了。
  • 虚读/幻读:指一个线程中的事务读取到了另外一个线程中提交的update的数据/insert数据。

事务的隔离级别

  • Read uncommitted:最低级别,以上情况均无法保证。(读未提交)
  • Read committed:可避免脏读情况发生(读已提交)。
  • Repeatable read:可避免脏读、不可重复读情况的发生。(可重复读,innoDB默认的隔离级别,解决幻读)
  • Serializable:可避免脏读、不可重复读、虚读情况的发生。(串行化)

2.MysqlInnoDB引擎的锁机制

锁分类1:按照锁的使用方式可分为:共享锁、排它锁、意向共享锁、意向排他锁

  • 共享锁/读锁(S):允许一个事务去读一行,阻止其他事务获得相同数据集的排他锁。(其他事务可以读但不能写该数据集)
  • 排他锁/写锁(X) :允许获得排他锁的事务更新数据,阻止其他事务取得相同数据集的共享读锁和排他写锁。(其他事务不能读和写该数据集)
  • 意向共享锁(IS):事务有意向对表中的某些行加共享锁(S锁)
  • 意向排他锁(IX):事务有意向对表中的某些行加排他锁(X锁)

意向锁的作用:意向锁就是协调行锁和表锁之间关系的当我们需要给一个表加表级锁时候,按照有无意向锁分两种情况:

  1. : 遍历表中数据所有行,来判断是否有行锁(问题)
  2. : 只需要判断一次意向锁()是否存在就知道表中是否存在行锁

所以,意向锁的存在大大提高了表锁的加锁效率。

观察以下锁兼容性:

注意:这里的排他 / 共享锁指的都是表锁!!!意向锁不会与行级的共享 / 排他锁互斥!!意向锁之间是兼容的,意向锁和行级锁兼容,这就决定了:意向锁并不会影响到多个事务对不同数据行加排他锁时的并发性。

总结: 意向锁是由数据引擎自己维护的,用户无法手动操作意向锁,在为数据行加共享 / 排他锁之前,InooDB会先获取该数据行所在在数据表的对应意向锁,意向锁的特点是与行锁兼容,与表锁互斥(表级共享锁除外),这个特点决定了意向锁能够在不影响行锁并发行的前提下解决行锁和表锁共存的问题,并且满足事务隔离性的要求。

锁分类:按照锁的算法可分为:Record Lock、Gap Lock、Next-Key Lock

  • Record Lock:记录锁,锁定一个行记录
  • Gap Lock:间隙锁,锁定一个区间
  • Next-Key Lock:记录锁+间隙锁,锁定记录+区间

如下图所示:

总结:不同的事物隔离级别之所有不同的表现就是因为不同的锁算法,对于读未提交和串行化的隔离级别暂不考虑(前者不加锁,后者全程加锁),我们主要分析读已提交和可重复读下隔离级别下的加锁情况,简单概括如下:

  • 读已提交:1. 使用的是 record lock 2. 当然特殊情况下( purge + unique key ),也会有Gap lock
  • 可重复读:1. 使用的是next-key locking 2. next-key lock = record lock + Gap lock

接下来详细总结,对于以下两条sql分别再读已提交和可重复读级别下innoDB引擎的加锁分析:

  • SQL1:select * from t1 where id = 10;
  • SQL2:delete from t1 where id = 10;

其实这两条语句分别对应着快照读和当前读,对于SQL1读取的是快照,不加锁,我们主要分析SQL2。在分析之前首先明确一个常识,由于INNODB的底层数据的索引结构是B+树,并且叶子节点存储的是按照主键id的顺序存储的数据页的本身,所以INNODB的数据和索引其实是存储在一起的,默认存储的索引即是主键索引,也叫做聚簇索引,其它的索引都是辅助索引,并且辅助索引是单独存在的,存储的只有索引数据列,如果查询详情需要查询完辅助索引获取指向主键索引的主键,之后需要再次查询聚簇索引(关于以上索引的知识只是简单介绍,之后会单开一篇文章详细分析)。

读已提交:

  • id为主键:这种情况是最容易分析的,由于主键所在的索引是数据和索引存储在一起的,所以只需要给聚簇索引添加X锁即可,并且主键索引都是唯一索引和读已提交级别的加锁特性所以添加的是记录锁。
  • id是唯一索引:唯一索引也是辅助索引,所以在查询完辅助索引定位到主键索引之后再去查询主键索引,所以需要在聚簇索引树和辅助索引树上分别添加X锁,并且由于是唯一索引和读已提交级别的加锁特性,添加的是记录锁。
  • id是非唯一索引:非唯一索引说明id=10对应的是辅助索引树的多条索引,所以这多条索引需要添加X锁,并且由于读已提交级别的加锁特性,对于多条数据添加的都是记录锁。
  • id无索引:这种情况比较特殊,由于id不是索引列,只能走聚簇索引,加锁的方式是对全部的索引数据加X锁。既不是加表锁,也不是在满足条件的记录上加行锁。 有人可能会问?为什么不是只在满足条件的记录上加锁呢?这是由于MySQL的实现决定的。如果一个条件无法通过索引快速过滤,那么存储引擎层面就会将所有记录加锁后返回,然后由MySQL Server层进行过滤。因此也就把所有的记录,都锁上了。
    注:在实际的实现中,MySQL有一些改进,在MySQL Server过滤条件,发现不满足后,会调用unlock_row方法,把不满足条件的记录放锁 (违背了2PL的约束)。这样做,保证了最后只会持有满足条件记录上的锁,但是每条记录的加锁操作还是不能省略的。

可重复读

  • id为主键:和读已提交一致
  • id为唯一索引:和读已提交一致
  • id为非唯一索引:关键的点来了,这个描述比较啰嗦(详细),大家可以发现对于读已提交的隔离级别,加锁的各种场景下我的描述是“并且由于是唯一索引和读已提交级别的加锁特性,添加的是记录锁。”这个描述其实不太准确,但是也可以看出某些问题,由于是唯一索引所以加锁的肯定是一条,所以锁算法只能使用记录锁(也可以加间隙锁,但是没有必要,INNODB对这方面做了优化,对于单条索引数据只加记录锁),但是如果不是给唯一的索引数据加锁呢?这时就和隔离级别的特性有关系了,对于已提交级别,加的锁都是记录锁,不管给多条还是单条索引数据加锁(参考读已提交id无索引的情况)。但是在可重复读的隔离级别下,给唯一索引加锁使用的是记录锁,但是给多条索引数据加锁使用的是记录锁+间隙锁。这也是可重复读的隔离级别能过解决幻读问题的关键:给索引间的间隙都加上锁了,必然能够防止在加锁的过程当中的数据插入,所以加锁过程中的多次读取的数据都是相同的。再说详细点儿,通过以上说明我们都知道,给辅助索引加锁需要加两个(辅助索引+聚簇索引),那记录锁+间隙锁具体是怎么加的呢?辅助索引是记录锁+间隙锁,聚簇索引是记录锁。对于这个结果我是从网上查来的,但是根据我的理解也是这样:首先肯定没有必要在两颗索引树上都加上记录锁+间隙锁,太浪费,有一个间隙锁已经能够解决幻读问题了。并且如果间隙锁加在聚簇索引上的话,由于聚簇索引的查询量远远大于辅助索引,会极大的影响数据库的查询效率,所以大胆猜测:聚簇索引上只加了记录锁,辅助索引上加了记录锁+间隙锁。(提示:对于数据库原理的猜测,如果没有明确的思路,可以从查询性能方面考虑,因为这是数据库存储的第一要素,面试的时候借鉴,事半功倍)
  • id无索引:对于这种情况,和读已提交的隔离界别类似,唯一的区别是加锁不同,可重复读的隔离界别对所有的索引数据加记录锁+间隙锁。

3.隔离级别和锁之间的关系

通过加锁控制,可以保证数据的一致性,但是同样一条数据,不论用什么样的锁,只可以并发读,并不可以读写并发(因为写的时候加的是排他锁 所以不可以读),这时就要引入数据多版本控制来实现读写并发(MVCC)。

4.MVCC

对于MVCC我们首先要了解几个概念:

  • 快照读:简单的select操作(不包括 select ... lock in share mode, select ... for update);
  • 当前读:select ... lock in share mode,select ... for update,insert,update,delete
  • 数据库隐藏列:6字节的事务ID(DB_TRX_ID),7字节的回滚指针(DB_ROLL_PTR),隐藏id(不指定id时使用默认的id);
  • undo log:Undo 记录某数据被修改的值,可以用来在事务失败时进行 rollback;也可以实现MVCC查询之前版本的数据返回;
  • read view:read view是当前所有事务的一个集合,这个数据结构中存储了当前Read View中最大的ID及最小的ID,根据事物id判断某一条数据的可见性。如下:m_ids: 一个列表, 存储当前系统活跃的事务id (重点), min_trx_id: 存m_ids的最小值, max_trx_id: 系统分配给下一个事务的id,creator_trx_id: 生成readView事务的事务id

MVCC描述:当执行不加锁的sql查询的时候,为了提高查询速度,innoDB默认会去查询数据的历史版本,也就是快照读,快照数据的存储依赖以下字段:

数组组织如下:

当数据被更新的时候,undolog中记录了数据修改之前的版本,以支持MVCC查询和回滚操作,历史版本的定位是通过回滚指针指向:DB_ROLL_PTR,获取到历史版本的数据之后拿到数据执行的事物id:DB_TRX_ID,之后通过数据可见性的比较算法判断当前数据是否可见,如果可见就返回,不可见的话继续通过DB_ROLL_PTR查找上一版本的数据,如此循环,直到找到最近版本的可见数据,最后返回。

详细流程图如下:

接下来为了方便理解献上readview数据结构源码及可见性算法源码:

// Friend declaration
class MVCC;
/** Read view lists the trx ids of those transactions for which a consistent
read should not see the modifications to the database. */
...
class ReadView {
    ...
    private:
        // Prevent copying
        ids_t(const ids_t&);
        ids_t& operator=(const ids_t&);
    private:
        /** Memory for the array */
        value_type* m_ptr;
        /** Number of active elements in the array */
        ulint       m_size;
        /** Size of m_ptr in elements */
        ulint       m_reserved;
        friend class ReadView;
    };
public:
    ReadView();
    ~ReadView();
    /** Check whether transaction id is valid.
    @param[in]  id      transaction id to check
    @param[in]  name        table name */
    static void check_trx_id_sanity(
        trx_id_t        id,
        const table_name_t& name);
// 判断一个修改是否可见
    /** Check whether the changes by id are visible.
    @param[in]  id  transaction id to check against the view
    @param[in]  name    table name
    @return whether the view sees the modifications of id. */
    bool changes_visible(
        trx_id_t        id,
        const table_name_t& name) const
        MY_ATTRIBUTE((warn_unused_result))
    {
        ut_ad(id > 0);
        if (id < m_up_limit_id || id == m_creator_trx_id) {
            return(true);
        }
        check_trx_id_sanity(id, name);
        if (id >= m_low_limit_id) {
            return(false);
        } else if (m_ids.empty()) {
            return(true);
        }
        const ids_t::value_type*    p = m_ids.data();
        return(!std::binary_search(p, p + m_ids.size(), id));
    }
    
private:
    // Disable copying
    ReadView(const ReadView&);
    ReadView& operator=(const ReadView&);
private:
   // 活动事务中的id的最大
    /** The read should not see any transaction with trx id >= this
    value. In other words, this is the "high water mark". */
    trx_id_t    m_low_limit_id;
    // 活动事务id的最小值
    /** The read should see all trx ids which are strictly
    smaller (<) than this value.  In other words, this is the
    low water mark". */
    // 
    trx_id_t    m_up_limit_id;
    /** trx id of creating transaction, set to TRX_ID_MAX for free
    views. */
    trx_id_t    m_creator_trx_id;
    /** Set of RW transactions that was active when this snapshot
    was taken */
    ids_t       m_ids;
    /** The view does not need to see the undo logs for transactions
    whose transaction number is strictly smaller (<) than this value:
    they can be removed in purge if not needed by other views */
    trx_id_t    m_low_limit_no;
    /** AC-NL-RO transaction view that has been "closed". */
    bool        m_closed;
    typedef UT_LIST_NODE_T(ReadView) node_t;
    /** List of read views in trx_sys */
    byte        pad1[64 - sizeof(node_t)];
    node_t      m_view_list;
};

// 判断一个修改是否可见
    /** Check whether the changes by id are visible.
    @param[in]  id  transaction id to check against the view
    @param[in]  name    table name
    @return whether the view sees the modifications of id. */
    bool changes_visible(
        trx_id_t        id,
        const table_name_t& name) const
        MY_ATTRIBUTE((warn_unused_result))
    {
        ut_ad(id > 0);
        if (id < m_up_limit_id || id == m_creator_trx_id) {
            return(true);
        }
        check_trx_id_sanity(id, name);
        if (id >= m_low_limit_id) {
            return(false);
        } else if (m_ids.empty()) {
            return(true);
        }
        const ids_t::value_type*    p = m_ids.data();
        return(!std::binary_search(p, p + m_ids.size(), id));
    }

可见性总结如下:

  1. 数据事务ID <up_limit_id 则显示:如果数据事务ID小于read view中的最小活跃事务ID,则可以肯定该数据是在当前事务启之前就已经存在了的,所以可以显示。
  2. 数据事务ID>=low_limit_id 则不显示:如果数据事务ID大于read view 中的当前系统的最大事务ID,则说明该数据是在当前read view 创建之后才产生的,所以数据不予显示。
  3. up_limit_id <数据事务ID<low_limit_id 则与活跃事务集合trx_ids里匹配:如果数据的事务ID大于最小的活跃事务ID,同时又小于等于系统最大的事务ID,这种情况就说明这个数据有可能是在当前事务开始的时候还没有提交的。所以这时候我们需要把数据的事务ID与当前read view 中的活跃事务集合trx_ids 匹配:
  • 情况1: 如果事务ID不存在于trx_ids 集合(则说明read view产生的时候事务已经commit了),这种情况数据则可以显示。
  • 情况2 如果事务ID存在trx_ids则说明read view产生的时候数据还没有提交,但是如果数据的事务ID等于creator_trx_id ,那么说明这个数据就是当前事务自己生成的,自己生成的数据自己当然能看见,所以这种情况下此数据也是可以显示的。
  • 情况3 如果事务ID既存在trx_ids而且又不等于creator_trx_id那就说明read view产生的时候数据还没有提交,又不是自己生成的,所以这种情况下此数据不能显示。

4.不满足read view条件时候,从undo log里面获取数据:当数据的事务ID不满足read view条件时候,从undo log里面获取数据的历史版本,然后数据历史版本事务号回头再来和read view 条件匹配 ,直到找到一条满足条件的历史数据,或者找不到则返回空结果;

接下来继续讨论一个问题:我们知道了read view是什么,那么它是合适产生的,这个问题的答案就是读已提交和可重复读两个隔离级别差异性的关键所在。先说表象:读已提交和可重复度最大的差别是是否解决了数据的可重复读问题。前者否,或者是。原因就是两种隔离级别readView的生成时机不同,可重复读是在事物开始的时候就生成了readView,之后只要事物不提交,不管进行多少次查询,那么readView都是同一个,所以查询出的可见的数据都是相同的(数据的历史版本数据肯定也不会发生变化,可见性的本质就是通过历史版本数据和readView进行比较);读已提交是在sql执行查询的时候生成readView,并且同一个事物的多次查询都会生成新的readView,所以在事物提交之前,如果有新的事物对数进行更新,那么新的事物也会生成在readView当中,所以会将新更新的数据返回。

最近发表
标签列表