0%

为了便于说明,我们创建一个表t,其中id是自增主键字段、c是唯一索引。

1
2
3
4
5
6
7
8

CREATE TABLE `t` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`c` int(11) DEFAULT NULL,
`d` int(11) DEFAULT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `c` (`c`)
) ENGINE=InnoDB;

自增值修改机制

在MySQL里面,如果字段id被定义为AUTO_INCREMENT,在插入一行数据的时候,自增值的行为如下:

  1. 如果插入数据时id字段指定为0、null或未指定值,那么就把这个表当前的AUTO_INCREMENT值填到自增字段;
  2. 如果插入数据时id字段指定了具体的值,就直接使用语句里指定的值。

根据要插入的值和当前值的大小关系,自增值的变更结果也会有所不同。假设,某次要插入的值是X,当前的自增值是Y。

  1. 如果X<Y,那么这个表的自增值不变;
  2. 如果X>=Y,就需要把当前自增值修改为新的自增值。

新的自增值生成算法是:从auto_increment_offset开始,以auto_increment_increment为步长,持续叠加,直到找到第一个大于X的值,作为新的自增值。

其中,auto_increment_offset和auto_increment_increment是两个系统参数,分别用来表示自增的初始值和步长,默认值都是1。

备注:在一些场景下,使用的就不全是默认值。比如,双M的主备结构里要求双写的时候,我们就可能会设置成auto_increment_increment=2,让一个库的自增id都是奇数,另一个库的自增id都是偶数,避免两个库生成的主键发生冲突。

当auto_increment_offset和auto_increment_increment都是1的时候,新的自增值生成逻辑很简单,就是:

  1. 如果准备插入的值>=当前自增值,新的自增值就是”准备插入的值+1“;
  2. 否则,自增值不变。

这就引入了我们文章开头提到的问题,在这两个参数都设置为1的时候,自增主键id却不能保证是连续的,这是什么原因呢?

自增值的修改时机

要回答这个问题,我们就要看一下自增值的修改时机。

假设,表t里面已经有了(1,1,1)这条记录,这时我再执行一条插入数据命令:

1
insert into t values(null, 1, 1);
  1. 这个语句的执行InnoDB引擎接口写入一行,传入的这一行的值是(0, 1, 1);
  2. InnoDB发现用户没有指定自增id的值,获取表t当前的自增值2;
  3. 将传入的行的值改成(2,1,1);
  4. 将表的自增值改成3;
  5. 继续执行插入数据操作,由于已经存在c=1的记录,所以报Duplicate key error,语句返回。

对应的执行流程图如下:

可以看到,这个表的自增值改成3,是在真正执行插入数据的操作之前。这个语句真正执行的时候,因为碰到唯一键c冲突,所以id这一行并没有插入成功,但也没有将自增值再改回去。

所以,在这之后,再插入新的数据行时,拿到的自增id就是3。也就是说,出现了自增主键不连续的情况。

如下图所示就是完整的演示结果。

可以看到,这个操作序列复现了一个自增主键id不连续的现场(没有id=2的行)。可见,唯一键冲突是导致自增主键id不连续的第一种原因。

同样地,事务回滚也会产生类似的现象,这就是第二种原因。

下面这个语句序列就可以构造不连续的自增id,你可以自己验证一下。

1
2
3
4
5
6
insert into t values(null, 1, 1);
begin;
insert into t values(null, 2, 2);
rollback;
insert into t values(null, 2, 2);
// 插入的行是(3, 2, 2)

你可能会问,为什么在出现唯一键冲突或者回滚的时候,MySQL没有把表t的自增值改回去呢?如果把表t的当前自增值从3改回2,再插入新数据的时候,不就可以生成id=2的一行数据了吗?

其实,MySQL这么设计是为了提升性能。接下来,我就跟你分析一下这个设计思路,看看自增值为什么不能回退。

假设有两个并行执行的事务,在申请自增值的时候,为了避免两个事务申请到相同的自增id,肯定要加锁,然后顺序申请。

  1. 假设事务A申请到了id=2,事务B申请到Id=3,那么这时候表t的自增值是4,之后继续执行。
  2. 事务B正确提交了,但事务A出现了唯一键冲突.
  3. 如果允许事务A把自增id回退,也就是把表t的当前自增值改回2,那么就会出现这样的情况:表里面已经有id=3的行,而当前的自增id值是2。
  4. 接下来,继续执行的其他事务就会申请到id=2,然后再申请到id=3。这是,就会出现插入语句报错”主键冲突“。

而为了解决这个主键冲突,有两种方法:

  1. 每次申请id之前,先判断表里面是否已经存在这个id。如果存在,就跳过这个id。但是,这个方法的成本很高。因为,本来申请id是一个很快的操作,现在还要再去主键索引树上判断id是否存在。
  2. 把自增id的缩范围扩大,必须等到一个事务执行完成并提交,下一个事务才能再申请自增id。这个方法的问题,就是锁的粒度太大,系统并发能力大大下降。

可见,这两个方法都会导致性能问题。造成这些麻烦的罪魁祸首,就是我们假设的这个”允许自增id回退“的前提导致的。

因此,InnoDB放弃了这个设计,语句执行失败也不回退自增id。也正是因为这样,所以才只保证了自增id是递增的,但不保证是连续的。

自增锁的优化

可以看到,自增id锁并不是一个事务锁,而是每次申请完就马上释放,以便允许别的事务再申请。其实,在MySQL5.1版本之前,并不是这样的.

接下来,我会先给你介绍下自增锁设计的历史,这样有助于你分析接下来的一个问题。

在MySQL5.0版本的时候,自增锁的范围是语句级别。也就是说,如果一个语句申请了一个表自增锁,这个锁会等语句执行结束以后才释放。显然,这样设计会影响并发度。

MySQL 5.1.22版本引入了一个新的策略,新增参数innodb_autoinc_lock_mode,默认值是1。

  1. 这个参数的值被设置成0时,表示采用之前MySQL 5.0版本的策略,即语句执行结束后才释放锁;
  2. 这个参数的值被设置为1时:
    • 普通insert语句,自增锁在申请之后就马上释放;
    • 类似insert...select这样的批量插入数据的语句,自增锁还是要等语句结束后才被释放;
  3. 这个参数值被设置为2的时候,所有申请自增主键的动作都是申请后就释放锁。

你一定有两个疑问:为什么默认设置下,insert...select要使用语句级的锁?为什么这个参数的默认值不是2?

答案是,这么设计还是为了数据的一致性。

我们一起来看一下这个场景:

在这个例子里,我往表t1中插入了4行数据,然后创建了一个相同结构的表t2,然后两个session同时执行向表t2中插入数据的操作。

你可以设想一下,如果session B是申请了自增值以后马上就释放自增锁,那么就可能出现这样的情况:

  • session B先插入了亮条记录,(1,1,1)、(2,2,2);
  • 然后,session A来申请自增id得到id=3,插入了(3,5,5);
  • 之后,session B继续执行,插入两条记录(4,3,3)、(5,4,4)。

你可能会说,这也没关系吧,毕竟session B的语义本身就没有要求表t2的所有行的数据都跟session A相同。

是的,从数据逻辑上看是对的。但是,如果我们现在的binlog_format=statement,你可以设想下,binlog会怎么记录呢?

由于两个session是同时执行插入数据命令的,所以binlog里面对表t2的更新日志只有两种情况:要么先记session A的,要么先记session B的。

但不论是哪一种,这个binlog拿去从库执行,或者用来恢复临时实例,备库和临时实例里面,session B这个语句执行出来,生成的结果里面,id都是连续的。这是,这个库就发生了数据不一致。

你可以分析一下,出现这个问题的原因是什么?

其实,这是因为原库session B的insert语句,生成的id不连续。这个不连续的id,用statement格式的binlog来串行执行,是执行不出来的。

而要解决这个问题,有两种思路:

  1. 一种思路是,让原库的批量插入数据语句,固定生成连续的id值。所以,自增锁直到语句执行结束才释放,就是为了达到这个目的。
  2. 另一种思路是,在binlog里面把插入数据的操作都如实记录进来,到备库执行的时候,不再依赖于自增主键去生成。这种情况,其实就是innodb_autoinc_lock_mode设置为2,同时binlog_format设置为row。

因此,生产上,尤其是有insert...select这种批量插入数据的场景时,从并发插入数据性能的角度考虑,我建议你这样设置:innodb_autoinc_lock_mode=2,并且binlog_format=row。这样做,既能提升并发性,又不会出现数据一致性问题。

需要注意的是,我这里说的批量插入数据,包含的语句类型是insert...select、replace...select和load data语句

但是,普通的insert语句里面包含多个value值的情况下,即使innodb_autoinc_lock_mode设置为1,也不会等语句执行完才释放锁。因为这类语句在申请自增id的时候,是可以精确计算出需要多少个id的,然后一次性申请,申请完成后锁就可以释放了。

也就是所,批量插入数据的语句,之所以需要这么设置,是因为”不知道要预先申请多少个id“。

既然预先不知道要申请多少个自增id,那么一种直接的想法就是需要一个时申请一个。但如果一个select..insert语句要插入10万行数据,按照这个逻辑的话就要申请10万次。显然,这种申请自增id的策略,在大批量插入数据的情况下,不但速度慢,还会影响并发插入的性能。

因此,对于批量插入数据的语句,MySQL有一个批量申请自增id的策略:

  1. 语句执行过程汇总,第一次申请自增id,会分配1个;
  2. 1个用完以后,这个语句第二次申请自增id,会分配2个;
  3. 2个用完以后,还是这个语句,第三次申请自增id,会分配4个;
  4. 依次类推,同一个语句去申请自增id,每次申请到的自增id个数都是上一次的两倍。

举个例子,我们一起看看下面的这个语句序列:

1
2
3
4
5
6
7
insert into t values(null, 1, 1);
insert into t values(null, 2, 2);
insert into t values(null, 3, 3);
insert into t values(null, 4, 4);
create table t2 like t;
insert into t2(c, d) select c, d from t;
insert ionto t2 values(null, 5, 5);

insert...select,实际上往表t2中插入了4行数据。但是,这四行数据是分三次申请的自增id,第一次申请到了id=1,第二次被分配了id=2和id=3,第三次被分配到id=4到id=7。

由于这条语句实际上只用上了4个id,所以id=5到id=7就被浪费掉了。之后,再执行insert into t2 values(null,5, 5),实际上插入的数据就是(8,5,5)。

这是主键id出现自增id不连续的第三种原因。

小结

今天,我们从”自增主键为什么会出现不连续的值”这个问题开始,首先讨论了自增值的存储。

在MyISAM引擎里面,自增值是被写在数据文件上面的。而在InnoDB中,自增值是被记录在内存的。MySQL直到8.0版本,才给InnoDB表的自增值加上了持久化的能力,确保重启后一个表的自增值不变。(将自增值的变更记录在了 redo log 中,重启的时候依靠 redo log 恢复重启之前的值。)

然后,我和你分享了在一个语句执行过程中,自增值改变的实际,分析了为什么MySQL在事务回滚的时候不能回收自增id。

MySQL 5.1.22版本开始引入的参数innodb_autoinc_lock_mode,控制了自增值申请时的锁范围。从并发性能的角度考虑,我建议你将其设置为2,同时将binlog_format设置为row。我在前面的文章中多次提到,binlog_format设置为row,是很有必要的。今天的例子给这个结论多了一个理由。

最后,我给你留一个思考题吧。

在最后一个例子中,执行insert into t2(c, d) select c,d from t;这个语句的时候,如果隔离级别是可重复读(RR, repeatable read),binlog_format=statement。这个语句会对表t的所有记录和间隙加锁。

你觉得为什么需要这么做呢?

我的回答:

如果不加记录和间隙锁,而binlog_format又是statement。那么此时如果有另一个事务对t有写操作,比如insert into t values(x, x, x),这时由于事务的隔离级别是可重复读,t2是看不到新增的数据的。而我们的binlog记录时如果将insert into t2(c, d) select c,d from t;记在了刚才那个语句的后面。那么在备库使用binlog同步的时候,备库会基于binlog恢复临时库,t2会看到新增的数据,就会造成主备数据的不一致。

参考

  • MySQL实战45讲

只要没有迷失”他者贡献“这课引导之星,那么你就不会迷失,而且做什么都可以。

我们要像跳舞一样认真过好作为刹那的”此时此刻“,既不看过去也不看未来,只需过好每一个完结的刹那。没必要与谁竞争,也不需要目的地,只要跳着,就一定会到达某一个地方。

我的力量无穷大。

世界不是靠他人改变而只能靠我来改变。

先说结论

如果你直接使用order by rand(),这个语句需要Using temporary和Using filesort,查询的执行代价往往是比较大的。所以,在设计的时候你要尽量避开这种写法。

在实际应用的过程中,比较规范的用法是:尽量将业务逻辑写在业务代码中,让数据库只做”读写数据“的事情。

正确的方法

下面这个流程:

  1. 取得整个表的行数,并记为C。
  2. 取得Y = floor(C * rand())。floor函数在这里的作用,就是取整数部分。
  3. 再用limit Y,1 取得一行。

下面这段代码,就是上面流程的执行语句的序列。

1
2
3
4
5
6
7

mysql> select count(*) into @C from t;
set @Y = floor(@C * rand());
set @sql = concat("select * from t limit ", @Y, ",1");
prepare stmt from @sql;
execute stmt;
DEALLOCATE prepare stmt;

由于limit后面的参数不能直接跟变量,所以在上面的代码中使用prepare+execute的方法。你也可以把拼接SQL语句的方法写在应用程序中,会更简单些(其实我觉得也更规范,业务逻辑写在业务代码中)。

MySQL处理limit Y, 1的做法就是按顺序一个一个地读出来,丢掉前Y个,然后把下一个记录作为返回结果,因此这一步需要扫描Y+1行。再加上,第一步扫面的C行,总共需要扫描C+Y+1行。

跟直接order by rand()比起来,执行代价还是小很多的。

如果按照这种思路,随机取3个word的值呢?可以这么做:

  1. 取得整个表的行数,即为C
  2. 按照相同的随机方法得到Y1、Y2、Y3
  3. 再执行三个limit Y, 1语句得到三行数据。
  4. 可以优化成,假设Y1,Y2,Y3是由小到大的三个数,则可以优化成这样,这样扫描行数为Y3 id1 = select * from t limit @Y1,1; id2= select * from t where id > id1 limit @Y2@Y1,1; select * from t where id > id2 limit @Y3 - @Y2,1;

参考

丁奇老师的MySQL实战45讲

首先官方文档是这么写的。参考MySQL5.6 Ref manual

每个InnoDB表都有一个特殊的索引,称为聚簇索引clustered index,行的数据就存储在这里。通常情况下,聚簇索引是主键的同义词。为了从查询、插入和其他数据库操作中获得最佳性能,你必须理解InnoDB是如何使用聚簇索引来优化每个表的最常见的查找和DML操作的。

  • 当你在表上定义一个primary key时,InnoDB就会把它作为聚簇索引。为你创建的每一个表定义一个主键。如果没有逻辑上唯一且非空的列或列集合,则添加一个新的自动增加列,其值会自动填入。
  • 如果你没有为你的表定义primary key,MySQL会定位第一个Unique索引,其中所有的关键列都是Not Null。InnoDB使用它作为聚簇索引。
  • 如果表没有primary key或合适的unique索引,InnoDB内部会在包含row id值的合成列上生成一个名为GEN_CLUST_INDEX的隐藏聚簇索引。行是由InnoDB分配给这种表中的行的ID来排序的。row id是一个6字节的字段,随着新行的插入而单调增加。因此,按row id排序的行在物理上是按插入顺序排列的。
聚簇索引是如何加快查询速度的?

通过聚簇索引访问行的速度很快,因为索引搜索会直接引导到包含所有行数据的页面。如果一张表很大,与使用与索引记录不同的页面存储行数据的存储组织相比,聚簇索引架构往往可以节省一次磁盘I/O操作。

二级索引与聚簇索引的关系

除了聚簇索引以外的所有索引都称为二级索引。在InnoDB中,二级索引中的每条记录都包含改行的主键列,以及二级索引指定的列。InnoDB使用这个主键值来搜索聚簇索引中的行。

如果主键长,二级索引就会使用更多的空间,所以主键短是有利的。

回答标题的问题

这里我没有找到mysql的rowid为什么是6个字节的答案,找到是关于oracle rowid的解释,猜测是互通的。

rowid是伪列。不会真正存在表的data block中,但是它会存在于index中。

扩展的rowid在磁盘上需要10个字节的存储空间,并使用18个字符来显示。

解释如下:

在oracle 8以前,一个rowid占用6个字节大小的存储空间(10 bit file# + 22bit block # + 16 bit row #),rowid格式为:BBBBBBBB.RRRR.FFFF。

在oracle 8以后,rowid的存储空间扩大到了10个字节(32 bit object# + 10bit rfile# + 22 bit block# + 16 bit row#),文件号仍然用10位表示,只是不再需要置换,为了向后兼容,同时引入了相对文件号(rfile#),所以从Oracle7到Oracle8,rowid仍然无需发生变化。

这里我猜测,mysql rowid也是存储了 10位的文件号,22位的块号,16位的行号。

更新:已经得到答案。

详见文章

epoll 网络事件收集器模型(也有分发)

nginx事件分发机制,在其中的循环流程中,最关键的就是,nginx怎样能够快速的从操作系统的kernal中获取到等待处理的事件,这么一个简单的步骤,其实,经历了很长时间的解决。比如,到现在nginx主要在使用epoll这样一个网络事件收集器的模型。那么,下面我们来简单的回顾下,epoll有些什么样的特点。

首先,epoll 和 kqueue(mac os内核才有)随文件描述符(句柄数的增加,也表示并发连接数的增加)的增加,所消耗的时间几乎不变。而Poll和select所消耗的时间是急剧上升的。epoll基本与句柄数增加是无关的,所以它的性能会好很多,而且非常适合做大并发连接的处理。那么,为什么会这样呢?

Benchmark显示:

前提

高并发连接中,每次处理的活跃连接数量占比很小

select 和 poll的实现是有问题的。每一次我去取操作系统的事件的时候,我都需要把这100万个连接通通地扔给操作系统,让它去依次地判断哪些连接上面有事件进来了,所以可以看到这里操作系统做了大量的无用功 ,它扫描了大量不活跃的连接。那么epoll就是用了这样的一个特性,因为每次处理的活跃连接的占比其实非常小,那么,它怎么实现的呢?其实非常简单,因为它维护了一个数据结构叫eventpoll。这里,它通过两个数据结构把这两件事分开了。也就是说,nginx每次取活跃连接的时候,我们只需要去遍历一个链表,这个链表里仅仅只有活跃的连接,这样我们的效率就很高。那么,我们还会经常做的操作是什么呢?比如说,nginx收到80端口建立连接的请求。那么,收到80端口建立连接成功以后呢,我们要添加一个读事件,这个读事件是用来读取HTTP消息的,那这时候呢,我可能会添加一个新的事件,或者写事件添加进来。这个时候添加呢,我只会放到这个红黑树中,这个二叉平衡树,它能保证我的插入效率值是logn。如果我现在不想再处理读事件或者写事件,我只需要从这个平衡二叉树中移除一个节点就可以了,同样是logn的时间复杂度。所以这个效率非常高。那么什么时候这个链表会有所增减呢。当我们读取一个事件的时候,链表中自然就没了。那么,当操作系统接收到网卡中发送来的一个报文的时候,那么这个链表就会增加一个新的元素,所以我们在使用epoll的时候,它的操作,添加修改删除,是非常快的,是logn复杂度的。而我们获取句柄的时候,只是去遍历这个rdllink,也就是ready准备好的所有的连接,是把它读取出来而已。那么,从内核态读取到用户态,只读这么一点东西,它的效率是非常高的。

实现

  • 红黑树
  • 链表

使用

  • 创建
  • 操作:添加/修改/删除
  • 获取句柄
  • 关闭

以上,简单介绍了epoll的使用方法,它对我们理解nginx的事件驱动模型是有帮助的。

其他

每一个nginx worker进程都有一个独立的ngx_cycle_t这样一个数据结构。有三个主要的数组,connections,read_events,write_events。分别代表预分配的连接、读事件和写事件。三个数组大小和配置是一模一样的,三个数组通过序号对应起来。

每一个connection到底使用了多大的内存呢?连接使用的核心数据结构是ngx_connection_s,64位操作系统中,大约占用了232个字节,nginx版本不同可能有微小的差异,每一个ngx_connetion_s结构体对应着两个事件,一个读一个写,每一个时间对应的结构体,事件的核心数据结构是ngx_event_s,结构体占用的字节数是96字节。所以当我们使用一个连接的时候,它使用的字节大约是232+96*2,我们的worker connections配置得越大,那么初始化的时候就会预分配这么多内存。

我们再来看ngx_event_s中有哪些成员,这里我们比较关注的是,有一个handler(ngx_event_gandler_pt)指针,这是一个回调方法,也就是很多第三方模块会把这个handler设为自己的实现;这里还有一个timer(ngx_rbtree_node_t),也就是说,当我们对http请求做读超时写超时等等设置的时候,其实是在操作它的读事件和写事件中的timer,这个timer就是inginx实现超时定时器,也就是基于rbtree就实现的这样一个结构体。那么红黑树中每个成员叫rbtree_node,那么这个timer就是它的node,用来指向我们的读事件是否超时、写事件是否超时,这些定时器其实也是可配的。

比如client_header_timeout,默认是60s,也就是在我们刚刚某个连接上,在准备读取它的header时,我们在它的读事件上添加了一个60s的定时器。

当多个事件形成队列的时候,可以用这个ngx_queue_t形成一个队列。

我们再来简单的看一下,ngx_connection_s有一些什么样的成员。ngx_event_t read,ngx_event_t write分别是它的读写事件,这个刚刚已经说过了。recv和send是它抽象的操作系统的底层方法,怎么样发送和接收。这里还有一个比较有意思的变量,叫做sent,它的类型是off_t,大家可以把它理解成一个无符号的整型,表达的呢就是这个连接上我已经发送了多少字节,也就是,我们在配置中,会经常使用到的bytes_sent变量,那么我们可以先看一看bytes_sent变量到底有什么作用。还是打开ngx_http_core_module的官方文档,我们先找到它的内置变量。可以看到bytes_sent,它表示向客户端发送了多少字节。通常,在access_log记录nginx处理了哪些请求中,我们会记录这么一个变量,比如,在我们查看access_log时。现在我们打开了nginx_conf这个配置文件,在配置文件中,我们有一行log_format main,定义了access_log的日志格式。在日志格式,我们看到有一个 中括号,这个中括号的后半部分我们用了bytes_sent这个内置变量,用来表示我们发送了多少字节。我们看一下在日志中,这个bytes_sent会表现为什么样的形式。

以上谈了nginx_connection_t和nginx_event连接和事件怎么样对应在一起的,当我们配置高并发的nginx时,必须把connections的数目配置到足够大,而每一个connection相对应两个event,都会消耗一定的内存,需要我们注意。还有nginx中像很多结构体中它们的一些成员和我们内置变量是可以对应起来的,比如说bytes_sent,还有一些比如说body_bytes_sent都是我们在access_log或者说在一些openresty lua写的代码中,我们获取到nginx内置的状态时,经常使用到的方法。

如果你开发过nginx的第三方开发模块,虽然我们在写C语言代码,但是我们不需要关心内存的释放,那么如果你现在在配置一些比较罕见的nginx使用场景,你可能会需要去修改nginx在请求和连接上初始分配内存池大小。但是nginx官方可能会写着推荐通常不需要去该这样的配置。那么我们究竟要不要这些内存池的大小呢?下面,我们来看一看内存池究竟是怎么样运转的。

ngx_connection_s这样的结构体中,有一个成员变量就是pool,ngx_pool_t,它对应着这个连接所使用的的内存池。这个内存池可以通过一个配置项叫connection_pool_size去定义。那么,我们为什么会需要内存池呢,如果我们有一些工具的话我们会发现,nginx它所产生的内存碎片其实是非常小的,这就是内存池的一个功了。那么,内存池呢,它会把内存提前分配好一批,而且,当我们使用小块内存的时候 ,它会用next指针一个个连接在一起,每次我们使用的东西比较少的时候呢,第二次再分配小块内存,会连接在一起去使用,这样就大大减少了我们的内存碎片。当然我们如果分配大块内存的时候,还是会走到操作系统的alloc去分配大块的内存。那么对于nginx有什么好处呢?因为它主要在处理web请求,web请求,特别对于http请求,它有两个非常明显的特点。每当我们有一个TCP连接的时候,那么,这个TCP连接上面可能会运行很多http请求,也就是所谓的http keepalive请求,连接没有关闭,执行完一条请求以后还负责执行另外一条请求。 那么有一些内存呢,我为连接分配一次就够了,比如说,我去读取每一个请求的前1k字节,那么在连接内存池上,我分配一次,只要这个连接不关闭,那么这段1k的内存,我永远不需要释放,什么时候需要释放呢?连接关闭的时候我再释放。没有任何问题。请求内存池呢?每一个http请求,我开始分配的时候,我不知道分配多大,但是http请求,特别是http1.1而言,通常我们会分配4k的大小的内存,因为我们的url或者header往往需要分配那么多,如果没有内存池呢,我们可能需要频繁的分配,小块的分配,而分配内存,其实是有代价的,如果我们一次分配,分配较多的内存呢,就没有这样的问题。而请求执行完毕以后,哪怕连接我们还可以复用,我们也可以把请求池销毁,而这样,所有nginx第三方模块开发者,他们就不必关注,内存什么时候会释放,它只要关注,我是从请求内存池里面,申请分配的内存,还是连接内存池里,申请分配的内存。只要这个逻辑讲得通,比如请求结束以后,连接仍然想继续使用,那么你可以在连接内存池里面分配。好我们看一下具体的例子。还是在nginx_http_core_module这个模块中,我们可以看到它有一个叫connection_pool_size,点开以后可以看到默认情况下,它大约是256或者512,这个跟我们的操作系统位数是有关的。那么内存池配置512,并不代表,在这里我只能分配512字节,当我们分配的内存超过预分配的大小的时候,还是可以继续分配的,这里只是说,因为我提前预分配了足够大小的空间,可以减少我分配内存的次数。那我们再来看,另一个配置,叫request pool size也就是我们每一个请求的内存池的大小,这里我们可以看到,它的默认大小是4k,为什么差距会那么大呢?之所以会差距8倍,是因为,对于连接而言,它需要保存的上下文信息非常的少,它只需要帮助后面的请求读取最初一部分字节就可以了,而对于请求而言,我们需要保存大量的上下文信息,比如说所有读取到的url或者header,我需要一直保存下来,url通常还比较长,所以我们需要有4k的大小。当然官方文档中说,它对性能的影响比较小,如果我们在极端场景下,如果你的url特别大,你可以考虑把这个分配得更大,或者说你是很小内存的,url非常小,header也非常少,你可以考虑request_pool_size把它降一降,这样或许nginx消耗的内存会小一些,那么也意味着你可以做更大并发量的请求。

以上我们介绍了内存池的原理,以及请求内存池和连接内存池,它们的配置代表着怎样的意义。内存池对减少我们的内存碎片,对第三方模块的快速开发,是有很大意义的。可能有一些第三方模块不当使用了内存池,比如本该在请求内存池里分配内存,却在连接内存池分配内存,这可能会导致内存的延期释放,导致nginx的内存无谓的增加,这需要我们注意。

参考

陶辉老师的Nginx核心知识100讲

先上代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
class Student(object):
def __new__(cls, *args, **kwargs):
print('class.__new__ called')
return super(Student, cls).__new__(cls)

def __init__(self, name, height):
print('class.__init__ called')
self.name = name
self.height = height

def __str__(self):
return "The height of Student %s if %s" % (self.name, self.height)


def main():
xiaoming = Student('xiaoming', 175)
print(xiaoming)


if __name__ == '__main__':
main()

其中,new()不是一定要有,只有继承自object的类才有,该方法可以return父类(通过super(当前类名, cls).__new__())出来的实例,或者直接是object的__new__出来的实例。值得注意的是,在定义子类时没有重新定义__new__()时,Python默认调用该类父类的__new__()方法来构造该类实例,如果该类父类也没有重写__new__(),那么将一直追溯至object的__new__()方法,因为object是所有新式类的基类。如果子类中重写了__new__()方法,那么可以自由选择任意一个其他的新式类。

可见,当类中同时出现__new__()和__init__()时,先调用__new__,再调用__init__(),具体的执行过程为:

  1. 调用实例对象代码xiaoming = Student('xiaoming', 175);
  2. 传入name和height的参数,执行Student类的__new__()方法,该方法返回一个类的实例,通常会用父类super(Student, cls).__new__(cls),new()产生的实例即__init__()的self;
  3. 用实例来调用__init__()方法,进行初始化实例对象的操作。

可以看到,python中__new__()与__init__()的区别,

  1. 首先用法不同,new()用于创建实例,所以该方法是在实例创建之前被调用,它是类级别的方法,是个静态方法; 而__init__()用于初始化实例,所以该方法是在实例对象创建后被调用,它是实例级别的方法,用于设置对象属性的一些初始值。 由此可知,new()在__init__()之前被调用。如果__new__()创建的是当前类的实例,会自动调用__init__(),通过return调用的__new__()的参数cls来保证是当前类实例,如果是其他类的类名,那么创建返回的是其他类实例,就不会调用当前类的__init__()函数。
  2. 其次传入参数不同 new()至少有一个参数cls,代表当前类,此参数在实例化时由Python解释器自动识别; init()至少有一个参数self,就是这个__new__返回的实例,init()在__new__()的基础上完成一些初始化的操作。
  3. 返回值不同 new()必须有返回值,返回实例对象; init()不需要有返回值。 另外谈谈__new__()的作用,new()方法主要用于继承一些不可变的class,比如int,str, tuple,提供一个自定义这些类的实例化过程的途径,一般通过重载__new__()的方法来实现。代码如下:
1
2
3
4
5
6
class PositiveInteger(int):
def __new__(cls, value):
return super(PositiveInteger, cls).__new__(cls, abs(value))

a = PositiveInteger(-10)
print(a)

另外,new()方法还可以用来实现单例模式,也就是使每次实例化时只返回同一个实例对象。代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
# 利用__new__实现python单例模式.py

import threading


class Singleton(object):
_instance_lock = threading.Lock()

def __init__(self):
pass

def __new__(cls, *args, **kwargs):
if not hasattr(Singleton, "_instance"):
with Singleton._instance_lock:
if not hasattr(Singleton, "_instance"):
Singleton._instance = object.__new__(cls)
return Singleton._instance


def main():
obj1 = Singleton()
obj2 = Singleton()
obj1.attr = 'value1'
print(obj1.attr, obj2.attr)
print(obj1 is obj2)


if __name__ == '__main__':
main()

参考

  1. 实现单例模式的几种方式,new方式推荐使用,链接
  2. new 和 init 的区别,链接

人生唯一确定的就是不确定的人生。人的有限性就体现在,我们是无法寻找到从我们自己而来的一个确定。

第一还是阅读,跟人类伟大的灵魂对话,因为你的困惑在两千多年前人类就已经有过这样的困惑。人类所有伟大的思想家都试图对抗这种困惑。

但第二的话更重要的是去做,做一些事情,从身边的小事开始做起。每天都只是一个礼物,昨天的已经成为过去,明天还没有到来,我们唯一能够拥有的就是今天。所以今天是一个礼物,是一个gift,是一个present。所以我们做好每天该做的事情。今生就是我们的哨岗,站好哨就可以了。

人的内心始终有两面,一面有幽暗的一面,一面有光明的一面,你做哪一面?是做幽暗的一面,还是做光明的一面?人不是做自己,人是朝着人性中良善的那一面去前进,去尽量地抑制自己内心的幽暗。这个叫做自己。

接收自己的有限,去迎接未知的无限。

感谢罗翔罗老师。

给时光以生命,而不是给生命以时光。(这句话出自帕斯卡,To the time to life, rather than to life in time.老帕之所以有此言论,大概和他的生命状态有很大关系。帕斯卡从小体质虚弱,又因过度劳累而使疾病缠身。他只活到三十九岁,但一生辉煌,活得虽短却很精彩。正是这短暂的一生让他更清楚的看到生命的本质,福祸相依,这正是上帝的公平,或者说是概率的公平。他还说过一句非常有名的话:“人只不过是一根芦苇,是自然界最脆弱的东西,但他是一根能思想的芦苇。”)

网上的解释是:活着的每时每刻都要精彩,而不是让生命虚度,随着时光衰老。

我的理解是:给每一个此时此刻赋予生命的精彩,只要活得精彩就美好,不必强行续命。正如有些年老的人,其实与其在病床上忍受病痛的折磨,他们更愿意让自己在睡梦或者昏迷中死去。见过很多这样的老者。不愿接受病痛的折磨,这其实也体现了生命的尊严。比如,今年听说一位澳大利亚的老教授,100多岁,但是已没有故友活在人世,自己也疾病缠身,他选择到了可以执行安乐死的国家安乐死。他的原话说,没有人deserve这样的对待,这不是人该接受的对待,他宁可选择死亡。世界上没有感同身受,但是从他的话里也能知道在生命的最后,他宁可提前结束掉它。

给岁月以文明,而不是给文明以岁月。

网上解释:文明的灿烂与否并不是以时间来衡量的,宁可获得短暂的灿烂文明,而不愿苟且偷生。

我的理解是:努力创造出好的文明,来让岁月精彩,一旦文明终将走向衰败,不要强行延长它的寿命,让更好的文明来替代它,不要把这个文明变得丑陋,成为后来文明的障碍。

重复一下,给时光以生命,给岁月以文明

  1. 能不能使用join语句?

    1. 如果可以使用Index Nested-Loop Join算法,也就是说可以用上被驱动表上的索引,是没问题的。
    2. 如果使用Block Nested_Loop Join算法,扫描行数就会过多。尤其是在大表上的join操作,这样可能要扫描被驱动表很多次,会占用大量的系统资源。所以这种join尽量不要用。
  2. 如果要使用join,应该选择大表做驱动表还是选择小表做驱动表?

    1. 如果是Index Nested-Loop Join算法,应该选择小表做驱动表;
    2. 如果是Block Nested-Loop Join算法:
      • 在oin_buffer_size足够大的时候,是一样的;
      • 在join_buffer_size不够大的时候(这种情况更常见),应该选择小表做驱动表。
    3. 所以总是选择小表做驱动表。
  3. 什么叫做小表?

    在决定哪个表做驱动表的时候,应该是两个表按照各自的条件过滤,过滤完成之后,计算参与join的各个字段的总数据量,数据量小的那个表,就是”小表“,应该作为驱动表。

  4. 总体来看,不论是在原表上加索引,还是用有索引的临时表,我们的思路都是让 join 语句能够用上被驱动表上的索引,来触发 BKA 算法,提升查询性能。

读写分离的主要目标是分摊主库的压力。每个大型架构最终都会实现读写分离。

读写分离的架构有两种。

图1中的结构是客户端(client)主动做负载均衡,这种模式下一般会把数据库的连接信息放在客户端的连接层。也就是说,由客户端来选择后端数据库进行查询。

还有一种架构是,在MySQL和客户端之间有一个中间代理层proxy,客户端只连接proxy,由proxy根据请求类型和上下文决定请求的分发路由。

接下来,我们就看一下客户端直连和带proxy的读写分离架构,各有哪些特点。

  1. 客户端直连方案,因为少了一层proxy转发,所以查询性能稍微好一点儿,并且整体架构简单,排查问题更方便。但是这种方案,由于要了解后端部署细节,所以在出现主备切换、库迁移等操作的时候,客户端都会感知到,并且需要调整数据库连接信息。

    你可能会觉得这样客户端也太麻烦了,信息大量冗余,架构很丑。其实也未必,一般采用这样的架构,一定会伴随一个负责管理后端的组件,比如ZooKeeper,尽量让业务端只专注于业务逻辑开发。

  2. 带proxy的架构,对客户端比较友好。客户端不需要关注后端细节,连接维护、后端信息维护等工作,都是由proxy完成的。但这样的话,对后端维护团队的要求会更高。而且,proxy也需要有高可用架构。因此,带proxy架构的整体就相对比较复杂。

理解了这两种方案的优劣,具体选择哪个方案就取决于数据库团队提供的能力了。但目前看,趋势是往带proxy的架构方向发展的。

但是,不论使用哪种架构,你都会碰到我们今天要讨论的问题:由于主从可能存在延迟,客户端执行完一个更新事务后马上发起查询,如果查询选择的是从库的话,就有可能读到刚刚事务更新之前的状态。

这种“在从库上会读到系统的一个过期状态”的现象,在这篇文章里,我们暂且称之为“过期读”。

前面我们说过了几种可能导致主备延迟的原因,以及对应的优化策略,但是主从延迟还是不能100%避免的。

不论哪种结构,客户端都希望查询从库的数据结果,跟查主库的数据结果是一样的。

接下来,我们就来讨论怎么处理过期读问题。

这里,我先把文章中涉及到的处理过期读的方案汇总在这里,以帮助你更好的理解和掌握全文的知识脉络。这些方案包括:

  • 强制走主库方案;
  • sleep 方案;
  • 判断主备无延迟方案;
  • 配合 semi-sync 方案;
  • 等主库位点方案;
  • 等 GTID 方案。

强制走主库方案

强制走主库方案其实就是,将查询请求做分类。通常情况下,我们可以将查询请求分为这么两类:

  1. 对于必须要拿到最新结果的请求,强制将其发到主库上。比如,在一个交易平台上,卖家发布商品以后,马上要返回主页面,看商品是否发布成功。那么,这个请求需要拿到最新的结果,就必须走主库。
  2. 对于可以读到旧数据的请求,才将其发到从库上。在这个交易平台上,买家来逛商铺页面,就算晚几秒看到最新发布的商品,也是可以接受的。那么,这类请求就可以走从库。

你可能会说,这个方案是不是有点畏难和取巧的意思,但其实这个方案是用得最多的。

当然,这个方案最大的问题在于,有时候你会碰到”所有查询都不能是过期读“的需求,比如一些金融类的业务。这样的话,你就要放弃读写分离,所有读写压力都在主库,等同于放弃了扩展性。

因此接下来,我们来讨论的话题是:可以支持读写分离的场景下,有哪些解决过期读的方案,并分析各个方案的优缺点。

Sleep方案

主库更新后,读从库之前先sleep一下。具体的方案就是,类似于执行一条select sleep(1)命令。

这个方案的假设是,大多数情况下主备延迟在1s之内,做一个sleep可以有很大概率拿到最新的数据。

这个方案给你的第一感觉,很可能是不靠谱,应该不会有人用吧?并且,你可能还会说,直接在发起查询时限制性一条sleep语句,用户体验很不友好啊。

但,这个思路确实可以在一定程度上解决问题。为了看起来更靠谱,我们可以换一种方式。

以卖家发布商品为例,商品发布后,用Ajax(Asynchronous JavaScript + XML,异步JavaScript和XML)直接把客户端输入的内容作为”新的商品“显示在页面上,而不是真正地去数据库做查询。

这样,卖家就可以通过这个显示,来确认产品已经发布成功了。等到卖家再刷新页面,去查看商品的时候,其实已经过了一段时间了,也就达到了sleep的目的,进而也就解决了过期读的问题。

也就是说,这个sleep方案确实解决了类似场景下的过期读问题。但,从严格意义上来说,这个方案存在的问题就是不精确。这个不精确包含了两层意思:

  1. 如果这个查询请求本来0.5秒就可以在从库上拿到正确结果,也会等1秒;
  2. 如果延迟超过1s,还是会出现过期读。

判断主库无延迟方案

要确保备库无延迟,通常有三种做法。

通过前面的文章,我们知道show slave status结果里的seconds_behind_master参数的值,可以用来衡量主备延迟时间的长短。

所以第一种确保主备无延迟的方法是,每次从库执行查询请求前,先判断seconds_behind_master是否已经等于0。如果还不等于0,那就必须等到这个参数变为0才能执行查询请求。

seconds_behind_master的单位是秒,如果你觉得精度不够的话,还可以采用对比位点和GTID的方法来确保主备无延迟,也就是我们接下来要说的第二和第三种方法。

如图3所示,是一个show slave status结果的部分截图。

现在,我们就通过这个结果,来看看具体如何通过对比位点和GTID来确保主备无延迟。

第二种方法,对比位点确保主备无延迟:

  • Master_Log_File和Read_Master_Log_Pos,表示的是读到的主库的最新位点;
  • Relay_Master_Log_FIle和Exec_Master_Log_Pos,表示的是备库执行的最新位点。

如果Master_Log_File和Relay_MAster_Log_File、Read_master_Log_Pos和Exec_Master_Log_Pos这两组值完全相同,就表示接收到的日志已经同步完成。

第三种方法,对比GTID集合确保主备无延迟:

  • Auto_Position=1,表示这对主备关系使用了GTID协议;
  • Retrieved_Gtid_Set,是备库收到的所有日志的GTID集合;
  • Executed_Gtid_Set,是备库所有已经执行完成的GTID集合。

如果这两个集合相同,也表示备库接收到的日志都已经同步完成。

可见,对比位点和对比GTID这两种方法,都要比对比seconds_behind_master是否为0更准确。

在执行查询请求之前,先判断从库是否同步完成的方法,相比于sleep方案,准确度确实提升了不少,但还是没有达到”精确“的程度。为什么这么说呢?

我们现在一起回顾一下,一个事务的binlog在主备库之间的状态:

  1. 主库执行完成,写入binlog,并反馈给客户端;
  2. binlog被从主库发送给备库,备库收到;
  3. 在备库执行binlog完成。

我们上面判断主备无延迟的逻辑,是”备库收到的日志都执行完成了“。但是,从binlog在主备之间状态的分析中,不难看出还有一部分日志,处于客户端已经收到提交确认,而备库还没收到日志的状态。

如图4所示就是这样的一个状态。

这是,主库上执行完成了三个事务trx1,trx2和trx3,其中:

  1. trx1和trx2已经传到从库,并且已经执行完成了;
  2. trx3在主库执行完成,并且已经回复给客户端,但是还没有传到从库中。

如果这时候你在从库B上执行查询请求,按照我们上面的逻辑,从库认为已经没有同步延迟,但还是查不到trx3的。严格地说,就是出现了过期读。

那么,这个问题有没有办法解决呢?

配合semi-sync

要解决这个问题,就要引入半同步机制,也就是semi-sync replication。

semi-sync做了这样的设计:

  1. 事务提交的时候,主库把binlog发给从库;
  2. 从库收到binlog以后,发回给主库一个ack,表示收到了;
  3. 从库收到这个ack以后,才能给客户端返回”事务完成“的确认。

也就是说,如果启用了semi-sync,就表示所有给客户端发送过确认的事务,都确保了备库已经收到了日志。

在前面文章的评论区,有同学问道:如果主库掉电的时候,有些binlog还来不及发给从库,会不会导致系统数据丢失?

答案是,如果使用的是普通的异步复制机制,就有可能丢失,但semi-sync就可以解决这个问题。

这样,semi-sync配合前面关于位点的判断,就能够确定在从库上执行的查询请求,可以避免过期读。

但是,semi-sync+位点判断的方案,只对一主一备的场景是成立的。在一主多从场景中,主库只要等到一个从库的ack,就开始给客户端返回确认。这是,在从库上执行查询请求,就有两种情况:

  1. 如果查询是落在这个响应了ack的从库上,是能够确保读到最新数据;
  2. 但如果是查询落到了其他从库上,它们可能还没有收到最新的日志,就会产生过期读的问题。

其实,判断同步位点的方案还有另外一个潜在的问题,即:如果在业务更新的高峰期,主库的位点或者GTID集合更新很快,那么上面的两个位点等值判断就会一直不成立,很可能出现从库上迟迟无法响应查询请求的情况。

实际上,回到我们最初的业务逻辑里,当发起一个查询请求以后,我们要得到准确的结果,其实并不需要等到”主备完全同步“。

为什么这么说呢?我们来看一下这个时序图。

图5所示,就是等待位点方案的一个bad case。图中备库B下的虚线框,分别表示relaylog和binlog中的事务。可以看到,图5中从状态1到状态4,一直处于延迟一个事务的状态。

备库B一直到状态4都和主库A存在延迟,如果用上面必须等到无延迟才能查询的方案,select语句一直到状态4都不能被执行。

但是,其实客户端是在发完trx1更新后发起的select语句,我们只需要确保trx1已经执行完成就可以执行select语句了。也就是说,如果在状态3执行查询请求,得到的就是预期结果了。

到这里,我们小结一下,semi-sync配合判断主备无延迟的方案,存在两个问题:

  1. 一主多从的时候,在某些从库执行查询请求会存在过期读的现象;
  2. 在持续延迟的情况下,可能出现过度等待的问题。

接下来,我要和你介绍的等主库位点方案,就可以解决这两个问题。

等主库位点方案

要理解等主库位点方案,我需要先和你介绍一条命令:

1
select master_pos_wait(file, pos[, timeout]);

这条命令的逻辑如下:

  1. 它是在从库执行的;
  2. 参数file和pos指的是主库上的文件名和位置;
  3. timeout可选,设置为正整数N表示这个函数最多等待N秒。

这个命令正常返回的结果是一个正整数M,表示从命令开始执行,到应用完file和pos表示的binlog位置,执行了多少事务。

当然,除了正常返回一个正整数M外,这条命令还会返回一些其他结果,包括:

  1. 如果执行期间,备库同步线程发生异常,则返回NULL;
  2. 如果等待时间超过N秒,就返回-1;
  3. 如果刚开始执行的时候,就发现已经执行过这个位置了,则返回0。

对于图5中先执行trx1,再执行一个查询请求的逻辑,要保证能够查到正确的数据,我们可以使用这个逻辑:

  1. trx1事务更新完成后,马上执行show master status得到当前主库执行到的File和Position;
  2. 选定一个从库执行查询语句;
  3. 在从库上执行select master_pos_wait(FIle, Position, 1);
  4. 如果返回值是>=0的正整数,则在这个从库执行查询语句。
  5. 否则,到主库执行查询语句。

上面的流程画出来。

这里我们假设,这条select查询最多在从库上等待1秒。那么,如果1秒内master_pos_wait返回一个大于等于0的整数,就确保了从库上执行的这个查询结果一定包含了trx1的数据。

步骤5到主库执行查询语句,是这类方案常用的退化机制。因为从库的延迟时间不可控,不能无限等待,所以如果等待超时,就应该放弃,然后到主库去查。

你可能会说,如果所有的从库都延迟超过1秒了,那查询压力不就都跑到主库上了吗?确实是这样。

但是,按照我们设定不允许过期读的要求,就只有两种选择,一种是超时放弃,一种是转到主库查询,具体怎么选择,就需要业务开发同学做好限流策略了。

GTID方案

如果你的数据库开启了GTID模式,对应的也有等待GTID的方案。

MySQL中同样提供了一个类似的命令:

1
select wait_for_executed_gtid_set(gtid_set, 1); 

这条命令的逻辑是:

  1. 等待,直到这个库执行的事务中包含传入的gtid_set,返回0;
  2. 超时返回1.

在前面等位点的方案中,我们执行完事务后,还要主动去主库执行show master status。而MySQL 5.7.6版本开始,允许在执行完更新类事务后,把这个事务的GTID返回给客户端,这样等GTID的方案就可以减少一次查询。

这时,等GTID的执行流程就变成了:

  1. trx1事务更新完成后,从返回包直接获取这个事务的GTID,记为gtid1;
  2. 选定一个从库执行查询语句;
  3. 在从库上执行select wait_for_executed_gtid_set(gtid1, 1);
  4. 如果返回值是0,则在这个从库执行查询语句;
  5. 否则,到主库执行查询语句。

跟等主库位点的方案一样,等待超时后时候直接到主库查询,需要业务开发同学来做限流考虑。

我把这个流程图画出来。

在上面的第一步中,trx1事务更新完成后,从返回包直接获取这个事务的GTID。问题是,怎么能够让MySQL在执行事务后,返回包中带上GTID呢?

你只需要将参数session_track_gtids设置为OWN_GTID,然后通过API接口mysql_session_track_get_first从返回包解析出GTID的值即可。

在专栏的第一篇文章中,我介绍mysql_reset_connection的时候,有同学留言问这类接口应该怎么使用。

这里我再回答一下。其实,MySQL并没有提供这类接口的SQL用法,是提供给程序的API。

比如,为了让客户端在事务提交后,返回的GTID能够在客户端显示出来,我对MySQL客户端代码做了点修改,如下所示:

这样,就可以看到语句执行完成,显示出GTID的值。

当然了,这只是一个例子。你要是用这个方案的时候,还是应该在你的客户端代码中调用mysql_session_track_get_first这个函数。

小结

在今天这篇文章中,我跟你介绍了一主多从做读写分离时,可能碰到过期读的原因,以及几种应对的方案。

这几种方案中,有的方案看上去是做了妥协,有的方案看上去不那么靠谱儿,但都是有实际应用场景的,你需要根据业务需求选择。

即使是最后等待位点和等待 GTID 这两个方案,虽然看上去比较靠谱儿,但仍然存在需要权衡的情况。如果所有的从库都延迟,那么请求就会全部落到主库上,这时候会不会由于压力突然增大,把主库打挂了呢?

其实,在实际应用中,这几个方案是可以混合使用的。

比如,先在客户端对请求做分类,区分哪些请求可以接受过期读,而哪些请求完全不能接受过期读;然后,对于不能接受过期读的语句,再使用等 GTID 或等位点的方案。

但话说回来,过期读在本质上是由一写多读导致的。在实际应用中,可能会有别的不需要等待就可以水平扩展的数据库方案,但这往往是用牺牲写性能换来的,也就是需要在读性能和写性能中取权衡。

转载

感谢丁奇老师的专栏,如转载参考有不妥,请通知我立即删除。