← 学习库技术专题源码原理 · 故障排查 · 架构实践

专题 06 / 完整阅读

数据库事务、MVCC 与性能优化

主要使用 MySQL 8.0.36 InnoDB,另外对照 PostgreSQL 16、Oracle 19c 和 OceanBase 4.x。不同数据库可以都叫 MVCC,却把旧版本放在不同地方,也不一定采用相同的锁和隔离规则。

核心知识与原理

ACID、日志与可见性

转账时,扣款和加款必须一起成功或一起撤销,这对应 Atomicity。金额约束、账户关系和业务规则要一直成立,这是 Consistency。并发事务能看到什么、会互相等到什么程度,由 Isolation 决定。成功提交后,按配置承诺的数据应能从故障中恢复,这是 Durability。

InnoDB 修改数据页后,不必立刻把每个页都写到磁盘。它先写 redo,崩溃后据此重做必要修改。WAL 要求相关日志先于数据页落盘。Undo 则用来撤销未成功事务,并为快照读提供旧版本。Redo 和 Undo 解决的是不同问题,都不是备份。

MySQL 的 binlog 又是另一层日志,主要服务复制和逻辑恢复。InnoDB 提交需要与 binlog 协调,持久程度还受 innodb_flush_log_at_trx_commit、sync_binlog 和存储设备影响。数据库返回成功,不等于异步副本当时一定已有这笔数据。

InnoDB Read View 与 RC/RR

假设余额从 100 改为 80,写事务尚未提交。另一个线程做普通 SELECT,不必看到未提交的 80,也不一定要等写锁:它可以沿 Undo 找到之前的 100。MVCC 就是让不同事务按规则读到合适的版本。

InnoDB 聚簇记录含事务 ID 和 Undo 指针。Read View 保存创建者、快照时活跃的读写事务集合,以及判断区间的界限。changes_visible() 先判断版本是否早于活跃事务界限、是否为自己写入,再检查新事务界限和活跃集合。不可见时,就沿 Undo 找更旧的版本。

RC 下,普通一致性读通常每条语句新建快照:第一次查完后别人提交修改,第二次就可能读到新值。RR 通常在第一次一致性读建立快照,后续复用;不是写 BEGIN 的那一刻必然建立。显式 consistent snapshot 等路径另有创建时机。

这里的“普通读”很重要。SELECT FOR UPDATE 和 UPDATE 要读取可以锁定和修改的较新状态,走当前读。一个 RR 事务先普通 SELECT,再 UPDATE,不应假定两个动作都只面对同一份旧快照。长快照还会让 purge 保留它可能用到的 Undo,影响清理。

索引、范围锁与执行计划

InnoDB 的主键索引叶子保存整行,二级索引叶子保存索引值和主键。通过二级索引找到主键,再去取整行,叫回表。查询所需列都在索引里时可以减少回表,这就是覆盖索引,但增加宽索引也会占内存、增加写成本。

联合索引 (tenant_id, created_at, id) 按这几列依次排序。tenant_id 等值能缩小到某个租户,created_at 范围再缩小连续扫描区间。范围之后的列不一定继续缩小这个区间,但仍可能用于 ICP 或过滤,不能一句“范围后索引失效”就结束。

范围更新或锁定读还涉及间隙。记录锁保护已有索引记录,Gap Lock 保护记录之间的空隙,Next-Key Lock 把记录和前间隙一起保护。RR 下某些范围锁定读用它阻止插入;唯一索引完整等值命中通常只锁记录,未命中又要另看。RC 大多数搜索不使用 gap lock,但外键和重复键检查有例外。

SQL 最终只返回一行,不代表只锁一行。若访问路径扫描了很大范围,锁也可能很多。EXPLAIN 先看 key、rows、访问类型和 Extra,再在受控环境用 EXPLAIN ANALYZE 比较实际扫描量。后者会真实执行,不能随意对生产重查询使用。死锁时应有界重试整个事务,重新做决策,而不是只补最后一条 SQL。

-- 业务示例:幂等键与条件状态转换放进同一个事务。
START TRANSACTION;
INSERT INTO processed_event(event_id) VALUES ('pay-20261008-001');
UPDATE orders SET status='PAID'
 WHERE id=1001 AND status='PENDING';
-- 调用方检查 affected rows,并区分首次成功、已处理与非法状态。
COMMIT;
-- 联合索引示例:tenant_id 等值、created_at 范围,id 用于稳定排序。
CREATE INDEX idx_order_tenant_time ON orders(tenant_id, created_at, id);

数据库实现差异与迁移

数据库 旧版本与快照怎样处理? 实践中需要留意什么?
MySQL InnoDB Undo 链配合 Read View,默认 RR next-key、聚簇主键、长事务阻碍 purge
PostgreSQL 16 表里保留 tuple 版本,VACUUM 清理,默认 RC 长快照影响清理;RR 是快照隔离,Serializable 用 SSI 检测危险冲突
Oracle 19c 使用 SCN 和 Undo 构造一致读,默认 RC 没有可直接照搬的同名 RR;可能遇到 ORA-08177 或 Undo 不足的 ORA-01555
OceanBase 4.x 分布式多版本存储与事务协调 MySQL/Oracle 租户模式不是完全等价实现,应核对小版本、功能与分布式计划

迁移先检查数据含义:时区、decimal、字符排序、NULL 和空字符串。在 Oracle 中空字符串按 NULL 处理,就可能改变唯一性或筛选结果。语法能执行只是第一步,事务结果和访问成本也要测。

全量复制应对应明确快照位点,CDC 从正确位置接上增量。按主键范围比较行数和规范化后的字段内容,重要业务再对金额与状态账;只比行数,漏一行同时多一行也可能“通过”。切换前等增量追平,控制写路由,预演回退。双写会产生自己的失败和乱序问题,需要单独对账。

Read View 手工推演与写偏差

假设快照里的活跃事务是 {100,104},最小活跃 ID 是 100,新事务界限是 108,创建者为 106。版本 99 可见;100、104 因仍活跃不可见;105 不在活跃集合里且未超界,可以看见;108 及以后不可见;自己的 106 可见。若当前版本是 104,就沿 Undo 找到可见的旧版。

快照也不是所有业务规则的保险。两名值班人员各自看到对方在线,于是分别把自己改为离线,写的不是同一行,最后却无人值班。要保护“至少一人在线”,可以锁住共同的约束记录,或使用数据库支持的更严格方式。不同产品的 Serializable 和冲突处理不同,应测试后再选;重试时也必须重新读取、重新判断。

源码级解析与调用链

InnoDB 普通读会经过 row_search_mvcc 及相关可见性检查。ReadView::changes_visible 判定版本能否返回;不行则由 row_vers_build_for_consistent_read 重建旧版本后再判断。

下面这段判断与前面的 {100,104} 例子对应,注意 m_up_limit_id 和 m_low_limit_id 的历史命名,按代码比较方向理解,不凭英文名称猜。

ReadView::changes_visibleMySQL 8.0.36 · L44–L319 · 完整源码
/** Read view lists the trx ids of those transactions for which a consistent
read should not see the modifications to the database. */

class ReadView {
  /** This is similar to a std::vector but it is not a drop
  in replacement. It is specific to ReadView. */
  class ids_t {
    typedef trx_ids_t::value_type value_type;

    /**
    Constructor */
    ids_t() : m_ptr(), m_size(), m_reserved() {}

    /**
    Destructor */
    ~ids_t() { ut::delete_arr(m_ptr); }

    /** Try and increase the size of the array. Old elements are copied across.
    It is a no-op if n is < current size.
    @param n            Make space for n elements */
    void reserve(ulint n);

    /**
    Resize the array, sets the current element count.
    @param n            new size of the array, in elements */
    void resize(ulint n) {
      ut_ad(n <= capacity());

      m_size = n;
    }

    /**
    Reset the size to 0 */
    void clear() { resize(0); }

    /**
    @return the capacity of the array in elements */
    ulint capacity() const { return (m_reserved); }

    /**
    Copy and overwrite the current array contents

    @param start                Source array
    @param end          Pointer to end of array */
    void assign(const value_type *start, const value_type *end);

    /**
    Insert the value in the correct slot, preserving the order.
    Doesn't check for duplicates. */
    void insert(value_type value);

    /**
    @return the value of the first element in the array */
    value_type front() const {
      ut_ad(!empty());

      return (m_ptr[0]);
    }

    /**
    @return the value of the last element in the array */
    value_type back() const {
      ut_ad(!empty());

      return (m_ptr[m_size - 1]);
    }

    /**
    Append a value to the array.
    @param value                the value to append */
    void push_back(value_type value);

    /**
    @return a pointer to the start of the array */
    trx_id_t *data() { return (m_ptr); }

    /**
    @return a const pointer to the start of the array */
    const trx_id_t *data() const { return (m_ptr); }

    /**
    @return the number of elements in the array */
    ulint size() const { return (m_size); }

    /**
    @return true if size() == 0 */
    bool empty() const { return (size() == 0); }

   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. */
  [[nodiscard]] bool changes_visible(trx_id_t id,
                                     const table_name_t &name) const {
    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));
  }

  /**
  @param id             transaction to check
  @return true if view sees transaction id */
  bool sees(trx_id_t id) const { return (id < m_up_limit_id); }

  /**
  Mark the view as closed */
  void close() {
    ut_ad(m_creator_trx_id != TRX_ID_MAX);
    m_creator_trx_id = TRX_ID_MAX;
  }

  /**
  @return true if the view is closed */
  bool is_closed() const { return (m_closed); }

  /**
  Write the limits to the file.
  @param file           file to write to */
  void print_limits(FILE *file) const {
    fprintf(file,
            "Trx read view will not see trx with"
            " id >= " TRX_ID_FMT ", sees < " TRX_ID_FMT "\n",
            m_low_limit_id, m_up_limit_id);
  }

  /** Check and reduce low limit number for read view. Used to
  block purge till GTID is persisted on disk table.
  @param[in]    trx_no  transaction number to check with */
  void reduce_low_limit(trx_id_t trx_no) {
    if (trx_no < m_low_limit_no) {
      /* Save low limit number set for Read View for MVCC. */
      ut_d(m_view_low_limit_no = m_low_limit_no);
      m_low_limit_no = trx_no;
    }
  }

  /**
  @return the low limit no */
  trx_id_t low_limit_no() const { return (m_low_limit_no); }

  /**
  @return the low limit id */
  trx_id_t low_limit_id() const { return (m_low_limit_id); }

  /**
  @return true if there are no transaction ids in the snapshot */
  bool empty() const { return (m_ids.empty()); }

#ifdef UNIV_DEBUG
  /**
  @return the view low limit number */
  trx_id_t view_low_limit_no() const { return (m_view_low_limit_no); }

  /**
  @param rhs            view to compare with
  @return truen if this view is less than or equal rhs */
  bool le(const ReadView *rhs) const {
    return (m_low_limit_no <= rhs->m_low_limit_no);
  }
#endif /* UNIV_DEBUG */
 private:
  /**
  Copy the transaction ids from the source vector */
  inline void copy_trx_ids(const trx_ids_t &trx_ids);

  /**
  Opens a read view where exactly the transactions serialized before this
  point in time are seen in the view.
  @param id             Creator transaction id */
  inline void prepare(trx_id_t id);

  /**
  Copy state from another view. Must call copy_complete() to finish.
  @param other          view to copy from */
  inline void copy_prepare(const ReadView &other);

  /**
  Complete the copy, insert the creator transaction id into the
  m_trx_ids too and adjust the m_up_limit_id *, if required */
  inline void copy_complete();

  /**
  Set the creator transaction id, existing id must be 0 */
  void creator_trx_id(trx_id_t id) {
    ut_ad(m_creator_trx_id == 0);
    m_creator_trx_id = id;
  }

  friend class MVCC;

 private:
  // Disable copying
  ReadView(const ReadView &);
  ReadView &operator=(const ReadView &);

 private:
  /** 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;

  /** 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;

#ifdef UNIV_DEBUG
  /** The low limit number up to which read views don't need to access
  undo log records for MVCC. This could be higher than m_low_limit_no
  if purge is blocked for GTID persistence. Currently used for debug
  variable INNODB_PURGE_VIEW_TRX_ID_AGE. */
  trx_id_t m_view_low_limit_no;
#endif /* UNIV_DEBUG */

  /** 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;
};

先允许较早版本和自己的版本,再拒绝超出新事务界限的版本,最后在活跃事务集合里查找。binary_search 找不到,才表示这个中间区间的事务版本可见。

roll_ptrroll_ptr不可见可见聚簇行 / trx=105Undo 旧版 / trx=99Undo 更旧版 / trx=82Read View / 活跃事务与界限changes_visible再次检查返回快照版本
图 1 · 对照上文阅读流程
查看 Mermaid 图表源码
flowchart LR
 A["聚簇行 / trx=105"] -->|roll_ptr| B["Undo 旧版 / trx=99"]
 B -->|roll_ptr| C["Undo 更旧版 / trx=82"]
 V["Read View / 活跃事务与界限"] --> D{"changes_visible"}
 A --> D
 D -->|不可见| B
 B --> E{"再次检查"}
 E -->|可见| F["返回快照版本"]

三层原理问答

1. RR 快照什么时候建立?

InnoDB RR 一般在第一次一致性读时建立快照,后续复用;BEGIN 本身不一定已经创建。RC 则通常每次一致性读更新快照。

为什么能读旧值? ReadView::changes_visible 判断当前版本所属事务是否可见,不可见时沿 Undo 重建旧版本。显式 consistent snapshot 和锁定读走不同流程。

长事务会怎样? 旧快照可能还要用历史数据,purge 不敢清理它们。报表要控制事务时长,跨批次读也要先想清楚是否接受不同时点的数据。

2. MVCC 是否让读写完全不阻塞?

普通快照读通常不需要等写锁,但写写冲突、SELECT FOR UPDATE 和元数据锁仍可能等待。MVCC 没有把数据库里所有等待都取消。

等在哪层? 快照读重建 Undo,当前读协调索引锁,DDL 还可能等待 MDL。看到查询慢,需要分清行锁、MDL、IO 与内部竞争,才能解释等待。

怎样处理? 找持有者及事务年龄,缩短长事务、安排变更窗口。只扩大连接池会让更多请求排队,不会解除一个仍未结束的持锁事务。

3. MySQL RR 能防所有幻读吗?

要先说明哪种读。快照 SELECT 重复读同一快照;锁定范围读可能使用 next-key 防止相关范围插入。把这两种机制混成一句“RR 没幻读”,无法解释实际操作。

普通读后 UPDATE 会怎样? UPDATE 是当前读,可能处理后来出现、符合条件的行。唯一等值命中、未命中和范围查询的锁也不同,需沿实际索引区间分析。

唯一业务怎样保证? 用唯一索引约束订单号或事件 ID,不靠查不到再插入。库存扣减用条件更新;跨数据库的结果还需要明确消息和补偿处理。

4. Redo、Undo、binlog 都是日志,为何不能互换?

它们面向不同工作。Redo 帮崩溃恢复重做修改,Undo 帮回滚和重建旧版本,binlog 主要用于复制及逻辑恢复,不能互相替代。

提交为什么要协调? 相关 WAL 先于数据页落盘,InnoDB 与 binlog 还要协调提交结果,避免崩溃后两层日志对成功事务产生分歧。刷盘配置决定具体的持久承诺。

高可用够不够? 还要看副本确认、备份、恢复速度和演练。异步副本可能落后,也会复制错误删除,所以它既不是零丢失保证,也不是完整备份。

5. 慢 SQL 有索引为什么仍慢?

索引存在,不代表筛掉了足够多的数据。低选择性索引、大量回表、排序、返回过多行或锁等待,都可能使查询慢。

怎样看到真实工作量? 比较计划中的估计与实际 rows、loops,检查类型转换、统计和排序。范围后的字段仍可能参与 ICP,但未必缩小扫描区间。EXPLAIN ANALYZE 会真实执行。

索引以外怎么改? 控制返回列和批量,深分页考虑稳定游标。新索引用接近生产分布的数据验证,还要看写入与缓存成本,不能只测一个很小的查询样本。

6. 数据库迁移怎样证明一致?

先有一致快照与增量位点,再按范围比较内容,切换后做业务对账。行数一样不代表每行一样,简单 sum(id) 也不能证明没有错漏。

哪些内容需要规范化? 时区、decimal、字符编码、排序规则和 NULL。CDC 还要处理更新、删除、断点与顺序,跨产品同名隔离级别不保证行为一致。

切换怎么恢复? 先灰度验证,增量追平后控制写路由,并预演回退。金融类数据按金额和状态核对,发现偏差有明确修复流程,而非只宣布 checksum 通过。

模拟生产案例:长快照拖住 Undo

更新业务看起来正常,Undo 和 history list 却持续增长,磁盘与查询压力越来越大。这是一个长 RR 报表事务影响清理的模拟情境。

先看 innodb_trx 中事务年龄、report 连接和 history list,再检查 purge 是否追得上。如果有一个报表很早建立快照却一直不提交,它可能仍需要历史版本,不能只怪更新量大。

受控结束该报表事务后,应观察 purge 是否逐渐追赶,这是检验假设的重要对照。改成明确的批次或分析读取方式,但先决定业务是否必须看到同一时点,避免拆批后结果含义变化。

长期设事务年龄告警和报表超时,检查连接复用时是否完成事务。备份、迁移的大快照同样需要评估保留历史版本的代价。

知识梳理与核心总结

一分钟要点回顾

InnoDB 普通一致性读用 Read View 判断当前版本,不可见就沿 Undo 读旧版。RC 通常每条读新快照,RR 通常首次一致读后复用;FOR UPDATE 和写操作则读当前状态。索引决定扫描和锁范围,不能只看返回行数。Redo、Undo、binlog 分别负责不同恢复工作。慢查询看实际扫描、回表和等待;迁移还要验证数据含义、CDC 位点和业务结果,不能只比行数。

深入理解与机制串联

我会从一个未提交余额修改讲 MVCC。别人把 100 改成 80 尚未提交,普通读可以借 Undo 看到旧 100。Read View 根据版本事务、活跃集合和界限判断可见性,必要时沿历史链回退。

RC 每次一致性读通常换快照,RR 则通常首次读后复用,不一定 BEGIN 时已经创建。SELECT FOR UPDATE、UPDATE 要看可锁定的当前状态,不能把它们和普通 SELECT 当同一快照。长事务还会保留旧版本,阻碍 purge。

索引方面,主键叶子存整行,二级索引带主键,非覆盖时要回表。联合索引先用等值前缀,再用范围,后续列可能参与过滤或 ICP。范围锁沿实际访问路径建立,缺好索引会扫描和锁更多记录;唯一完整等值命中与未命中也要分别看。

慢 SQL 先看计划、实际 rows 和 loops,再查锁等待、IO 和返回量。EXPLAIN ANALYZE 会执行,需要控制影响。死锁按整个事务有界重试,重新读和判断,不能只补最后一条。

跨库我不会把 InnoDB Undo 当所有 MVCC 的实现。PG 用 tuple 与 VACUUM,Oracle 用 SCN/Undo,OceanBase 还有分布式协调。迁移先核对时区、NULL、精度和约束,再保证全量与 CDC 位点衔接,按范围比内容和业务金额状态。切换与回退预演过,才说明方案可以恢复。

延伸问题、常见误解与速记

  • 高频追问:RR 快照何时创建?长事务为何拖 purge?唯一未命中锁什么?范围后哪些列还有用?
  • 容易答错:所有数据库默认 RR;快照读和当前读相同;PG 用 InnoDB Undo;有索引一定快;副本就是备份。
  • 常看的源码:ReadView::changes_visible、row_search_mvcc、row_vers_build_for_consistent_read、trx_assign_read_view。
  • 推导时把读到哪个版本、扫描哪个区间、锁住哪些位置分开。

官方资料与版本来源

本文按上述版本阅读官方源码,函数窗口保留完整分支与边界,声明窗口聚焦所讨论的字段或配置。版权见 source-notices.txt,下载记录见 sources.json。

先记住这几件事

  • Read View 判断版本可见性,不可见沿 Undo 找旧版本。
  • RC 通常逐语句快照,RR 通常第一次一致性读后复用。
  • 普通快照读与 UPDATE/FOR UPDATE 的当前读不同。
  • 实际索引扫描决定成本和锁范围,不只是最终返回行数。
  • 跨库要核对数据与隔离含义,迁移保证快照、CDC 与对账。

四个重点问题

1. RR 快照什么时候建立?

InnoDB RR 一般在第一次一致性读时建立快照,后续复用;BEGIN 本身不一定已经创建。RC 则通常每次一致性读更新快照。

为什么能读旧值? ReadView::changes_visible 判断当前版本所属事务是否可见,不可见时沿 Undo 重建旧版本。显式 consistent snapshot 和锁定读走不同流程。

长事务会怎样? 旧快照可能还要用历史数据,purge 不敢清理它们。报表要控制事务时长,跨批次读也要先想清楚是否接受不同时点的数据。

2. MVCC 是否让读写完全不阻塞?

普通快照读通常不需要等写锁,但写写冲突、SELECT FOR UPDATE 和元数据锁仍可能等待。MVCC 没有把数据库里所有等待都取消。

等在哪层? 快照读重建 Undo,当前读协调索引锁,DDL 还可能等待 MDL。看到查询慢,需要分清行锁、MDL、IO 与内部竞争,才能解释等待。

怎样处理? 找持有者及事务年龄,缩短长事务、安排变更窗口。只扩大连接池会让更多请求排队,不会解除一个仍未结束的持锁事务。

3. MySQL RR 能防所有幻读吗?

要先说明哪种读。快照 SELECT 重复读同一快照;锁定范围读可能使用 next-key 防止相关范围插入。把这两种机制混成一句“RR 没幻读”,无法解释实际操作。

普通读后 UPDATE 会怎样? UPDATE 是当前读,可能处理后来出现、符合条件的行。唯一等值命中、未命中和范围查询的锁也不同,需沿实际索引区间分析。

唯一业务怎样保证? 用唯一索引约束订单号或事件 ID,不靠查不到再插入。库存扣减用条件更新;跨数据库的结果还需要明确消息和补偿处理。

4. Redo、Undo、binlog 都是日志,为何不能互换?

它们面向不同工作。Redo 帮崩溃恢复重做修改,Undo 帮回滚和重建旧版本,binlog 主要用于复制及逻辑恢复,不能互相替代。

提交为什么要协调? 相关 WAL 先于数据页落盘,InnoDB 与 binlog 还要协调提交结果,避免崩溃后两层日志对成功事务产生分歧。刷盘配置决定具体的持久承诺。

高可用够不够? 还要看副本确认、备份、恢复速度和演练。异步副本可能落后,也会复制错误删除,所以它既不是零丢失保证,也不是完整备份。

核心要点

InnoDB 普通一致性读用 Read View 判断当前版本,不可见就沿 Undo 读旧版。RC 通常每条读新快照,RR 通常首次一致读后复用;FOR UPDATE 和写操作则读当前状态。索引决定扫描和锁范围,不能只看返回行数。Redo、Undo、binlog 分别负责不同恢复工作。慢查询看实际扫描、回表和等待;迁移还要验证数据含义、CDC 位点和业务结果,不能只比行数。

常见误解与源码

  • 高频追问:RR 快照何时创建?长事务为何拖 purge?唯一未命中锁什么?范围后哪些列还有用?
  • 容易答错:所有数据库默认 RR;快照读和当前读相同;PG 用 InnoDB Undo;有索引一定快;副本就是备份。
  • 常看的源码:ReadView::changes_visible、row_search_mvcc、row_vers_build_for_consistent_read、trx_assign_read_view。
  • 推导时把读到哪个版本、扫描哪个区间、锁住哪些位置分开。

全文搜索

输入关键词,搜索本篇完整正文与答案。