「笔记」MySQL 实战 45 讲 - 实践篇(六)

不连续的自增主键

  • 由于自增主键可以让主键索引尽量地保持递增顺序插入,避免了页分裂,因此索引更紧凑

  • 自增值保存在哪儿

    • 表的结构定义存放在后缀名为.frm 的文件中,但是并不会保存自增值。
    • 不同的引擎对于自增值的保存策略不同
      • MyISAM 引擎的自增值保存在数据文件中
      • InnoDB 引擎的自增值,其实是保存在了内存里(MySQL 8.0 版本后才有自增值持久化的能力
        • 在 MySQL 5.7 及之前的版本
          • 自增值保存在内存里,并没有持久化,每次重启后主动将当前 max(id) + 1作为自增值
          • MySQL 重启可能会修改一个表的 AUTO_INCREMENT 的值(作关联外键时需当心
        • 在 MySQL 8.0 版本
          • 自增值的变更记录在了 redo log 中,重启的时候依靠 redo log 恢复重启之前的值
  • 自增值修改机制

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

      • 如果插入数据时 id 字段指定为 0、null 或未指定值,那么就把这个表当前的 AUTO_INCREMENT 值填到自增字段
      • 如果插入数据时 id 字段指定了具体的值,就直接使用语句里指定的值(插入值 X,自增值是Y
        • 如果 X
        • 如果 X≥Y,就需要把当前自增值修改为新的自增值;
    • 自增值生成算法

      • 从 auto_increment_offset 开始,以 auto_increment_increment 为步长,持续叠加,直到找到第一个大于 X 的值,作为新的自增值
        • auto_increment_offset:自增的初始值(默认值是1
        • auto_increment_increment:步长(默认值是1
      • 在一些场景下,使用的就不全是默认值,双 M 主备结构要求双写时,步长设置为2
        • 让一个库的自增 id 都是奇数,另一个库都是偶数,避免两个库生成主键发生冲突
    • 自增值的修改时机

      • 模拟唯一键冲突时流程来演示自增值的修改时机

        「笔记」MySQL 实战 45 讲 - 实践篇(六)_第1张图片

        • 这个表的自增值改成 3,是在真正执行插入数据的操作之前
        • 碰到唯一键冲突时,并未自增值再改回去
        • 唯一键冲突是导致自增主键 id 不连续的第一种原因
      • 事务回滚也会产生类似的现象,这就是第二种原因

      • 自增值不回退主要原因是为了提高性能(若要实现回退,将极大降低系统并发能力

    • 自增锁的优化

      • 自增 id 锁并不是一个事务锁,而是每次申请完就马上释放,以便允许别的事务再申请
      • 在 MySQL 5.0 版本的时候,自增锁的范围是语句级别(影响并发度
        • 果一个语句申请了一个表自增锁,这个锁会等语句执行结束以后才释放
      • MySQL 5.1.22 版本引入了一个新策略,新增参数 innodb_autoinc_lock_mode,默认值是 1
        • 这个参数的值被设置为 0 时,表示采用之前 MySQL 5.0 版本的策略
        • 这个参数的值被设置为 1 时
          • 普通 insert 语句,自增锁在申请之后就马上释放
          • 类似 insert … select 这样的批量插入数据的语句,自增锁还是要等语句结束后才释放
        • 这个参数的值被设置为 2 时,所有的申请自增主键的动作都是申请后就释放锁
      • 在生产上,尤其是有 insert … select 这种批量插入数据的场景时,从并发插入数据性能考虑
        • 建议设置为:innodb_autoinc_lock_mode=2 ,并且 binlog_format=row
        • 既能提升并发性,又不会出现数据一致性问题
        • 批量插入数据包含的语句类型是 insert … select、replace … select 和 load data 语句
      • 对于批量插入数据的语句,MySQL 有一个批量申请自增 id 的策略
        • 语句执行过程中,第一次申请自增 id,会分配 1 个;
        • 1 个用完以后,这个语句第二次申请自增 id,会分配 2 个;
        • 2 个用完以后,还是这个语句,第三次申请自增 id,会分配 4 个;
        • 依此类推,同一个语句去申请自增 id,每次申请到的自增 id 个数都是上一次的两倍。
      • 批量申请自增 id 的策略也正是出现自增 id 不连续的第三种原因

insert 锁情况

  • insert … select 语句

    • 栗子(可重复读隔离级别下、binlog_format=statement
      • session A:insert into t2(c,d) select c,d from t;
      • session B:insert into t values(-1,-1,-1);
      • 如果未加锁且两个事物并发执行,可能同步至备库时会出现主备不一致情况
    • 对表 t 的所有行和间隙加锁是为了保证 日志和数据的一致性
  • insert 循环写入

    • 栗子:insert into t(c,d) (select c+1, d from t force index© order by c desc limit 1);

    • explain 结果

      img

      • Using temporary 表示这个语句用到了临时表(需要把表 t 的内容读出来,写入临时表

      • Explain 结果里的 rows=1 是因为受到了 limit 1 的影响(实际扫描了 5行

    • 查看 Innodb_rows_read 变化

      「笔记」MySQL 实战 45 讲 - 实践篇(六)_第2张图片

      • 创建临时表,表里有两个字段 c 和 d
      • 按照索引 c 扫描表 t,依次取 c=4、3、2、1,然后回表,读到 c 和 d 的值写入临时表(4条
      • 由于语义里面有 limit 1,所以只取了临时表的第一行,再插入到表 t 中(1条
    • 这个语句会导致在表 t 上做全表扫描,并且会给索引 c 上的所有间隙都加上共享的 next-key lock

    • 这里需要使用临时表的原因

      • 这类一边遍历数据,一边更新数据的情况,如果读出来的数据直接写回原表,就可能在遍历过程中,读到刚刚插入的记录,新插入的记录如果参与计算逻辑,就跟语义不符
    • 由于这个语句涉及的数据量很小,可以考虑使用内存临时表来做这个优化

      create temporary table temp_t(c int,d int) engine=memory;
        insert into temp_t  (select c+1, d from t force index(c) order by c desc limit 1);
        insert into t select * from temp_t;
        drop table temp_t;
      
  • insert 唯一键冲突

    • 唯一键冲突加锁(可重复读(repeatable read)隔离级别下

      「笔记」MySQL 实战 45 讲 - 实践篇(六)_第3张图片

      • 发生唯一键冲突的时候,并不只是简单地报错返回,还在冲突的索引上加了锁
      • session A 持有索引 c 上的 (5,10]共享 next-key lock(读锁)
      • 主键索引、唯一索引冲突时均是加的 next-key lock
    • 唯一键冲突 – 死锁

      • 死锁场景复现

        「笔记」MySQL 实战 45 讲 - 实践篇(六)_第4张图片

        • 在 session A 执行 rollback 语句回滚的时候,session C 几乎同时发现死锁并返回
      • 状态变化图 – 死锁

        「笔记」MySQL 实战 45 讲 - 实践篇(六)_第5张图片

        • T2 时刻,B,C 同时执行相同的 insert 语句,发现了唯一键冲突,加上读锁
        • T3 时刻,A 回滚,B,C 同时发起插入操作,申请加写锁(发生死锁
      • 这里死锁的场景即常见的锁兼容死锁场景:双方都获得读锁且都想申请死锁,导致死锁

        • 读锁与读锁 兼容,写锁 与 读写锁 互斥
        • 死锁还有另外一种场景场景:AB - BA 场景
  • Insert into … on duplicate key update

    • 栗子:insert into t values(11,10,10) on duplicate key update d=100;
      • 这里语义的逻辑是,插入一行数据,如果碰到唯一键约束,就执行后面的更新语句
      • 冲突后会更新对应值(这里是 d = 100),且会给索引 c 上 (5,10] 加 next-key lock(写锁)
    • 如果有多个列违反了唯一性约束,就会按照索引的顺序,修改跟第一个索引冲突的行
    • 需要注意的是,执行这类语句的 affected rows 返回的是 2,很容易造成误解
      • 真正更新的只有一行,只是在代码实现上,insert 和 update 都认为自己成功
      • update 计数加了 1, insert 计数也加了 1,即 affected rows 返回的是 2

复制表

  • 常见三种复制表的方式
    • mysqldump 方法
    • 导出 CSV 文件
    • 物理拷贝方法
  • 以上三种方式的优缺点(后两种都是逻辑备份方式,支持跨引擎
    • 物理拷贝的方式速度最快,尤其对于大表拷贝来说是最快的方法
      • 如果出现误删表情况,用备份恢复出误删之前的临时库,然后再把临时库中的表拷贝到生产库上,是恢复数据最快的方法
      • 但这种方法的使用也存在一定的局限性
        • 必须是全表拷贝,不能只拷贝部分数据
        • 需要到服务器上拷贝数据,在用户无法登录数据库主机的场景下无法使用
        • 由于是通过拷贝物理文件实现的,源表和目标表都是使用 InnoDB 引擎时才能使用
    • 用 mysqldump 生成包含 INSERT 语句文件的方法,可以在 where 参数增加过滤条件部分导出
      • 不足之一是不能使用 join 这种比较复杂的 where 条件写法
    • 用 select … into outfile 的方法是最灵活的,支持所有的 SQL 写法
      • 缺点之一就是每次只能导出一张表的数据,而且表结构也需要另外的语句单独备份

用户授权

  • grant 语句会同时修改数据表和内存,判断权限的时候使用的是内存数据
    • 因此,规范地使用 grant 和 revoke 语句,是不需要随后加上 flush privileges 语句的
  • flush privileges 语句本身会用数据表的数据重建一份内存权限数据,
    • 所以在权限数据可能存在不一致的情况下再使用此命令
    • 这种不一致往往是由于直接用 DML 语句操作系统权限表导致的(尽量不要使用这类语句
  • 在使用 grant 语句赋权时:grant super on . to ‘ua’@’%’ identified by ‘pa’; 除了赋权外还包含了
    • 如果用户’ua’@’%'不存在,就创建这个用户,密码是 pa;
    • 如果用户 ua 已经存在,就将密码修改成 pa;
    • 不建议的写法,因为这种写法很容易就会不慎把密码给改了

分区表

  • 分区表是什么

    • 创建分区表

        
        CREATE TABLE `t` (
          `ftime` datetime NOT NULL,
          `c` int(11) DEFAULT NULL,
          KEY (`ftime`)
        ) ENGINE=InnoDB DEFAULT CHARSET=latin1
        PARTITION BY RANGE (YEAR(ftime))
        (PARTITION p_2017 VALUES LESS THAN (2017) ENGINE = InnoDB,
         PARTITION p_2018 VALUES LESS THAN (2018) ENGINE = InnoDB,
         PARTITION p_2019 VALUES LESS THAN (2019) ENGINE = InnoDB,
        PARTITION p_others VALUES LESS THAN MAXVALUE ENGINE = InnoDB);
        insert into t values('2017-4-1',1),('2018-4-1',1);
      
      • 初始化插入了两行记录,按照定义的分区规则,分别落在 p_2018 和 p_2019 这两个分区上
    • 这个表包含了一个.frm 文件和 4 个.ibd 文件,每个分区对应一个.ibd 文件

      • 对于引擎层来说,这是 4 个表;
      • 对于 Server 层来说,这是 1 个表;
    • 除了以范围分区(range)以外,MySQL 还支持 hash 分区、list 分区等分区方法

  • 分区表的引擎层行为

    • 举个在分区表加间隙锁的例子,目的是说明对于 InnoDB 来说,这是 4 个表

      「笔记」MySQL 实战 45 讲 - 实践篇(六)_第6张图片

      • 对于普通表来说的加锁范围

        「笔记」MySQL 实战 45 讲 - 实践篇(六)_第7张图片

        • 也就是说,‘2017-4-1’ 和’2018-4-1’ 这两个记录之间的间隙是会被锁住的
      • 分区表 t 的加锁范围

        「笔记」MySQL 实战 45 讲 - 实践篇(六)_第8张图片

        • 由于分区表的规则,session A 的 select 语句只操作了分区 p_2018(深绿色为加锁范围
    • 再来一个MyISAM 分区表的例子

      「笔记」MySQL 实战 45 讲 - 实践篇(六)_第9张图片

      • 预期:由于 MyISAM 引擎只支持表锁,所以这条 update 语句会锁住整个表 t 上的读
      • 实际:MyISAM 的表锁是在引擎层实现的,session A 加的表锁,其实是锁在分区 p_2018 上
    • 手动分表和分区表有什么区别

      • 在性能上,这和分区表并没有实质的差别
        • 手工分表的逻辑,也是找到需要更新的所有分表,然后依次执行更新
      • 从引擎层看,这两种方式也是没有差别的
        • 一个是由 server 层来决定使用哪个分区,一个是由应用层代码来决定使用哪个分表
      • 其实这两个方案的区别,主要是在 server 层上
        • 打开表的行为,即一个是第一次访问的时候需要访问所有分区
        • 分区表共用 MDL 锁
  • 分区策略

    • 每当第一次访问一个分区表的时候,MySQL 需要把所有的分区都访问一遍(MyISAM 引擎时
      • 一个典型的报错情况是这样的:如果一个分区表的分区很多并且超过阈值而报错
      • open_files_limit 参数使用的是默认值 1024,访问这个表时,需要打开所有的文件
    • MyISAM 分区表使用的分区策略,我们称为通用分区策略(generic partitioning)
      • 每次访问分区都由 server 层控制
      • 通用分区策略因历史问题,在文件管理、表管理的实现上很粗糙,因此有比较严重的性能问题
      • MySQL 从 5.7.17 开始,将 MyISAM 分区表标记为即将弃用 (deprecated)
      • 从 MySQL 8.0 版本开始,就不允许创建 MyISAM 分区表
    • 从 MySQL 5.7.9 开始,InnoDB 引擎引入了本地分区策略(native partitioning)
      • 策略是在 InnoDB 内部自己管理打开分区的行为
      • 目前来看,只有 InnoDB 和 NDB 这两个引擎支持了本地分区策略
  • 分区表的 server 层行为

    • MySQL 在第一次打开分区表的时候,需要访问所有的分区
    • 在 server 层,认为这是同一张表,因此所有分区共用同一个 MDL 锁
    • 在引擎层,认为这是不同的表,因此 MDL 锁之后的执行过程,会根据分区表规则,只访问必要的分区
  • 分区表的应用场景

    • 分区表一个显而易见的优势是对业务透明,相对于用户分表来说,使用分区表的业务代码更简洁
    • 分区表可以很方便的清理历史数据
      • 可以直接通过 alter table t drop partition …这个语法删掉分区,从而删掉过期历史数据
      • 与使用 delete 语句删除数据相比,优势是速度快、对系统影响小

自增id 用完

  • 每种自增 id 有各自的应用场景,在达到上限后的表现也不同
    • 表的自增 id 达到上限后,再申请时值就不会改变,进而导致继续插入数据时报主键冲突的错误

      • 无符号整型 (unsigned int) 是 4 个字节,上限就是 2^32-1 = 4294967295
      • 如果有可能达到这个上限,就应该创建成 8 个字节的 bigint unsigned
    • row_id 达到上限后,则归 0 再重新递增,如果出现相同的 row_id,后写的数据会覆盖之前的数据

      • 如果没有指定主键,那么 InnoDB 会给你创建一个不可见的,长度为 6 个字节的 row_id
      • InnoDB 维护了一个全局的 dict_sys.row_id 值,所有无主键的 InnoDB 表插入时均会使用
      • 在代码实现时 row_id 是一个长度为 8 字节的无符号长整型 (bigint unsigned)
        • 但 InnoDB 在设计时,给 row_id 留的只是 6 个字节的长度,只存放了最后 6 个字节
          • row_id 写入表中的值范围,是从 0 到 248-1
          • 当 dict_sys.row_id=248时,如果再有插入数据的行为要来申请 row_id,拿到以后再取最后 6 个字节的话就是 0(即达到上限后,继续循环
      • 对比表自增 id 策略,表自增 id 到达上限后,再插入数据时报主键冲突错误,是更能被接受的
        • 毕竟覆盖数据,就意味着数据丢失,影响的是数据可靠性
        • 报主键冲突,是插入失败,影响的是可用性
        • 而一般情况下,可靠性优先于可用性
    • Xid 只需要不在同一个 binlog 文件中出现重复值即可。虽然理论上会出现重复值,但是概率极小,可以忽略不计

      • MySQL 内部维护了一个全局变量 global_query_id,每次执行语句的时候将它赋值给 Query_id,然后给这个变量加 1
        • 若为事务执行的第一条语句,那么 MySQL 还会同时把 Query_id 赋值给这个事务的 Xid
      • global_query_id 是一个纯内存变量,重启之后就清零
        • 在同一个数据库实例中,不同事务的 Xid 也是有可能相同的
        • MySQL 重启之后会重新生成 binlog 文件,保证了同一个 binlog 中,Xid 一定是惟一的
      • global_query_id 定义的长度是 8 个字节,这个自增值的上限是 2^64-1
    • InnoDB 的 max_trx_id 递增值每次 MySQL 重启都会被保存起来,所以我们文章中提到的脏读的例子就是一个必现的 bug,好在留给我们的时间还很充裕

      • InnoDB 内部维护了一个 max_trx_id 全局变量,每次需要申请一个新的 trx_id 时,就获得 max_trx_id 的当前值,然后并将 max_trx_id 加 1

      • InnoDB 数据可见性的核心思想

        • 每一行数据都记录了更新它的 trx_id,当一个事务读到一行数据的时候,判断这个数据是否可见的方法,就是通过事务的一致性视图与这行数据的 trx_id 做对比
      • 对于正在执行的事务,你可以从 information_schema.innodb_trx 表中看到事务的 trx_id

        「笔记」MySQL 实战 45 讲 - 实践篇(六)_第10张图片

        • 对于只读事务,InnoDB 并不会分配 trx_id(加锁读 除外
        • T2 时刻查到的这个很大的数字是怎么来的呢
          • 这个数字是每次查询的时候由系统临时计算出来的
          • 它的算法是:把当前事务的 trx 变量的指针地址转成整数,再加上 2^48
        • 使用这个算法,就可以保证以下两点
          • 因为同一个只读事务在执行期间,它的指针地址是不会变的,查询出的 trx_id 一样
          • 如果有并行的多个只读事务,每个事务的 trx 变量的指针地址肯定不同
        • 为什么还要再加上 248呢?
          • 目的是要保证只读事务显示的 trx_id 值比较大,正常情况下就会区别于读写事务的 id
          • 只在理论上可能出现一个读写事务与一个只读事务显示的 trx_id 相同的情况
        • 只读事务不分配 trx_id,有什么好处呢?
          • 一个好处是,这样做可以减小事务视图里面活跃事务数组的大小
            • 在创建事务的一致性视图时,InnoDB 就只需要拷贝读写事务的 trx_id
          • 另一个好处是,可以减少 trx_id 的申请次数(减少了并发事务申请 trx_id 的锁冲突
    • thread_id 是我们使用中最常见的,而且也是处理得最好的一个自增 id 逻辑了

      • 系统保存了一个全局变量 thread_id_counter,每新建一个连接,就将 thread_id_counter 赋值给这个新连接的线程变量

      • thread_id_counter 定义的大小是 4 个字节,因此达到 232-1 后,它就会重置为 0,然后继续增加

      • 不会在 show processlist 里看到两个相同的 thread_id 原因(唯一数组的逻辑

        do {
          new_id= thread_id_counter++;
        } while (!thread_ids.insert_unique(new_id).second);
      

你可能感兴趣的:(「笔记」MySQL,实战,45,讲,自增主键,insert锁,复制表,分区表,自增id用完)