X先生

X先生 查看完整档案

深圳编辑深圳大学  |  计算机与软件学院 编辑腾讯深圳  |  后台开发工程师 编辑填写个人主网站
编辑

腾讯TEG研发管理部小小后台攻城狮一枚,负责腾讯敏捷产品研发平台TAPD的基础功能的开发和维护,热爱技术,喜欢分享,欢迎与我交流~

个人动态

X先生 发布了文章 · 9月28日

深入理解MySQL中事务隔离级别的实现原理

前言

说到数据库事务,大家脑子里一定很容易蹦出一堆事务的相关知识,如事务的ACID特性,隔离级别,解决的问题(脏读,不可重复读,幻读)等等,但是可能很少有人真正的清楚事务的这些特性又是怎么实现的,为什么要有四个隔离级别。

今天我们就先来聊聊MySQL中事务的隔离性的实现原理,后续还会继续出文章分析其他特性的实现原理。

当然MySQL博大精深,文章疏漏之处在所难免,欢迎批评指正。

说明

MySQL的事务实现逻辑是位于引擎层的,并且不是所有的引擎都支持事务的,下面的说明都是以InnoDB引擎为基准。

定义

隔离性(isolation)指的是不同事务先后提交并执行后,最终呈现出来的效果是串行的,也就是说,对于事务来说,它在执行过程中,感知到的数据变化应该只有自己操作引起的,不存在其他事务引发的数据变化。

隔离性解决的是并发事务出现的问题

标准SQL隔离级别

隔离性最简单的实现方式就是各个事务都串行执行了,如果前面的事务还没有执行完毕,后面的事务就都等待。但是这样的实现方式很明显并发效率不高,并不适合在实际环境中使用。

为了解决上述问题,实现不同程度的并发控制,SQL的标准制定者提出了不同的隔离级别:未提交读(read uncommitted)、提交读(read committed)、可重复读(repeatable read)、序列化读(serializable)。其中最高级隔离级别就是序列化读,而在其他隔离级别中,由于事务是并发执行的,所以或多或少允许出现一些问题。见以下的矩阵表:

隔离级别(+:允许出现,-:不允许出现)脏读不可重复读幻读
未提交读                                 +        +              +        
提交读                                   -        +              +        
可重复读                                 -        -              +        
序列化读                                 -        -              -        

注意,MySQL的InnoDB引擎在提交读级别通过MVCC解决了不可重复读的问题,在可重复读级别通过间隙锁解决了幻读问题,具体见下面的分析

实现原理

标准SQL事务隔离级别实现原理

我们上面遇到的问题其实就是并发事务下的控制问题,解决并发事务的最常见方式就是悲观并发控制了(也就是数据库中的锁)。标准SQL事务隔离级别的实现是依赖锁的,我们来看下具体是怎么实现的:

事务隔离级别   实现方式                                                     
未提交读(RU)事务对当前被读取的数据不加锁;

事务在更新某数据的瞬间(就是发生更新的瞬间),必须先对其加行级共享锁,直到事务结束才释放。
提交读(RC)   事务对当前被读取的数据加行级共享锁(当读到时才加锁),一旦读完该行,立即释放该行级共享锁;

事务在更新某数据的瞬间(就是发生更新的瞬间),必须先对其加行级排他锁,直到事务结束才释放。
可重复读(RR)事务在读取某数据的瞬间(就是开始读取的瞬间),必须先对其加行级共享锁,直到事务结束才释放;

事务在更新某数据的瞬间(就是发生更新的瞬间),必须先对其加行级排他锁,直到事务结束才释放。
序列化读(S)  事务在读取数据时,必须先对其加表级共享锁 ,直到事务结束才释放;

事务在更新数据时,必须先对其加表级排他锁 ,直到事务结束才释放。

可以看到,在只使用锁来实现隔离级别的控制的时候,需要频繁的加锁解锁,而且很容易发生读写的冲突(例如在RC级别下,事务A更新了数据行1,事务B则在事务A提交前读取数据行1都要等待事务A提交并释放锁)。

为了不加锁解决读写冲突的问题,MySQL引入了MVCC机制,详细可见我以前的分析文章:一文读懂数据库中的乐观锁和悲观锁和MVCC

InnoDB事务隔离级别实现原理

在往下分析之前,我们有几个概念需要先了解下:

1、锁定读和一致性非锁定读

锁定读:在一个事务中,主动给读加锁,如SELECT ... LOCK IN SHARE MODE 和 SELECT ... FOR UPDATE。分别加上了行共享锁和行排他锁。锁的分类可见我以前的分析文章:你应该了解的MySQL锁分类)。

https://dev.mysql.com/doc/refman/8.0/en/innodb-locking-reads.html

一致性非锁定读:InnoDB使用MVCC向事务的查询提供某个时间点的数据库快照。查询会看到在该时间点之前提交的事务所做的更改,而不会看到稍后或未提交的事务所做的更改(本事务除外)。也就是说在开始了事务之后,事务看到的数据就都是事务开启那一刻的数据了,其他事务的后续修改不会在本次事务中可见。

Consistent read是InnoDB在RC和RR隔离级别处理SELECT语句的默认模式。一致性非锁定读不会对其访问的表设置任何锁,因此,在对表执行一致性非锁定读的同时,其它事务可以同时并发的读取或者修改它们。

https://dev.mysql.com/doc/refman/8.0/en/innodb-consistent-read.html

2、当前读和快照读

当前读

读取的是最新版本,像UPDATE、DELETE、INSERT、SELECT ...  LOCK IN SHARE MODE、SELECT ... FOR UPDATE这些操作都是一种当前读,为什么叫当前读?就是它读取的是记录的最新版本,读取时还要保证其他并发事务不能修改当前记录,会对读取的记录进行加锁。

快照读

读取的是快照版本,也就是历史版本,像不加锁的SELECT操作就是快照读,即不加锁的非阻塞读;快照读的前提是隔离级别不是未提交读和序列化读级别,因为未提交读总是读取最新的数据行,而不是符合当前事务版本的数据行,而序列化读则会对表加锁

3、隐式锁定和显式锁定

隐式锁定

InnoDB在事务执行过程中,使用两阶段锁协议(不主动进行显示锁定的情况):

  • 随时都可以执行锁定,InnoDB会根据隔离级别在需要的时候自动加锁;
  • 锁只有在执行commit或者rollback的时候才会释放,并且所有的锁都是在同一时刻被释放。

显式锁定

  • InnoDB也支持通过特定的语句进行显示锁定(存储引擎层)
select ... lock in share mode //共享锁
select ... for update //排他锁
  • MySQL Server层的显示锁定:
lock table
unlock table

了解完上面的概念后,我们来看下InnoDB的事务具体是怎么实现的(下面的读都指的是非主动加锁的select)

事务隔离级别   实现方式                                                     
未提交读(RU)事务对当前被读取的数据不加锁,都是当前读

事务在更新某数据的瞬间(就是发生更新的瞬间),必须先对其加行级共享锁,直到事务结束才释放。
提交读(RC)   事务对当前被读取的数据不加锁,且是快照读

事务在更新某数据的瞬间(就是发生更新的瞬间),必须先对其加行级排他锁(Record),直到事务结束才释放。

通过快照,在这个级别MySQL就解决了不可重复读的问题
可重复读(RR)事务对当前被读取的数据不加锁,且是快照读

事务在更新某数据的瞬间(就是发生更新的瞬间),必须先对其加行级排他锁(Record,GAP,Next-Key),直到事务结束才释放。

通过间隙锁,在这个级别MySQL就解决了幻读的问题
序列化读(S)  事务在读取数据时,必须先对其加表级共享锁 ,直到事务结束才释放,都是当前读

事务在更新数据时,必须先对其加表级排他锁 ,直到事务结束才释放。

可以看到,InnoDB通过MVCC很好的解决了读写冲突的问题,而且提前一个级别就解决了标准级别下会出现的幻读和不可重复读问题,大大提升了数据库的并发能力。

一些常见误区

幻读到底包不包括了delete的情况?

不可重复读:前后多次读取一行,数据内容不一致,针对其他事务的update和delete操作。为了解决这个问题,使用行共享锁,锁定到事务结束(也就是RR级别,当然MySQL使用MVCC在RC级别就解决了这个问题)

幻读:当同一个查询在不同时间生成不同的行集合时就是出现了幻读,针对的是其他事务的insert操作,为了解决这个问题,锁定整个表到事务结束(也就是S级别,当然MySQL使用间隙锁在RR级别就解决了这个问题)

网上很多文章提到幻读和提交读的时候,有的说幻读包括了delete的情况,有的说delete应该属于提交读的问题,那到底真相如何呢?我们实际来看下MySQL的官方文档(如下)

The so-called phantom problem occurs within a transaction when the same query produces different sets of rows at different times. For example, if a SELECT) is executed twice, but returns a row the second time that was not returned the first time, the row is a “phantom” row.

https://dev.mysql.com/doc/refman/5.7/en/innodb-next-key-locking.html

可以看到,幻读针对的是结果集前后发生变化,所以看起来delete的情况应该归为幻读,但是我们实际分析下上面列出的标准SQL在RR级别的实现原理就知道,标准SQL的RR级别是会对查到的数据行加行共享锁,所以这时候其他事务想删除这些数据行其实是做不到的,所以在RR下,不会出现因delete而出现幻读现象,也就是幻读不包含delete的情况。

MVCC能解决了幻读问题?

网上很多文章会说MVCC或者MVCC+间隙锁解决了幻读问题,实际上MVCC并不能解决幻读问题。如以下的例子:

begin;

#假设users表为空,下面查出来的数据为空

select * from users; #没有加锁

#此时另一个事务提交了,且插入了一条id=1的数据

select * from users; #读快照,查出来的数据为空

update users set name='mysql' where id=1;#update是当前读,所以更新成功,并生成一个更新的快照

select * from users; #读快照,查出来id为1的一条记录,因为MVCC可以查到当前事务生成的快照

commit;

可以看到前后查出来的数据行不一致,发生了幻读。所以说只有MVCC是不能解决幻读问题的,解决幻读问题靠的是间隙锁。如下:

begin;

#假设users表为空,下面查出来的数据为空

select * from users lock in share mode; #加上共享锁

#此时另一个事务B想提交且插入了一条id=1的数据,由于有间隙锁,所以要等待

select * from users; #读快照,查出来的数据为空

update users set name='mysql' where id=1;#update是当前读,由于不存在数据,不进行更新

select * from users; #读快照,查出来的数据为空

commit;

#事务B提交成功并插入数据

注意,RR级别下想解决幻读问题,需要我们显式加锁,不然查询的时候还是不会加锁的

版权声明

转载请注明作者和文章出处
作者: X先生
https://segmentfault.com/a/1190000025156465

觉得不错的话请帮忙收藏点赞~

查看原文

赞 13 收藏 10 评论 0

X先生 发布了文章 · 9月7日

干货!如何平稳用户无感知的完成系统重构升级

前言

我们在实际开发系统的过程当中,很有可能会遇到需要进行系统重构升级的情况,需要重构的原因可能是之前的设计不合理,导致现在维护起来非常的困难,也有可能是现在的业务发展非常迅速,需要进行分库分表了又或者之前用的是单机的本地的文件存储,现在需要用到统一的网络存储。总而言之,就是当初的系统设计已经不符合现在发展需要了,需要进行重构和升级。

而这其中会可能会涉及到代码逻辑的变更,数据存储的变更(如DB或者文件存储等)或者第三方接口的变更。在这样一个新旧的切换过程当中,怎么样才能让用户无感知,平稳地进行过渡?

有人说可能说可以停服,然后迁数据,迁完后切新逻辑,然而先不说会有一段不可接受的不可用时间,就说在迁移过程中,我们如何保证能一次迁移成功呢?再退一步,就算数据迁移成功了,但是如果代码逻辑有漏洞,我们又该如何快速回退到旧版本呢?这可不单单是切回旧代码就好了,要知道这段时间可能产生了新版本的数据,这些新数据可也要迁回旧版本。

重构升级系统的过程可能会遇到这么多问题,那我们有什么办法可以平稳且用户无感知地完成系统升级吗?今天就给大家提供一个通用的系统重构升级的框架。里面很多具体的逻辑得按不同系统的实际情况来,但是整体思路却是通用且可靠的。

场景模拟

我们先来模拟一个简单的场景,并看看实际情况中应该如何操作。

假设我们一开始有个users表存储学生数据,表结构以及一些数据如下:

idnameage
1张三18
2李四19
3王五17

后面随着业务发展,我们需要记录学生的语文成绩,然后我们在users表加了score字段,如下

idnameagescore
1张三1898
2李四1976
3王五1780

过一段时间我们发现又需要记录数学分数了,后面还可能需要记录英语分数等等。这时候不可能一次次加字段,现有的表设计又极不满足我们的需求,所以只好对现在的系统进行重构升级了。我们想用两个表来存数据:

students

idnameage

mark表

idtypeuser_idscore

这时候我们会面临几个问题:

  • 代码逻辑的切换:包括增删改查
  • 表结构的变更
  • 数据的迁移
  • 迁移过程中用户无感知

如何来升级呢?

步骤

  1. 在旧代码的增删改查的地方写好新逻辑和建好新的表,但是一开始线上并不调用新逻辑和写入新表,仅仅在测试环境调用和写入,线上仍调用旧逻辑。如:

    if($is_dev){
      //新逻辑:如增删改查students表和mark表
    }else{
      //旧逻辑:如增删改查users表
    }
  2. 测试新逻辑没问题了,线上同时双写新旧表(包括增删改),如:

    //新写入逻辑:如增删改students表和mark表
    //旧写入逻辑:如增删改users表
    
    
    if($is_dev){
      //新读取逻辑:如查students表和mark表
    }else{
      //旧读取逻辑:如查users表
    }
  3. 进行数据迁移,把原来users表的数据迁到students表和mark
  4. 然后让系统运行一段时间,然后再对users表和students表、mark表的数据进行对账,如果有数据不一致的情况,说明我们之前双写的时候有遗漏的地方,需要补全,如果没有不一致,说明我们写入的地方都已经对齐了,现在新旧数据是已经能一直保持一致了,那下面就是切读的地方了。
  5. 把读的地方改成只读新的,如下

    //新写入逻辑:如增删改students表和mark表
    //旧写入逻辑:如增删改users表
    
    //新读取逻辑:如查students表和mark表
  6. 系统运行一段时间,没发现问题之后把写的改成只写新的,如下

    //新写入逻辑:如增删改students表和mark表
    
    //新读取逻辑:如查students表和mark表

完成之后我们的系统就平稳的完成迁移了。

分析

整个过程可能看起来很繁琐,没关系,我们一步一步来分析其必要性。

  • 第一步是先写好新逻辑,并进行充分的测试,这当然是必要的啦,算是升级前的准备
  • 第二步是我们升级的起始步骤了,我们需要双写数据。在代码发布过程中,即使有的访问到了新代码双写了,有的还是访问旧代码单写也没关系,因为会有第三步数据迁移的过程。这一步和第三步是为了保证数据无感知迁移(不用停服迁移数据),保证迁移后数据的一致性。
  • 第四步是对账过程,是为了找出我们写入遗漏的地方,这是因为我们不能保证对于一个庞大的系统,你一次改造就能改到了所有的地方,所以留一段时间对写入进行对账是非常有必要的,有则改之。
  • 上面的第四步保证了我们新旧的数据已经是一致的了,这时候第五步我们就可以很放心的把我们读从旧的地方改到读新的地方了。
  • 第六步也是很重要的,我们要先让系统平稳运行一段时间再切成单读新表,因为这个过程中,如果我们发现系统新逻辑有问题,我们可以很多地切回读旧逻辑,因为我们写入还是双写的,旧数据还是有写入,直接切回去是没有问题的。这就避免了无法回滚,或者回滚后数据丢失的问题。
  • 注意第五步和第六步最好分开进行,也就是说不要一次就改单读和单写,不然在发布代码的过程中,某些机器还是旧代码,这是用户访问系统的时候可能先访问到新机器写入了数据,然后又访问到旧机器读的是旧表,这时候就会读不到刚写入的数据。这种临界情况虽然极端,但是在大访问量的基数还是可能出现的

总结

可以看到,上面的系统升级重构的思路是比较细致的,但是确实是非常平稳,且不需要停服就能完成升级,即使系统非常的复杂,升级重构的逻辑和存储结构大变样也能适用。当然在实际过程中大家也可以根据实际情况(小系统小改动)进行一些步骤的合并或者缩短时间。

版权声明

转载请注明作者和文章出处
作者: X先生
https://segmentfault.com/a/1190000023924409

觉得不错的话请帮忙收藏点赞~

查看原文

赞 5 收藏 2 评论 0

X先生 发布了文章 · 9月2日

你应该了解的MySQL锁分类

MySQL中的锁

锁是为了解决并发环境下资源竞争的手段,其中乐观并发控制,悲观并发控制和多版本并发控制是数据库并发控制主要采用的技术手段(具体可见我之前的文章),而MySQL中的锁就是其中的悲观并发控制。

MySQL中的锁有很多种类,我们可以按照下面方式来进行分类。

按读写

从数据库的读写的角度来分,数据库的锁可以分为分为以下几种:

  • 独占锁:又称排它锁、X锁、写锁。X锁不能和其他锁兼容,只要有事务对数据上加了任何锁,其他事务就不能对这些数据再放置X了,同时某个事务放置了X锁之后,其他事务就不能再加其他任何锁了,只有获取排他锁的事务是可以对数据进行读取和修改。
  • 共享锁:又称读锁、S锁。S锁与S锁兼容,可以同时放置。
  • 更新锁:又称U锁。它允许再加S锁,但不允许其他事务再施加U锁或X锁,当被读取的数据要被更新时,则升级S锁为X锁。U锁的优点是允许事务A读取数据的同时不阻塞其它事务,并同时确保事务A自从上次读取数据后数据没有被更改,因此可以减少X锁和S锁的冲突,同时避免使用S锁后再升级为X锁造成的死锁现象。注意,MySQL并不支持U锁,SQLServer才支持U锁。

兼容性矩阵如下(+ 代表兼容, -代表不兼容)

右侧是已加的锁XSU
X---
S-++
U-+-

按粒度

MySQL支持不同级别的锁,其锁定的数据的范围也不同,也即我们常说的锁的粒度。MySQL有三种锁级别:行级锁、页级锁、表级锁。不同的存储引擎支持不同的锁粒度,例如MyISAM和MEMORY存储引擎采用的是表级锁,页级锁仅被BDB存储引擎支持,InnoDB存储引擎支持行级锁和表级锁,默认情况下是采用行级锁。

特点

表级锁:开销小,加锁快;不会出现死锁;锁定粒度大,发生锁冲突的概率最高,并发度最低。数据库引擎总是一次性同时获取所有需要的锁以及总是按相同的顺序获取表锁从而避免死锁。
行级锁:开销大,加锁慢;会出现死锁;锁定粒度最小,发生锁冲突的概率最低,并发度也最高。行锁总是逐步获得的,因此会出现死锁。
页面锁:开销和加锁时间界于表锁和行锁之间;会出现死锁;锁定粒度界于表锁和行锁之间,并发度一般。

下面详细介绍行锁和表锁,页锁由于使用得较少就不介绍了。

行锁

按行对数据进行加锁。InnoDB行锁是通过给索引上的索引项加锁来实现的,Innodb一定存在聚簇索引,行锁最终都会落到聚簇索引上,通过非聚簇索引查询的时候,先锁非聚簇索引,然后再锁聚簇索引。如果一个where语句里面既有聚簇索引,又有二级索引,则会先锁聚簇索引,再锁二级索引。由于是分步加锁的,因此可能会有死锁发生。

MySQL的行锁对S、X锁上做了一些更精确的细分,使得行锁的粒度更细小,可以减少冲突,这就是被称为“precise mode”的兼容矩阵。(该矩阵没有出现在官方文档上,是有人通过Mysql lock0lock.c:lock_rec_has_to_wait源代码推测出来的。)

行锁兼容矩阵

  • 间隙锁(Gap Lock):只锁间隙,前开后开区间(a,b),对索引的间隙加锁,防止其他事务插入数据。
  • 记录锁(Record Lock):只锁记录,特定几行记录。
  • 临键锁(Next-Key Lock):同时锁住记录和间隙,前开后闭区间(a,b]。
  • 插入意图锁(Insert Intention Lock):插入时使用的锁。在代码中,插入意图锁,实际上是GAP锁上加了一个LOCK_INSERT_INTENTION的标记。
右侧是已加的锁(+ 代表兼容, -代表不兼容)GRNI
G++++
R++
N++
I++

S锁和S锁是完全兼容的,因此在判别兼容性时不需要对比精确模式。精确模式的检测,用在S、X和X、X之间。从这个矩阵可以看到几个特点:

  • INSERT操作之间不会有冲突:你插入你的,我插入我的。
  • GAP,Next-Key会阻止Insert:插入的数据正好在区间内,不允许插入。
  • GAP和Record,Next-Key不会冲突
  • Record和Record、Next-Key之间相互冲突。
  • 已有的Insert锁不阻止任何准备加的锁。
  • 间隙锁(无论是S还是X)只会阻塞insert操作。

注意点

  • 对于记录锁,列必须是唯一索引列或者主键列,查询语句必须为精确匹配,如“=”,否则记录锁会退化为临键锁。
  • 间隙锁和临键锁基于非唯一索引,在唯一索引列上不存在间隙锁和临键锁。

表锁与锁表的误区

只有正确通过索引条件检索数据(没有索引失效的情况),InnoDB才会使用行级锁,否则InnoDB对表中的所有记录加锁,也就是将锁住整个表。注意,这里说的是锁住整个表,但是Innodb并不是使用表锁来锁住表的,而是使用了下面介绍的Next-Key Lock来锁住整个表。网上很多的说法都是说用表锁,然而实际上并不是,我们可以通过下面的例子来看看。

假设我们有以下的数据(MySQL8):

mysql> select * from users;
+----+------+-----+
| id | name | age |
+----+------+-----+
|  1 | a    | 1   |
|  2 | a    | 1   |
|  3 | a    | 1   |
|  4 | a    | 1   |
|  5 | a    | 1   |
+----+------+-----+

方法一:

我们使用表锁锁表,并查看引擎的状态

mysql> begin;
Query OK, 0 rows affected (0.00 sec)

mysql> lock tables users write;
Query OK, 0 rows affected (0.00 sec)

mysql>  show engine innodb status\G
...
------------
TRANSACTIONS
------------
Trx id counter 4863
Purge done for trx's n:o < 4862 undo n:o < 0 state: running but idle
History list length 911
LIST OF TRANSACTIONS FOR EACH SESSION:
---TRANSACTION 281479760456232, not started
mysql tables in use 1, locked 1   ###############注意这里
0 lock struct(s), heap size 1136, 0 row lock(s)
...

然后我们再通过非索引的字段查询来加锁,并查看引擎的状态

## 先解锁上次的表锁
mysql> unlock tables;
Query OK, 0 rows affected (0.00 sec)

mysql> commit;
Query OK, 0 rows affected (0.00 sec)

mysql> begin;
Query OK, 0 rows affected (0.00 sec)

mysql> select * from users where name = 'a' for update;

mysql>  show engine innodb status\G
...
------------
TRANSACTIONS
------------
Trx id counter 4864
Purge done for trx's n:o < 4862 undo n:o < 0 state: running but idle
History list length 911
LIST OF TRANSACTIONS FOR EACH SESSION:
---TRANSACTION 4863, ACTIVE 37 sec
2 lock struct(s), heap size 1136, 6 row lock(s)    ###############注意这里
...

然后我们再删除id为2,3,4的数据,然后在通过非索引的字段查询来加锁,并查看引擎的状态

mysql> delete from users where id in (2,3,4);
Query OK, 3 rows affected (0.00 sec)

mysql> commit;
Query OK, 0 rows affected (0.00 sec)

mysql> begin;
Query OK, 0 rows affected (0.00 sec)

mysql> select * from users where name = 'a' for update;

mysql>  show engine innodb status\G
...
------------
TRANSACTIONS
------------
Trx id counter 4870
Purge done for trx's n:o < 4869 undo n:o < 0 state: running but idle
History list length 914
LIST OF TRANSACTIONS FOR EACH SESSION:
---TRANSACTION 4869, ACTIVE 9 sec
2 lock struct(s), heap size 1136, 3 row lock(s)   ###############注意这里
...

可以看到这里使用了表锁和因为没法用索引锁定特定行而转而锁住整个表是不一样的。从第二次和第三次的操作来看,lock住的row也是不同的,这是因为两者间隙的个数不同,所以可以看到使用的并不是表锁,而是Next-Key Lock。第一次锁住了(-∞,1],(1,2],(2,3],(3,4],(4,5],(5,∞],第二次锁住了(-∞,1],(1,5],(5,∞]。

方法二:

也可以通过以下语句来查看锁的信息,也可以知道用的是行锁,且是锁住了区间(插入不了数据)和记录,所以是Next-Key Lock。

mysql> select ENGINE_TRANSACTION_ID,LOCK_TYPE,LOCK_MODE from performance_schema.data_locks where ENGINE_TRANSACTION_ID in (你的事务id);
+-----------------------+-----------+-----------+
| ENGINE_TRANSACTION_ID | LOCK_TYPE | LOCK_MODE |
+-----------------------+-----------+-----------+
|                  4889 | TABLE     | IX        |
|                  4889 | RECORD    | X         |
|                  4889 | RECORD    | X         |
|                  4889 | RECORD    | X         |
+-----------------------+-----------+-----------+
10 rows in set (0.00 sec)

LOCK_TYPE:对于InnoDB,可选值为 RECORD(行锁), TABLE(表锁)

LOCK_MODE:对于InnoDB,可选值为S[,GAP], X[,GAP], IS[,GAP],IX[,GAP], AUTO_INC和UNKNOWN。除了AUTO_INC和UNKNOWN,其他锁定模式都包含了GAP锁(如果存在)。

具体可见 MySQL文档:https://dev.mysql.com/doc/ref...

表级锁

直接对整个表加锁,影响表中所有记录,表读锁和表写锁的兼容性见上面的分析。

MySQL中除了表读锁和表写锁之外,还存在一种特殊的表锁:意向锁,这是为了解决不同粒度的锁的兼容性判断而存在的。

意向锁

因为锁的粒度不同,表锁的范围覆盖了行锁的范围,所以表锁和行锁会产生冲突,例如事务A对表中某一行数据加了行锁,然后事务B想加表锁,正常来说是应该要冲突的。如果只有行锁的话,要判断是否冲突就得遍历每一行数据了,这样的效率实在不高,因此我们就有了意向表锁。

意向锁的主要目的是为了使得 行锁表锁 共存,事务在申请行锁前,必须先申请表的意向锁,成功后再申请行锁。注意:申请意向锁的动作是数据库完成的,不需要开发者来申请。

意向锁是表级锁,但是却表示事务正在读或写某一行记录,而不是整个表, 所以意向锁之间不会产生冲突,真正的冲突在加行锁时检查。

意向锁分为意向读锁(IS)和意向写锁(IX)。

表锁的兼容性矩阵

右侧是已加的锁(+ 代表兼容, -代表不兼容)ISIXSX
IS+++
IX++
S++
X

参考资料

https://www.cnblogs.com/rjzhe...
https://dev.mysql.com/doc/ref...

版权声明

转载请注明作者和文章出处
作者: X先生
https://segmentfault.com/a/1190000023869573

觉得不错的话请帮忙收藏点赞~

查看原文

赞 24 收藏 18 评论 0

X先生 发布了文章 · 7月29日

git cherry-pick:挑选指定commit来合并

前言

在我们使用Git进行日常开发的过程中,常常需要进行的操作就是代码合并了。常见的操做命令是 git merge branch-name,这个命令会合并的是整个分支的commit,然而有时候我们需要的可能是仅仅某一个 commit或者某几个commit,这时候就需要用到git cherry-pick了。

git cherry-pick的作用就如它的名字一样,精心挑选。我们可以精心挑选其他分支上的 commit 合并到当前的分支上来。

原理

git cherry-pick 可以把其他分支的某个commit应用到当前分支,并且自动生成一个新的 commit 进行提交,因此这两次commit的哈希值是不一样的,属于不同的commit

基本用法

单个commit合并

git cherry-pick commit-hash/branch-name

如果使用的是哈希值,则会把对应的commit合并过来,如果是分支名,则会把对应分支的最新一次commit合并过来。

多个commit合并

# 1、 分散的commit
git cherry-pick commit-hash1 commit-hash2

# 2、连续的commit
# Git 1.7.2 版本以后,新增了支持批量cherry-pick 
# 可以将一个连续的时间序列内的连续commit,进行cherry-pick操作。

# 合并(start,end]之间的提交,不包含start
git cherry-pick start-commit-hash..end-commit-hash 

# 合并[start,end]之间的提交,包含start
git cherry-pick start-commit-hash^..end-commit-hash 

注意
无论是对单个 commit 进行 cherry-pick ,还是批量处理,注意一定要根据时间线,依照 commit 的先后顺序来处理,否则会有意想不到的问题。

如何处理冲突

代码合并不可避免的就是会遇到代码冲突了,git merge会遇到冲突,同样的git cherry-pick也会遇到代码冲突,那么遇到代码冲突的时候,该如何处理呢?

遇到冲突的时候,Git会给出报错信息,并停下来,要求用户解決 conflict 的问题。Git会把所有冲突的文件列在Unmerged paths的地方,可以通过git status查看,如下图。
在这里插入图片描述
此时我们有以下处理方案:

  • 解决冲突

    • 修改冲突的地方,并通过命令git add .把文件重新加入暂存区。
    • 继续合并,git cherry-pick --continue
  • 回退所有修改:git cherry-pick --abort,此时会回到操作前的样子
  • 单纯退出cherry-pickgit cherry-pick --quit,此时不会回到操作前的状态

常用配置项

-e:修改提交信息,如果不修改,则使用合并过来的commit的提交信息
-x:标记来源commit,会在提交信息里标记来源的commit哈希,方便以后追查。
-n:只修改工作区和暂存区的代码,而不产生新的commit。这时候可以自己提交或者做其他修改后提交

Enjoy it !

版权声明

转载请注明作者和文章出处
作者: X先生
https://segmentfault.com/a/1190000023414791
查看原文

赞 0 收藏 0 评论 0

X先生 发布了文章 · 7月28日

保障服务稳定之服务限流

一、前言

对于一个系统而言,最重要的要求之一肯定就是服务的稳定性了,一个不稳定的系统可能给企业带来巨大的损失,包括经济和品牌等等方面的损失。

我们都希望系统能稳定可靠地对外提供服务,响应快,不宕机,不故障,但是在实际情况中,常常会遇到一些异常的情况,这就考验我们系统的稳定性了。

今天就来讲讲保障服务稳定性的手段之一的服务限流。

二、解决的问题

我们系统运行过程中有时候可能会遇到突发异常大流量,如果系统无法正确处理这些突然涌入大量请求,就会导致系统轻则响应慢,经常超时,重则导致整个系统宕机,因此这就要求我们系统能以一定的策略处理大流量的涌入,这样才不对被突发大流量压垮,导致完全无法对外提供服务。

注意,这里大流量说的是突发异常大流量,是非正常情况,所以我们的处理策略是对部分请求进行丢弃或者排队处理,保证我们系统对外还是可用的,而非全部请求都需要处理完。而对于系统正常的普通流量来说,如果其请求量逐渐达到了我们系统的负载能力的上限的话,这时候需要进行的就是服务的扩容,而不是限流并丢弃请求了。

我们系统可能遇到的突发大流量的场景有很多,但统一的表现都是,某些接口请求量激增,导致超过了系统的处理能力了,例如:

  • 突发热点事件(例如微博热搜)
  • 爬虫
  • 恶意攻击
  • 恶意刷单(如12306)

···

面对突发大流量,我们系统能使用的手段之一就是服务限流了。限流是通过对一个时间段处理内的请求量进行限制来保护系统,一旦达到限制速率则可以丢弃请求,从而控制了系统处理的请求量不会超过其处理能力。

限流可能在整个网络请求过程的各个层面发生,例如nginx,业务代码层等,这里主要介绍的是限流的思想,也就是限流算法,并给出业务层的代码实现例子。

三、限流算法

1、计数器算法

计数器限流算法是比较简单粗暴的算法,主要通过一个或者多个计数器来统计一段时间内的请求总量,然后判断是否超过限制,超过则进行限流,不超过则对应的计数器数量加1。

计数器限流算法又可以分为固定窗口和滑动窗口两种。

固定窗口

固定窗口计数器限流算法是统计固定一个时间窗口内的请求总数来判断是否进行限流,例如限制每分钟请求总数为100,则可以通过一个计数器来统计当前分钟的请求总数,每来一个请求判断当前分钟对应的计数器的数量,没有超过限制则在当前分钟对应的计数器加1,超过则拒绝请求。

PHP实现逻辑如下:

/**
 * 固定窗口计数器限流算法
 * @param $key string 限流依据,例如uid,url等
 * @param $time int 限流时间段,单位秒
 * @param $limit int 限流总数
 */
function limit($key, $time, $limit) {

    //当前时间所在分片
    $current_segment=floor(time() / $time);
    //按当前时间和限流参数生成key
    $current_key = $key . '_' . $time . '_' . $limit . '_' . $current_segment;

    $redis = new Redis();
    //key不存在才设置,且设置过期时间
    $redis->set($current_key, 0, ['nx', 'ex' => $time]);
    $current = $redis->incr($current_key);

    //为了解决请求并发的问题,代码实现上要先加再判断
    if ($current > $limit) {
        return false;
    }
    return true;
}

缺点

固定窗口计数器限流算法实现起来虽然很简单,但是有一个十分致命的问题,那就是临界突刺问题:最后一秒和最开始1秒的流量集中一起,会出现大量流量。

计数器的限制数量判断是按时间段的,在两个时间段的交界时间点,限制数量的当前值会发生一个抖动的变化,从而使瞬间流量突破了我们期望的限制。例如以下的情况:

可以看到在0:59的时候,如果突然来了100个请求,这时候当前值是100,而到了1:00的时候,因为是下一个时间段了,当前值陡降到0,这时候又进来100个请求,都能通过限流判断,虽然两个时间段平均下来还是没超过限制,但是在临界时间点的请求量却达到了两倍之多,这种情况下就可能压垮我们的系统。

滑动窗口

上面会出现突刺的问题其实就在于固定窗口算法的窗口时间跨度太大,且是固定不变的,为了解决突刺的问题,我们就有了滑动窗口计数器限流算法。

滑动窗口算法是固定窗口算法的优化版,主要有两个特点:

  • 划分多个小的时间段,各时间段各自进行计数。
  • 根据当前时间,动态往前滑动来计算时间窗口范围,合并计算总数。

可以看到,每次时间往后,窗口也会动态往后滑动,从而丢弃一些更早期的计数数据,从而实现总体计数的平稳过度。当滑动窗口划分的格子越多,那么滑动窗口的滑动就越平滑,限流的统计就会越精确。事实上,固定窗口算法就是只划分成一个格子的滑动窗口算法。

PHP实现逻辑如下:

/**
 * 滑动窗口计数器限流算法
 * @param $key string 限流依据,例如uid,url等
 * @param $time int 限流时间段,单位秒
 * @param $limit int 限流总数
 * @param $segments_num int 分段个数
 */
function limit($key, $time, $limit, $segments_num) {

    //小分片时间长度
    $segments_time=floor($time/$segments_num);
    //当前时间所在小分片
    $current_segment=floor(time() / $segments_time);
    //按限流时间段生成key
    $current_key = $key . '_' . $time . '_' . $limit . '_' . $current_segment;
    
    $redis = new Redis();
    //先更新当前时间所在小分片计数,key不存在才设置,且设置过期时间
    $redis->set($current_key, 0, ['nx', 'ex' => $time]);
    $current = $redis->incr($current_key);

    for($window=$segments_time;$window<$time;$window+=$segments_time){
        $current_segment=$current_segment-1;
        $tmp_key = $key . '_' . $time . '_' . $limit . '_' . $current_segment;
        //计算时间窗口内的总数
        $current+=intval($redis->get($tmp_key));
        if ($current > $limit) {
            //超过限制的话要回滚本次加的次数
            $redis->decr($current_key);
            return false;
        }
    }
    
    return true;
}

缺点

滑动窗口限流算法虽然可以保证任意时间窗口内接口请求次数都不会超过最大限流值,但是相对来说对系统的瞬时处理能力还是没有考虑到,无法防止在更细的时间粒度上访问过于集中的问题,例如在同一时刻(同一秒)大量请求涌入,还是可能会超过系统负荷能力。

2、漏桶算法

漏桶算法就是一种从系统的处理能力出发进行限流的算法。类似生活用到的漏斗,上面进水,下面有个小口出水,当请求进来时,相当于水倒入漏斗,然后从下端小口慢慢匀速的流出。不管上面流量多大,下面流出的速度始终保持不变,超过漏斗容量的则丢弃。漏桶算法以固定的速率释放访问请求(即请求通过),直到漏桶为空。

漏桶算法有两个关键数据:桶的容量V和流出的速率R。假设每个请求的平均处理时间是S,最大超时时间是SS,则V/R+S<=SS。

可以使用队列来实现,队列设置最大容量,访问请求先进入队列,队列满了的话就丢弃丢弃后续请求,然后通过另外一个woker以固定速率从队列出口拿请求去处理。具体实现逻辑不展示了。

缺点
漏桶算法的缺陷很明显,由于出口的处理速率是固定的,当短时间内有大量的突发请求时,即便此时服务器没有任何负载,每个请求也都得在队列中等待一段时间才能被响应,因此漏桶算法无法应对突发流量。

3、令牌桶算法

令牌桶算法也是有一个桶,但是它不是通过限制漏出的请求来控制流量,而是通过控制桶的令牌的生成数量来达到限流的目的的。令牌桶定时往桶里面丢一定的令牌,令牌桶满了就不再往里面加令牌。每来一个请求就要先在桶里拿一个令牌,拿到令牌则通过,拿不到则拒绝。

当访问量小于令牌桶的生成速率时,令牌桶可以慢慢积累令牌直到桶满,这样当短时间的突发访问量来时,其积累的令牌数保证了大量请求都能立刻拿到令牌进行后续处理。当访问量持续大量流入时,积累的令牌被消耗完了之后,后续请求又依赖于一定速率产生的新令牌,这时候就变成类似漏桶算法那样的固定流量限制。

由此可见,相比于漏桶算法,令牌桶算法能够在限制调用的平均速率的同时还允许一定程度的突发调用。

PHP实现逻辑如下:

/**
 * 令牌桶限流算法
 * @param $key string 限流依据,例如uid,url等
 * @param $rate float 令牌生成速率,每秒$rate个
 * @param $volume int 容量
 * @return bool
 */
function limit($key, $rate, $volume) {

    //按限流参数生成key
    $current_key = $key . '_' . $rate . '_' . $volume;

    $redis = new Redis();
    $time=time();
    
    //没有则初始化
    $redis->hSetNx($current_key, 'num', $volume);
    $redis->hSetNx($current_key, 'time', $time);

    //以下逻辑在高并发情况下要用lua脚本或者加分布式锁,这里仅用于说明算法的逻辑,就不考虑并发情况了
    //计算从上次到现在,需要添加的令牌数量
    $last=$redis->hMGet($current_key,['num','time']);
    $last_time=$last['time'];
    $last_num=$last['num'];
    $incr=($time-$last_time)*$rate;
    $current=min($volume,($last_num+$incr));//计算当前令牌数
    
    if($current>0){
        $redis->hMSet($current_key,[
            'num'=>$current-1,
            'time'=>time()
        ]);
        return true;
    }
    
    return false;
}

上面的实现方案是令牌按时间回复数量,事实上令牌的生成也可以通过另外的服务去生成,这样可以按一定策略去调控令牌的生成速率。

版权声明

转载请注明作者和文章出处
作者: X先生
https://segmentfault.com/a/1190000023411052
查看原文

赞 2 收藏 1 评论 0

X先生 发布了文章 · 7月28日

linux上强大的字符串匹配工具详解-grep

1. grep 是什么

grep 是用于匹配输入数据中符合条件的字符串的工具,其匹配过程支持正则表达式,因而匹配能力非常强大。

grep 可以从文件或者标准输入设备中读取数据,若不指定任何文件名称,或是所给予的文件名为 -,则 grep 会从标准输入设备读取数据,否则从文件读取数据进行匹配。

2. 怎么用

grep 的命令格式如下:

 grep [option] pattern file [file2…]


3. 能匹配什么

我们先来看看 grep 能匹配什么,也就是 pattern 参数支持哪些形式。

3.1 普通全匹配

这也是最普通的字符串匹配了,直接匹配 pattern 所指的字符串。例如,

grep apple file.txt

#匹配结果如下,会直接列出匹配的行
apple
apple

3.2 正则表达式匹配

我们上面也说到了,grep强大的匹配能力就在于其支持正则表达式,下面我们来看看 grep 支持的正则表达式语法有哪些。

首先,grep 默认支持的是以下正则表达式。

位置限定匹配

  • 匹配行开头:^
grep ^a file.txt 

匹配a开头的行,注意是要该行的开头是a才会匹配。如果不是在开头出现,即使中间出现了也不会匹配该行。

  • 匹配行结尾:$
grep a$ file.txt

匹配a结尾的行,注意是要该行的结尾是a才会匹配。

  • 匹配单词开头:\<
grep '\<app' #匹配app开头的单词所在的行,例如apple,注意要有引号
  • 匹配单词结尾:\>
grep 'le\>' #匹配le结尾的单词所在的行,例如apple,注意要有引号
  • 单词锁定匹配:b
grep '\bgrep\b'  #只匹配单词grep,例如不会匹配到grepa 

字符匹配

.        grep .a file.txt #匹配任意一个字符 例如 aa,ba等

[]        grep "[abc]c" file.txt #匹配[]里的任意一个字符,例如ac或者bc或者cc,注意加引号
        grep "[a-z]a" file.txt #匹配a-z间的26个字母任意一个字符,例如aa
    
[^]        grep "[^ab]a" #匹配除ab之外的任意一个字符,例如da
    
\w        grep "\w"  file.txt #匹配文字和数字字符,也就是[A-Za-z0-9]

\W        grep "\W"  file.txt #\w的反置形式,匹配一个或多个非单词字符,如点号句号等

次数限定匹配

*        grep "a*b" file.text   # *前面的字符重复0到多次,例如b,ab,aab

\{m\}    grep "x\{m\}" file.text  #重复字符x,m次,如:grep '0\{3\}'匹配包含3个0的行  

\{m,\}    grep "x\{m,\}" file.text #重复字符x,至少m次,如:'0\{5,\}'匹配至少有5个0的行

\{m,n\}    grep "x\{m,n\}"  #重复字符x,至少m次,不多于n次,如:'0\{5,10\}'匹配5--10个0的行

拓展匹配模式

除了上面默认支持的模式之外,grep 还支持拓展匹配模式,拓展匹配模式要加参数 -E,支持的拓展匹配模式如下:

?        grep -E 'go?d' file.txt  #?匹配0个或1个在其之前的字符,例如这里匹配gd,god

+        grep -E 'go+d' file.txt #?匹配1个或多个在其之前的字符,例如这里匹配god,good等

()        grep -E 'g(oo)d' file.text #匹配括号里的字符串,一般都是和其他匹配模式一起使用,例如 grep -E 'g(oo)?d' file.text

|        grep -E 'god|good' file.txt #匹配被|分隔的多个字符串,例如此例匹配god或者good

注意点

  • 对于标准grep,如果在扩展元字符前面加,grep会自动启用扩展选项-E。例如,
grep 'go\?d' file.txt
  • 当我们想要把上面的匹配模式所用到的字符当做普通字符来匹配,需要用到转义字符"\",不过如果这些特殊字符是位于"[]"当做的时候,大部分都会自动转义为普通字符了,除了"-"或者"^"等极少数字符以外。


4. option参数

了解了 grep 能匹配哪些数据之后,我们再来了解下 grep 可用的参数,grep 的参数主要用来影响查找的过程以及打印结果的。

4.1 影响查找过程

-a            将二进制文档以文本的方式来查找

-d <动作>     当指定要查找的含有目录(例如 grep apple ./*),必须使用这项参数,否则grep指令将回报信息并停止动作。其中动作支持,skip:跳过目录,recurse:递归读取目录的数据

-E             开启对拓展匹配模式的支持,如上面的例子

-f            指定匹配模式规则文件,其内容含有一个或多个匹配模式规则,格式为每行一个匹配模式规则。

-F            等同于fgrep命令,也就是fast grep,会把所有的字符都看作普通字符,也就是说正则表达式中的所有字符表示回其自身的字面意义,不再特殊。

-i            忽略字符大小写的差别

-r/-R        此参数的效果和指定"-d recurse"参数相同。

-w            单词匹配,等同于 "\<word\>"或者"\bword\b"

-y            忽略关键字符的大小写。(跟-i参数相同)

4.2 影响打印结果


-A <num>    除了显示符合模式的那一列之外,再显示该行之后num行的内容

-B <num>    除了显示符合模式的那一列之外,再显示该行之前num行的内容

-b            在匹配到行的开头标示该行的第一个字符前面总共多少byte数据

-color        以特定颜色高亮显示匹配关键字

-c            仅显示匹配行的总行数

-C <num>    除了匹配的那一行之外,并显示该行之前后各num行的内容,其中C是可以省略的,可以直接 grep -4 apple file.txt

-h            在显示匹配的那一行之前,不显示该行所属的文件名称(不加这个参数,匹配多个文件的时候会显示命中文件的名字)

-H            在显示匹配的那一行之前,表示该行所属的文件名称(不加这个参数,匹配单个文件的时候不会显示命中文件的名字)

-l            只显示命中的文件的名称

-L            只显示没命中的文件的名称

-n             显示命中的行所在的行数

-o            只显示匹配的部分,不显示该行其他的部分

-P           使用perl的正则表达式语法,因为perl的正则更加多元化,能实现更加复杂的场景。典型用法是匹配指定字符串之间的字符。(-e或-E是匹配扩展正则表达式,-P是匹配perl正则表达式)

-q            不显示任何信息

-s            不显示错误信息

-v            显示不包含匹配文本的所有行

-V            显示版本信息

-x            只显示整行都符合的列。

参考资料

https://zh.wikipedia.org/wiki/Grep#egrep%E5%92%8Cfgrep
https://www.runoob.com/linux/linux-comm-grep.html
https://www.cnblogs.com/kevingrace/p/9299232.html


Enjoy it !

版权声明

转载请注明作者和文章出处
作者: X先生
https://segmentfault.com/a/1190000023398646
查看原文

赞 0 收藏 1 评论 0

X先生 发布了文章 · 7月28日

好用的shell通配符

前言

我们在使用 shell 执行任务的过程中,常常会遇到需要处理一批数据的情况,如果我们一个一个的传递参数就会非常的麻烦,这时候就需要用到 shell 的通配符功能了。例如rm *.txt可以删除当前目录下所有的 txt 文件。

功能

shell 通配符起到的是拓展参数的功能,注意 shell 通配符是由 shell 处理的,而不是用到参数的命令或者语句处理的。

例如对于 rm *.txt,shell 在参数遇到通配符的时候,会把这个通配符当做路径或者文件的匹配模式去磁盘上搜索所有的匹配项。

如果存在匹配,则把所有的匹配项替换到参数去,例如上面的命令最终的形式可能是 rm a.txt b.txtrm命令拿到的是实际的文件列表,而不是*.txt

如果不存在匹配或者无法识别该模式,则shell会将该通配符作为一个普通字符传递给命令,然后再由命令去处理。例如如果我们目录下没有任何 txt 文件,执行上面的命令就会报错:no matches found: *.txt

跨目录匹配
通配符只能匹配单层目录,如果要跨目录匹配,则要这样子写:

rm */*.txt

注意,上面的写法只能匹配一级子目录下的 txt 文件,没有办法匹配当前目录以及二级子目录下的文件

如果要匹配当前目录和一级子目录下的txt文件,则要用到多个通配符组合,例如如下命令

ls *{\/*,}.txt

注意,以上命令在当前目录或者一级目录之一没有txt文件的时候,也会报错。

通配符

shell 通配符看起来很像正则表达式,然而并不是正则表达式,它的功能比正则表达式要弱,只支持下面几种通配符形式。

*

匹配 0 或多个字符

?

匹配任意一个字符

[]

匹配 [] 中的任意单一字符,例如[abc]匹配a、b、c中的任何一个字符。[]支持范围匹配,例如 [a-z]匹配所有小写字母。

{,}

匹配{}中被,分隔的任意一个子字符串。例如{AA,BB,CC}.txt匹配到 AA.txtBB.txtCC.txt{}也支持范围匹配,例如{A..Z}匹配所有大写字母

{}和其他通配符不同的地方在于,即使没有匹配到数据,{}依然会展开。例如

# 如果我们目录下没有文件A,B,下面的命令会报错:no matches found: [AB]
echo [A-B]
# 下面的命令则会输出:A B
echo {A-B}

{}支持嵌套,因此可以组合成复杂的模式。例如

echo {a{a..c},b{b..c}}
#输出aa ab ac bb bc

[!]和[^]

匹配除了 [] 中的其他所有字符,也即不匹配[]中的所有字符。

Enjoy it !

版权声明

转载请注明作者和文章出处
作者: X先生
https://segmentfault.com/a/1190000023398532
查看原文

赞 0 收藏 1 评论 0

X先生 发布了文章 · 7月22日

一文读懂数据库中的乐观锁和悲观锁和MVCC

前言

在数据库的实际使用过程中,我们常常会遇到不希望数据被同时写或者读的情景,例如秒杀场景下,两个请求同时读到系统还有库存1个,然后又先后把库存更新为0,这时候就会出现超卖的情况,这时候货物的实际库存和我们的记录就会对应不上了。

为了解决这种资源竞争导致的数据不一致等问题,我们需要有一种机制来进行保证数据的正确访问和修改,而在数据库中,这种机制就是数据库的并发控制。其中乐观并发控制,悲观并发控制和多版本并发控制是数据库并发控制主要采用的技术手段。

悲观并发控制

本质

维基百科:在关系数据库管理系统里,悲观并发控制(又名“悲观锁”,Pessimistic Concurrency Control,缩写“PCC”)是一种并发控制的方法。它可以阻止一个事务以影响其他用户的方式来修改数据。如果一个事务执行的操作读某行数据应用了锁,那只有当这个事务把锁释放,其他事务才能够执行与该锁冲突的操作。

事实上我们常说的悲观锁并不是一种实际的锁,而是一种并发控制的思想,悲观并发控制对于数据被修改持悲观的态度,认为数据被外界访问时,必然会产生冲突,所以在数据处理的过程中都采用加锁的方式来保证对资源的独占。

数据库的锁机制其实都是基于悲观并发控制的观点进行实现的,而且按照实际使用情况,数据库的锁又可以分为许多种类,具体可以见我后面的文章。

实现方式

数据库悲观锁的加锁流程大致如下:

  • 开始事务后,按照操作类型给需要加锁的数据申请加某一类锁:例如共享行锁等
  • 加锁成功则继续后面的操作,如果数据已经被加了其他的锁,而且和现在要加的锁冲突,则会加锁失败(例如已经加了排他锁),此时需等待其他的锁释放(可能出现死锁)
  • 完成事务后释放所加的锁

优缺点

优点:
悲观并发控制采取的是保守策略:“先取锁,成功了才访问数据”,这保证了数据获取和修改都是有序进行的,因此适合在写多读少的环境中使用。当然使用悲观锁无法维持非常高的性能,但是在乐观锁也无法提供更好的性能前提下,悲观锁却可以做到保证数据的安全性。

缺点:
由于需要加锁,而且可能面临锁冲突甚至死锁的问题,悲观并发控制增加了系统的额外开销,降低了系统的效率,同时也会降低了系统的并行性。

乐观并发控制

本质

维基百科:在关系数据库管理系统里,乐观并发控制(又名“乐观锁”,Optimistic Concurrency Control,缩写“OCC”)是一种并发控制的方法。它假设多用户并发的事务在处理时不会彼此互相影响,各事务能够在不产生锁的情况下处理各自影响的那部分数据。

乐观并发控制对数据修改持乐观态度,认为即使在并发环境中,外界对数据的操作一般是不会造成冲突,所以并不会去加锁,而是在提交数据更新之前,每个事务会先检查在该事务读取数据后,有没有其他事务又修改了该数据。如果其他事务有更新的话,则让返回冲突信息,让用户决定如何去做下一步,比如说重试或者回滚。

可以看出,乐观锁其实也不是实际的锁,甚至没有用到锁来实现并发控制,而是采取其他方式来判断能否修改数据。乐观锁一般是用户自己实现的一种锁机制,虽然没有用到实际的锁,但是能产生加锁的效果。

实现方式

CAS(比较与交换,Compare and swap) 是一种有名的无锁算法。无锁编程,即不使用锁的情况下实现多线程之间的变量同步,也就是在没有线程被阻塞的情况下实现变量的同步,所以也叫非阻塞同步(Non-blocking Synchronization)。实现非阻塞同步的方案称为“无锁编程算法”( Non-blocking algorithm)。

乐观锁基本都是基于 CAS(Compare and swap)算法来实现的。我们先来看下CAS过程,一个CAS操作的过程可以用以下c代码表示:

int cas(long *addr, long old, long new)
{
    /* Executes atomically. */
    if(*addr != old)
        return 0;
    *addr = new;
    return 1;
}

CAS有3个操作数,内存值V,旧的预期值A,要修改的新值B。当且仅当预期值A和内存值V相同时,将内存值V修改为B,否则什么都不做。整个CAS操作是一个原子操作,是不可分割的。

乐观锁的实现就类似于上面的过程,主要有以下几种方式:

  • 版本号标记:在表中新增一个字段:version,用于保存版本号。获取数据的时候同时获取版本号,然后更新数据的时候用以下命令:update xxx set version=version+1,… where … version="old version" and ....。这时候通过判断返回结果的影响行数是否为0来判断是否更新成功,更新失败则说明有其他请求已经更新了数据了。
  • 时间戳标记:和版本号一样,只是通过时间戳来判断。一般来说很多数据表都会有更新时间这一个字段,通过这个字段来判断就不用再新增一个字段了。
  • 待更新字段:如果没有时间戳字段,而且不想新增字段,那可以考虑用待更新字段来判断,因为更新数据一般都会发生变化,那更新前可以拿要更新的字段的旧值和数据库的现值进行比对,没有变化则更新。
  • 所有字段标记:数据表所有字段都用来判断。这种相当于就、不仅仅对某几个字段做加锁了,而是对整个数据行加锁,只要本行数据发生变化,就不进行更新。

优缺点

优点:
乐观并发控制没有实际加锁,所以没有额外开销,也不错出现死锁问题,适用于读多写少的并发场景,因为没有额外开销,所以能极大提高数据库的性能。

缺点:
乐观并发控制不适合于写多读少的并发场景下,因为会出现很多的写冲突,导致数据写入要多次等待重试,在这种情况下,其开销实际上是比悲观锁更高的。而且乐观锁的业务逻辑比悲观锁要更为复杂,业务逻辑上要考虑到失败,等待重试的情况,而且也无法避免其他第三方系统对数据库的直接修改的情况。

多版本并发控制

本质

维基百科: 多版本并发控制(Multiversion concurrency control, MCC 或 MVCC),是数据库管理系统常用的一种并发控制,也用于程序设计语言实现事务内存。

乐观并发控制和悲观并发控制都是通过延迟或者终止相应的事务来解决事务之间的竞争条件来保证事务的可串行化;虽然前面的两种并发控制机制确实能够从根本上解决并发事务的可串行化的问题,但是其实都是在解决写冲突的问题,两者区别在于对写冲突的乐观程度不同(悲观锁也能解决读写冲突问题,但是性能就一般了)。而在实际使用过程中,数据库读请求是写请求的很多倍,我们如果能解决读写并发的问题的话,就能更大地提高数据库的读性能,而这就是多版本并发控制所能做到的事情。

与悲观并发控制和乐观并发控制不同的是,MVCC是为了解决读写锁造成的多个、长时间的读操作饿死写操作问题,也就是解决读写冲突的问题。MVCC 可以与前两者中的任意一种机制结合使用,以提高数据库的读性能。

数据库的悲观锁基于提升并发性能的考虑,一般都同时实现了多版本并发控制。不仅是MySQL,包括Oracle、PostgreSQL等其他数据库系统也都实现了MVCC,但各自的实现机制不尽相同,因为MVCC没有一个统一的实现标准。

总的来说,MVCC的出现就是数据库不满用悲观锁去解决读-写冲突问题,因性能不高而提出的解决方案。

实现方式

MVCC的实现,是通过保存数据在某个时间点的快照来实现的。每个事务读到的数据项都是一个历史快照,被称为快照读,不同于当前读的是快照读读到的数据可能不是最新的,但是快照隔离能使得在整个事务看到的数据都是它启动时的数据状态。而写操作不覆盖已有数据项,而是创建一个新的版本,直至所在事务提交时才变为可见。

当前读和快照读

什么是MySQL InnoDB下的当前读和快照读?

当前读
像select lock in share mode(共享锁), select for update ; update, insert ,delete(排他锁)这些操作都是一种当前读,为什么叫当前读?就是它读取的是记录的最新版本,读取时还要保证其他并发事务不能修改当前记录,会对读取的记录进行加锁。

快照读
像不加锁的select操作就是快照读,即不加锁的非阻塞读;快照读的前提是隔离级别不是未提交读和串行化级别,因为未提交读总是读取最新的数据行,而不是符合当前事务版本的数据行。而串行化则会对所有读取的行都加锁

优缺点

MVCC 使大多数读操作都可以不用加锁,这样设计使得读数据操作很简单,性能很好,并且也能保证只会读取到符合标准的行。不足之处是每行记录都需要额外的存储空间,需要做更多的行检查工作,以及一些额外的维护工作。

适用场景

  • 悲观锁

    • 用来解决读-写冲突和写-写冲突的的加锁并发控制
    • 适用于写多读少,写冲突严重的情况,因为悲观锁是在读取数据的时候就加锁的,读多的场景会需要频繁的加锁和很多的的等待时间,而在写冲突严重的情况下使用悲观锁可以保证数据的一致性
    • 数据一致性要求高
    • 可以解决脏读,幻读,不可重复读,第一类更新丢失,第二类更新丢失的问题
  • 乐观锁

    • 解决写-写冲突的无锁并发控制
    • 适用于读多写少,因为如果出现大量的写操作,写冲突的可能性就会增大,业务层需要不断重试,这会大大降低系统性能
    • 数据一致性要求不高,但要求非常高的响应速度
    • 无法解决脏读,幻读,不可重复读,但是可以解决更新丢失问题
  • MVCC

    • 解决读-写冲突的无锁并发控制
    • 与上面两者结合,提升它们的读性能

参考资料

维基百科
https://www.cnblogs.com/rinack/p/10032207.html
https://draveness.me/database-concurrency-control/

版权声明

转载请注明作者和文章出处
作者: X先生
https://segmentfault.com/a/1190000023332101
查看原文

赞 8 收藏 7 评论 0

X先生 发布了文章 · 7月3日

MySQL 连接查询超全详解

1 作用

在MySQL中join操作被称为连接,作用是能连接多个表的数据(通过连接条件),从多个表中获取数据合并在一起作为结果集返回给客户端。例如:

表A:

idnameage
1A18
2B19
3C20

表B:

iduidgender
11F
22M

通过连接可以获取到合并两个表的数据:

select A.*,B.gender from A left join B on A.id=B.uid

idnameagegender
1A18F
2B19M
3C20null

2 连接关键字

连接两个表我们可以用两个关键字:onusingon可以指定具体条件,using则指定相同名字数据类型的列作为等值判断的条件,多个则通过逗号隔开。
如下:

on: select * from A join B on A.id=B.id and B.name=''

using: select * from A join B using(id,name) = select * from A join B on A.id=B.id and A.name=B.name

3 连接类型

3.1 内连接

内连接和交叉连接

  • 语法: A join | inner join | cross join B
  • 表现:A和B满足连接条件记录的交集,如果没有连接条件,则是A和B的笛卡尔积
  • 特点:在MySQL中,cross joininner joinjoin所实现的功能是一样的。因此在MySQL的官方文档中,指明了三者是等价的关系。

隐式连接

  • 语法:from A,B,C
  • 表现:相当于无法使用onusingjoin
  • 特点:逗号是隐式连接运算符。 隐式连接是SQL92中的标准内容,而在SQL99中显式连接才是标准,虽然很多人还在用隐私连接,但是它已经从标准中被移除。从使用的角度来说,还是推荐使用显示连接,这样可以更清楚的显示出多个表之间的连接关系和连接依赖的属性。

3.2 外连接

左外连接

  • 语法: A left join B
  • 表现:左表的数据全部保留,右表满足连接条件的记录展示,不满足的条件的记录则全是null

右外连接

  • 语法: A right join B
  • 表现:右表的数据全部保留,左表满足连接条件的记录展示,不满足的条件的记录则全是null

全外连接

MySQL不支持全外连接,只支持左外连接和右外连接。如果要获取全连接的数据,要可以通过合并左右外连接的数据获取到,如 select * from A left join B on A.name = B.name union select * from A right join B on B.name = B.name;

这里union会自动去重,这样取到的就是全外连接的数据了。

3.3 自然连接

  • 语法:A natural join B ==== A natural left join B ==== A natural right join B
  • 表现:相当于不能指定连接条件的连接,MySQL会使用左右表内相同名字和类型的字段作为连接条件。
  • 特点:自然连接也分自然内连接,左外连接,右外连接,其表现和上面提到的一致,只是连接条件由MySQL自动判定。

4 执行顺序

在连接过程中,MySQL各关键字执行的顺序如下:

from -> on|using -> where -> group by -> having -> select -> order by -> limit 

可以看到,连接的条件是先于where的,也就是先连接获得结果集后,才对结果集进行where筛选。

5 连接算法

join有三种算法,分别是Nested Loop JoinHash joinSort Merge Join。MySQL官方文档中提到,MySQL只支持Nested Loop Join这一种算法。

具体来说Nested Loop Join又分三种细分的算法:

  • SNLJ
  • BNLJ
  • INLJ

我们来看下对于连接语句select * from A left join B on A.id=B.tid,这三种算法是怎么连接的。

5.1 Simple Nested Loop Join(SNLJ)

SNLJ是在没有使用到索引的情况下,通过两层循环全量扫描连接的两张表,得到符合条件的两条记录则输出。也就是让两张表做笛卡尔积进行扫描,是比较暴力的算法,会比较耗时。其过程如下:

for (a in A) {
     for (b in B) {
         if (a.id == b.tid) {
             output <a, b>;
         }
     }
 }

当然,MySQL即使在无索引可用,或者判断全表扫描可能比使用索引更快的情况下,还是不会选择使用过于粗暴的SNLJ算法,而是采用下面的算法。

5.2 Block Nested Loop Join(BNLJ)

INLJ是MySQL无法使用索引的时候采用的join算法。会将外层循环的行分片存入join buffer, 内层循环的每一行与整个buffer中的记录做比较,从而减少内层循环的次数,具体逻辑如下:

for (blockA in A.blocks) {
     for (b in B) {
         if (b.tid in blockA.id) {
             output <a, b>;
         }
     }
 }

相比于SNLJ算法,BNLJ算法通过外层循环的结果集的分块,可以有效的减少内层循环的次数。

原理

举例来说,外层循环的结果集是100行,使用SNLJ算法需要扫描内部表100次,如果使用BNLJ算法,假设每次分片的数量是10,则会先把对Outer Loop表(外部表)每次读取的10行记录放到join buffer,然后在InnerLoop表(内部表)中每次循环都直接匹配这10行数据,这样内层循环只需要10次,对内部表的扫描减少了9/10,所以BNLJ算法就能够显著减少内层循环表扫描的次数。

当然这里,不管SNLJ还是BNLJ算法,他们总的比较次数都是一样的,都是要拿外层循环的每一行与内层循环的每一行进行比较。

BNLJ算法减少的是总的扫描行数,SNLJ算法是外层循环要一行行扫描A表的数据,然后取A.id去表B一行行扫描看是否匹配。而BNLJ算法则是外层循环要一行行扫描A表的数据,然后放到内存分块里,然后去表B一行行扫描,扫描出来的B的一行数据与内存分块里的A的数据块进行比较。这里可以一次就是很多行A的数据与B的数据进行比较,而且是在内存中进行比较,速度更加快了。

影响因素

这里BNLJ算法总的扫描行数是由外层循环的数据量N,和分块数量K还有内层循环的数据量M决定的。其中分块数量K与外层循环的数据量N又是息息相关的,我们可以表示为λN,其中λ取值为(0~1)。则总扫描次数C=N+λNM

可以看出,在这个式子里,Nλ的大小都会影响扫描行数,但是λ才是影响扫描行数的关键因素,这个值越小越好(除非NM的差值非常大,这时候N才会成为关键影响因素)。

那什么会影响 λ 的大小呢?那就是 MySQL的join_buffer_size设置项的大小了。λjoin_buffer_size成倒数关系,join_buffer_size越大,分块越大,λ越小,分块数量也就越少,也就是外层循环的次数也越少。所以在使用不上索引的时候,我们要优先考虑扩大join_buffer_size的大小,这样优化效果会更明显。而在能使用上索引的时候,MySQL会使用以下算法来进行join

5.3 Index Nested Loop Join(INLJ)

INLJ是MySQL判断能使用到被驱动表的索引的情况下采用的算法。假设A表的数据行为10,B表的数据行为100,且B.tid建立了索引,则对于select * from A left join B on A.id=B.tid,MySQL会采用Index Nested Loop Join。其过程如下:

for (a in A) {
     if (a.id in B.tid.Index) {
        output <a, tid.Index所在行>;
     }
 }

总共需要循环10次A,每次循环的时候通过索引查询一次B的数据。而如果我们反过来是B left join A的话,总共要循环100次B,由此可见如果使用join的话,需要让小表做驱动表,这样才能有效减少循环次数。但是需要注意的是,这个结论的前提是可以使用被驱动表的索引。

INLJ内层循环读取的是索引,可以减少内存循环的次数,提高join效率,但是也有缺点的,就是如果扫描的索引是非聚簇索引,并且需要访问非索引的数据,会产生一个回表读取数据的操作,这就多了一次随机的I/O操作。例如上面在索引里匹配到了tid,还要去找tid所在的行在磁盘所在的位置,具体可以见我以前的文章:MySQL索引详解之索引的存储方式

6 注意点

  • 尽量增加连接条件,减少join后数据集的大小
  • 用小结果集驱动大结果集,将筛选结果小的表首先连接,再去连接结果集比较大的表
  • 被驱动表的被join的字段要建立索引,且使用上索引。使用上索引包括使用该字段,且不会有索引失效的情况出现
  • 设置足够大的join_buffer_size

7 外连接常见问题

Q:如果想筛选驱动表的数据,例如左连接筛选左表的数据,该在连接条件还是where筛选?
A:要通过where筛选,连接条件只影响连接过程,不影响连接返回的结果数(某些情况下连接条件会影响连接返回的结果数,例如左连接中,右侧匹配的数据不唯一的时候)

Q:被驱动表匹配的数据行不唯一导致最终连接数据超过驱动表数据量该怎么办?例如对于左连接,右表匹配的数据行不唯一。
A:join之前先对被驱动表去重,例如通过group by去重:A lef join (select * from B group by name)

8 参考资料

https://www.jianshu.com/p/686...
https://www.cnblogs.com/blueo...
https://leokongwq.github.io/2...
https://zhuanlan.zhihu.com/p/...

版权声明

转载请注明作者和文章出处
作者: X先生
https://segmentfault.com/a/1190000023086991
查看原文

赞 2 收藏 2 评论 0

认证与成就

  • 获得 62 次点赞
  • 获得 17 枚徽章 获得 3 枚金徽章, 获得 6 枚银徽章, 获得 8 枚铜徽章

擅长技能
编辑

(゚∀゚ )
暂时没有

开源项目 & 著作
编辑

(゚∀゚ )
暂时没有

注册于 2015-02-07
个人主页被 3.7k 人浏览