暂无图片
暂无图片
暂无图片
暂无图片
暂无图片

移动云大云海山数据库(He3DB)多版本并发控制(MVCC)详解

原创 移动云He3DB 2026-03-24
129

一、前言

在数据库技术的演进历程中,并发控制始终是核心命题 —— 如何在多用户同时读写数据的场景下,既保证事务的隔离性与数据一致性,又不牺牲系统的并发性能,是所有成熟数据库需要解决的关键问题。传统的锁机制虽能实现隔离,但 “读阻塞写、写阻塞读” 的特性,使其在高并发场景下极易成为性能瓶颈;而基于时间戳的乐观并发控制,又难以平衡一致性与可用性的需求。

多版本并发控制(Multi-Version Concurrency Control,MVCC)正是为破解这一困境而生的核心技术。它摒弃了 “一锁到底” 的传统思路,通过为数据维护多个历史版本、制定清晰的版本可见性规则,让读操作无需加锁即可访问符合事务隔离级别的数据版本,写操作仅修改最新版本且不阻塞读操作,最终实现 “读写解耦、并发无损” 的目标。作为支撑数据库 ACID 特性中 “隔离性(Isolation)” 的核心底层机制,MVCC 已成为 PostgreSQL、MySQL(InnoDB)、Oracle 等主流数据库的标配,也是区分数据库并发能力的关键技术标志。

二、多版本并发控制(MVCC)基本原理

2.1、MVCC介绍

MVCC(Multi-Version Concurrency Control)即多版本并发控制,是数据库实现高并发且保证隔离性的核心技术。

(1)核心思想:不为数据加锁(或减少锁),而是为每一行数据保存多个版本,不同事务看到不同版本的数据;

(2)解决的问题:

  • 读写不阻塞(读事务不用等写事务释放锁,写事务也不用等读事务),提升并发性能。
  • 同一个事务下的读写通过快照读,消除了因为并发事务的修改的数据不一致,保证了数据隔离性。

2.2、MVCC实现方式

数据库实现MVCC都是修改数据的同事,原数据保留一段时间,这样其他的事务还能查询到该数据。

版本生成:数据被修改时,如何创建新版本并标记版本属性(如事务 ID、时间戳);

版本存储:历史版本保存在哪里(独立日志 / 表内物理行 / 专用存储区);

版本可见性判断:事务如何筛选出 “自己能看的版本”,保证隔离性;

版本清理:旧版本何时、以何种方式被删除,避免存储膨胀。

image

2.3、MVCC常见机制

围绕 “版本生成 - 版本存储 - 版本可见性” 三大核心环节,不同数据库对MVCC有不同的实现机制,主要下面两种:

(1)基于回滚日志的链式版本

版本生成:修改数据时,只更新当前行的最新版本,为其标记「事务 ID/SCN」,并通过 “回滚指针” 指向旧版本;
版本存储:旧版本数据保存在独立的「Undo Log(回滚日志)/ 回滚段」中,以 “链表” 形式链式存储;
可见性判断:事务启动时生成「Read View/SCN 快照」,通过事务 ID/SCN 筛选 Undo Log 中的可见版本。

(2)基于物理行的独立版本

版本生成:修改 / 删除数据时,不更新原行,而是生成一行全新的物理行,为新行标记「xmin(创建事务 ID)」,为原行标记「xmax(删除 / 修改事务 ID)」;
版本存储:所有版本都直接存储在表中(无独立 Undo Log),旧版本和新版本是表中不同的物理行;
可见性判断:事务启动时生成「事务快照」,通过 xmin/xmax 和活跃事务列表判断行版本是否可见。

MVCC的实现机制在业界并没有盖棺定论的最优解,各种方案都有各自的优势和劣势,具体可参考如下说明:

对比维度

回滚日志

物理行的独立版本

优点

存储开销低
写入性能高
清理自动化

可见性判断简单
隔离性更强
无日志依赖

缺点

快照读有损耗
隔离性有限
日志管理复杂

存储开销高
运维成本高
写入性能略低

三、多版本并发控制实践

3.1、环境准备

先创建测试表和数据,并查看默认隔离级别

-- 1. 创建测试表(含主键,模拟业务表)
CREATE TABLE mvcc_demo (
id SERIAL PRIMARY KEY,
name VARCHAR(50),
balance NUMERIC(10,2)
);

-- 2. 插入测试数据
INSERT INTO mvcc_demo (name, balance) VALUES
('Alice', 1000.00),
('Bob', 2000.00);

image

-- 3. 查看隔离级别,修改为RR(可重复读)
postgres=# SHOW default_transaction_isolation;
default_transaction_isolation
-------------------------------
repeatable read
(1 row)

image

3.2、读写解耦和版本隔离

通过事务对比,验证MVCC的读写解耦和版本隔离。

会话 1(写事务)

会话 2(读事务)

会话 3(读事务)

BEGIN;(启动事务)

BEGIN;(启动事务)

-

UPDATE mvcc_demo SET balance = 1500.00 WHERE id = 1;(修改未提交)

SELECT xmin, xmax, * FROM mvcc_demo WHERE id = 1;(读取旧版本)

-

-

输出:xmin | xmax | id | name | balance
--------+--------+----+-------+---------
130967 | 130968 | 1 | Alice | 1000.00

-

COMMIT;(提交写事务)

SELECT xmin, xmax, * FROM mvcc_demo WHERE id = 1;(获取旧版本)

BEGIN;(新事务)
SELECT xmin, xmax, * FROM mvcc_demo WHERE id = 1;(读新版本)

-

输出:xmin | xmax | id | name | balance
--------+--------+----+-------+---------
130967 | 130968 | 1 | Alice | 1000.00

输出:xmin | xmax | id | name | balance
--------+------+----+-------+---------
130968 | 0 | 1 | Alice | 1500.00

-

COMMIT;(提交读事务)

COMMIT;(提交读事务)

3.3、版本信息和可见性

查看版本信息,比较以下三个值:

ctid:页号/行号,不同版本的同一逻辑行ctid不同;

xmin:创建的事务ID;

xmax:结束事务ID,未被修改则为0。

SELECT ctid, xmin, xmax, * FROM mvcc_demo;

事务A,值为1000,物理标识符 0/4,创建事务ID 130967,结束事务ID 130968。

image

事务B,值为1500,物理标识符 0/5,创建事务ID 130968,结束事务ID 0。

image

查看活跃版本以及无效版本,此时可以看到 dead_rows 是 MVCC 产生的 “死版本”,需要通过 VACUUM 进行清理,否则会造成表的膨胀,同时查询的速度降低。

-- 查看表的 MVCC 关键统计(重点看 dead_tuple_count)
SELECT
relname AS table_name,
n_live_tup AS live_rows, -- 活跃行(可见版本)
n_dead_tup AS dead_rows, -- 死行(无事务引用的旧版本)
last_vacuum, -- 最后一次手动 VACUUM 时间
last_autovacuum -- 最后一次自动 VACUUM 时间
FROM pg_stat_user_tables
WHERE relname = 'mvcc_demo';

image

3.4、查看膨胀率和清理旧版本

数据库的旧版本不会直接回收,需要通过VACUUM 回收空间。日常可以手动进行回收,同时可以配置自动回收策略。

-- 基础清理:清理死版本,不回收磁盘(仅标记为可用)
VACUUM mvcc_demo;

-- 清理+分析:清理死版本 + 更新统计信息(推荐日常使用)
VACUUM ANALYZE mvcc_demo;

-- 全量清理:清理死版本 + 回收磁盘空间(需表级排他锁,业务低峰执行)
VACUUM FULL mvcc_demo;

清理后 dead_rows 显示为0。

image

AUTO VACUUM配置,数据库默认配置如下:

-- autovacuum 配置
SHOW autovacuum; -- 输出:on(必须开启)
SHOW autovacuum_vacuum_threshold; -- 触发阈值:默认50行
SHOW autovacuum_vacuum_scale_factor; -- 触发比例:默认0.2(20%)

image

查看数据库表的膨胀率

select relname AS table_name,
pg_size_pretty(pg_table_size(relid)) AS actual_size, -- 表总大小(含死版本+索引)
pg_size_pretty(pg_relation_size(relid)) AS data_size, -- 表数据区大小(不含索引)
ROUND((1 + n_dead_tup::FLOAT / NULLIF(n_live_tup, 0))::NUMERIC, 2) AS row_bloat_ratio, -- 膨胀率(按行数估算)
ROUND((pg_table_size(relid)::FLOAT / NULLIF(pg_relation_size(relid), 0))::NUMERIC, 2) AS size_bloat_ratio -- 膨胀率(按大小估算)
FROM pg_stat_user_tables ;

image

此时次看大小的膨胀率有点高,执行 VACUUM FULL 回收磁盘后再次查看:

image

另外执行 VACUUM 还有一些注意事项,需留意:

  • VACUUM 不会阻塞读操作,但会轻微影响写性能;
  • VACUUM FULL 会加排他锁,禁止在业务高峰执行(可先用 VACUUM 临时清理,低峰用 VACUUM FULL 回收磁盘);
  • 长事务会阻塞 VACUUM ,导致无法清理该事务启动后产生的死版本,可适当限制长事务时长(如通过 idle_in_transaction_session_timeout 配置)。

「喜欢这篇文章,您的关注和赞赏是给作者最好的鼓励」
关注作者
【版权声明】本文为墨天轮用户原创内容,转载时必须标注文章的来源(墨天轮),文章链接,文章作者等基本信息,否则作者和墨天轮有权追究责任。如果您发现墨天轮中有涉嫌抄袭或者侵权的内容,欢迎发送邮件至:contact@modb.pro进行举报,并提供相关证据,一经查实,墨天轮将立刻删除相关内容。

评论