• 事务是指满足 ACID 特性的一组操作,可以通过 Commit 整体提交这一组操作,也可以使用 Rollback 整体回滚这一组操作。
  • 事务支持是在引擎层实现的,InnoDB 支持事务,而 MySQL 原生的 MyISAM 引擎不支持事务。
  • 原子性(Atomicity) :事务是可以提交或回滚的原子单元。当事务对数据库进行多次更改时,要么在提交事务时所有更改都成功,要么在回滚事务时所有更改都被撤消。回滚通过回滚日志(Undo Log)来实现,回滚日志记录着事务所执行的修改操作,在回滚时反向执行这些修改操作即可。
  • 一致性(Consistency) :数据库在任何时候都保持一致状态ーー在每次提交或回滚之后,以及在事务处理过程中。如果跨多个表更新相关数据,查询可以看到所有旧值或所有新值,而不是新旧值的混合。数据和日志之间、主库和备库之间要保持一致。
  • 隔离性(Isolation) :事务在进行过程中彼此隔离,它们不能互相干扰或查看彼此的未提交数据。这种隔离是通过锁机制实现的。可以调整隔离级别,在可以确保事务之间不会互相干扰的情况下,用较少的隔离保护来换取性能和并发的提高。
  • 持久性(Durability) :一旦事务提交,则其所做的修改将会永远保存到数据库中。即使系统发生崩溃,事务执行的结果也不能丢失。系统发生崩溃可以用重做日志(Redo Log)进行恢复,从而实现持久性。与回滚日志记录数据的逻辑修改不同,重做日志记录的是数据页的物理修改。

并发一致性问题

  • 脏读 :一个事务可以读到其他事务未提交的数据。
  • 不可重复读 :在一个事务内多次读取同一数据集合,在此期间其他事务修改了该数据并提交(Update),导致原事务多次读取到结果可能不一致。
  • 幻读 :一个事务在前后两次查询同一个范围的时候,后一次查询看到了前一次查询没有看到的新插入的行(Insert)。
    • RR 隔离级别要求前后两次查询的结果完全一致,“快照读”时一定不会读到其他事务新插入的行,但“当前读”时有可能,所以引入间隙锁解决幻读问题。
    • RC 隔离级别允许前后两次查询的结果不一致,只要新插入的行已经被其他事务提交了,就应该读到,所以幻读不是问题,是特性。

事务的隔离级别

多版本并发控制(MVCC)

  • 多版本并发控制(Multi-Version Concurrency Control, MVCC)是 InnoDB 存储引擎实现隔离级别的一种具体方式,用于实现读已提交 RC、可重复读 RR。
  • 基本思想:同一个数据行在系统中可以存在多个版本,不同时刻启动的事务会有不同的 read-view。写操作会新增一个版本快照,而读操作去读旧版本快照,没有互斥关系。
    • 读未提交 RU :直接返回记录上的最新值,无需 read-view。
    • 读已提交 RC :在每个 SQL 语句开始执行时都重新创建 read-view。
    • 可重复读 RR :在事务启动时创建 read-view,事务内的读操作都共用这一个 read-view。
    • 串行化 RS :直接用加锁的方式来避免并行访问,无需 read-view。
  • 一致性读视图(consistent read-view) :即所谓的“快照”,它没有物理结构,而是通过高低水位、数据行版本号、回滚日志来判断数据是否可见。
    • 事务 trx_id :InnoDB 对写操作事务分配的事务 id,读操作不分配。
    • 数据行版本号 row trx_id :在这个数据行上执行了写操作的事务 trx_id。
    • 回滚日志 Undo log :通过回滚指针把一个数据行的所有版本号、版本对应的数据值连接起来。
    • 高低水位 :未提交事务数组,具体是指当前事务启动时数据库中已创建但未提交的其他 trx_id ,其最小值即低水位,最大值 + 1 为高水位。
    • 根据数据行版本号与未提交数组、高低水位的关系,决定此数据行版本是否可见。如果不可见,则需通过回滚日志找到上一个数据行版本,继续重复此判断过程,直到找到一个可见版本。
row trx_id < 低水位 row trx_id 不在未提交数组中 row trx_id 在未提交数组中 row trx_id > 高水位
此数据行版本在当前事务启动时的状态 已提交 已提交 未提交 未创建
此数据行版本对当前事务是否可见 可见 可见 不可见 不可见

快照读/一致性读 VS 当前读

  • 快照读/一致性读 :普通的 SELECT 语句是读快照中的数据,不需要加行锁。
  • 当前读 :INSERT、UPDATE、DELETE 语句都是先读后写,读取最新的数据,需要加行锁。如果行锁被其他事务占用,则需等待其他事务提交时才会释放行锁(两阶段锁协议)。
  • SELECT 语句也可以强制加行锁,进行当前读。
# 当前读,加 S 锁,共享锁
SELECT * FROM table WHERE xxx lock in share mode;
# 当前读,加 X 锁(写锁),排他锁
SELECT * FROM table WHERE xxx for update;
# 在 select 语句执行时,id=5 这一行会加一个写锁,而且由于两阶段锁协议,这个写锁在执行 commit 语句的时候才能释放。
begin;
select * from t where id=5 for update;
commit;
  • 为什么不要使用长事务
    • 长事务意味着从事务启动至今的所有回滚日志(undo log)都必须保留,会大量占用存储空间。
    • 由于两阶段锁协议,长事务持有的锁要等到事务提交时才能释放,长期占用锁资源,可能拖垮整个库。
  • 查询长事务 sql
# 查询是否有持续时间超过 60s 的长事务
select * from information_schema.innodb_trx where TIME_TO_SEC(timediff(now(),trx_started)) > 60;
  • 如何避免长事务
    • 显式启动事务: begin 或 start transaction, commit,rollback。
    • 设置自动提交:set autocommit=1,MySQL 的默认设置,对于未显式启动的语句,每个语句都会被当做一个事务并自动提交。
  • 如何避免长事务对业务的影响
    • 从应用开发端来看
      • 确认是否使用了 set autocommit=0。在测试环境中把 MySQL 的 general_log 开起来,然后随便跑一个业务逻辑,通过 general_log 的日志来确认。框架如果会设置这个值,一般也就会提供参数来控制行为,你的目标就是把它改成 1。有些客户端连接框架会默认连接成功后先执行一个 set autocommit=0 的命令,如果又是长连接(连接池),就导致了意外的长事务。
      • 确认是否有不必要的只读事务。有些框架会习惯不管什么语句先用 begin/commit 框起来。例如业务并没有这个需要,但是也把好几个 select 语句放到了事务中。这种只读事务可以去掉。
      • 业务连接数据库的时候,根据业务本身的预估,通过 SET MAX_EXECUTION_TIME 命令,来控制每个语句执行的最长时间,避免单个语句意外执行太长时间。
    • 从数据库端来看
      • 监控 information_schema.Innodb_trx 表,设置长事务阈值,超过就报警 / 或者 kill。
      • Percona 的 pt-kill 这个工具不错,推荐使用。
      • 在业务功能测试阶段要求输出所有的 general_log,分析日志行为提前发现问题。
      • 如果使用的是 MySQL 5.6 或者更新版本,把 innodb_undo_tablespaces 设置成 2(或更大的值)。如果真的出现大事务导致回滚段过大,这样设置后清理起来更方便。

利用事务统计大表总行数

  • Mysql 获取表的总行数 3 种方式
    • MyISAM 引擎把一个表的总行数存在了磁盘上,虽然 count(*) 很快,但是不支持事务。
    • show table status 命令虽然返回很快(TABLE_ROWS),但是不准确,是随机采样估算的,误差可达50%,其结果跟information_schema.tables 的行数一样。
    • InnoDB 引擎由于有 MVCC,不同时刻开启的事务, count(*) 应查询到的总行数是不确定的,必须遍历全表,虽然结果准确,但大表会有性能问题。优化器会选择最小的索引树来遍历,因为二级索引数据页的行密度比较大,需要扫描的数据页就较少,能减少磁盘IO。
  • InnoDB 大表要经常查询总行数时,只能自己计数
    • Redis 计数:有丢失数据和计数不精确的问题。异常重启时可能丢失计数的更新,需要一次 count(*) ,是可接受的代价。但 Redis 计数值会跟 MVCC 的实际行数不一致,这两个不同的存储构成的系统,不支持分布式事务,无法拿到精确一致的视图。
    • Mysql 单独建一个计数表,把计数的修改和写数据操作放在一个事务中,读取计数器和查询最新数据也在一个事务中。能解决技术不精确的问题,而且 InnoDB 也是 crash-safe 的。
    • 计数的修改和写数据操作谁先谁后
      两者都在一个事务中,其顺序是不影响逻辑结果的。但是,从并发系统性能的角度考虑,应该先插入行,后更新计数表。因为可能多个事务都想同时更新计数表的同一行,会有行锁竞争,行锁是两阶段提交,最影响并发度的更新语句应放最后,从而最大程度地减少事务之间的锁等待,提升并发度。
  • count(1) ≈ count(*) > count(主键 id) > count(字段)
    count() 是一个聚合函数,对于返回的结果集,一行行地判断,如果 count 函数的参数不是 NULL,累计值就加 1,否则不加。最后返回累计值。
    count(字段)表示返回满足条件的数据行里面,“字段”值不为 NULL 的总个数,最慢。
    count(*)count(主键 id)count(1) 的参数都不为空,所以都表示返回满足条件的结果集的总行数,优化器会选择最小的索引树来遍历。
    count(*) 专门做了优化,不取值,最快。
    count(1) 无需取值,最快。
    count(主键 id) 需要取主键 ID,涉及到解析数据行,拷贝字段值,略慢。
事务处理介绍  事务是这样一种机制,它确保多个SQL语句被当作单个工作单元来处理。事务具有以下的作用:  * 一致性:同时进行的查询和更新彼此不会发生冲突,其他用户不会看到发生了变化但尚未提交的数据。  * 可恢复性:一旦系统故障,数据库会自动地完全恢复未完成的事务。  二 事务与一致性  事务是完整性的单位,一个事务的执行是把数据库从一个一致的状态转换成另一个一致的状态。因此, 用户A往——》用户B账户里转100块钱。 正常逻辑:首先用户A账户里扣除一百元,然后用户B账户增加100元,则转账操作完成。这是正常情况,接下来再看看这种情况: 用户A账户里扣除100元,这条操作语句执行完了,正要