22、数据库并发控制:事务隔离级别、乐观锁与悲观锁、MVCC原理、连接池的线程安全实现

数据库并发控制,说白了就是解决「多个线程同时读写同一行数据,怎么保证不出错」的问题。我早年做交易系统时,就吃过并发控制的亏——两个线程同时扣库存,结果库存变成负数了。嗯,从那以后,我对这块就特别上心。

22.1 事务隔离级别:你到底能看到什么?

事务隔离级别,决定了并发事务之间「互相能看到多少」。标准 SQL 定义了四个级别,从松到严:

隔离级别 脏读 不可重复读 幻读
READ UNCOMMITTED 可能 可能 可能
READ COMMITTED 不可能 可能 可能
REPEATABLE READ 不可能 不可能 可能
SERIALIZABLE 不可能 不可能 不可能

我个人习惯,生产环境至少用 READ COMMITTED。为什么?因为 READ UNCOMMITTED 会读到别人还没提交的数据——万一对方回滚了,你读到的就是脏数据。我在项目中遇到过,一个报表查询用了 READ UNCOMMITTED,结果数据对不上,排查了半天才发现是脏读的问题。

注意: SERIALIZABLE 虽然最安全,但性能代价极大。除非是金融交易等强一致性场景,否则不建议使用。

22.2 乐观锁与悲观锁:两种思路

这两种锁,代表了两种截然不同的并发控制哲学。

悲观锁:我假设一定会有人跟我抢,所以先锁住再说。在数据库里,就是 SELECT ... FOR UPDATE。你想想看,如果并发冲突概率很高,悲观锁反而效率高——因为避免了反复重试。

乐观锁:我假设没人跟我抢,先更新再说,如果发现冲突了再重试。实现方式通常是加一个版本号字段:

-- 先查询版本号
SELECT id, version, balance FROM accounts WHERE id = 1;

-- 更新时检查版本号
UPDATE accounts 
SET balance = balance - 100, version = version + 1 
WHERE id = 1 AND version = 5;

-- 如果影响行数为0,说明版本号变了,需要重试

我曾经在一个高并发秒杀系统里用过乐观锁。一开始觉得挺美,结果压测时发现重试次数太多,反而把数据库打爆了。后来改成悲观锁 + 排队,反而更稳。所以你看,没有银弹,得看场景。

我的建议: 读多写少用乐观锁,写多读少用悲观锁。如果拿不准,先上悲观锁,至少不会出数据问题。

22.3 MVCC 原理:多版本并发控制

MVCC 是 MySQL InnoDB 等引擎的核心机制。它让「读不阻塞写,写不阻塞读」成为可能。

原理其实不复杂:每一行数据都有多个版本,每个版本都记录了创建时间和过期时间(用事务ID表示)。读操作时,只读取「当前事务开始前已经提交」的版本。写操作时,创建新版本,旧版本保留给还在进行中的读事务。

我画个图帮你理解:

MVCC 多版本并发控制原理 版本1 (已提交) 事务ID: 10 版本2 (已提交) 事务ID: 15 版本3 (未提交) 事务ID: 20 读事务 (ID: 12) 只能看到版本1和版本2 读事务 (ID: 22) 能看到所有已提交版本 写事务 (ID: 20) 正在创建版本3 关键点: • 每个读事务看到的是「快照」,不受后续写操作影响 • 写事务创建新版本时,旧版本仍然保留给正在进行的读事务 • 当事务提交后,它的版本才对后续事务可见

MVCC 的好处很明显:读操作几乎不需要加锁,性能极高。但要注意,如果事务长时间不提交,旧版本就无法清理,会导致 undo log 膨胀。我曾经在生产环境遇到过,一个长事务跑了半小时,结果磁盘空间被 undo log 撑爆了。

22.4 连接池的线程安全实现

连接池,说白了就是「提前创建一批数据库连接,谁用谁取,用完归还」。但多线程环境下,连接池本身必须是线程安全的。

我实现过一个简单的连接池,核心思路是这样的:

class ConnectionPool {
private:
    std::queue<Connection*> idle_conns_;   // 空闲连接
    std::mutex mtx_;
    std::condition_variable cv_;
    int max_size_;

public:
    Connection* GetConnection() {
        std::unique_lock<std::mutex> lock(mtx_);
        // 有空闲连接就直接取
        if (!idle_conns_.empty()) {
            auto conn = idle_conns_.front();
            idle_conns_.pop();
            return conn;
        }
        // 没空闲但还没到上限,创建新连接
        if (total_conns_ < max_size_) {
            total_conns_++;
            return new Connection();
        }
        // 满了就等待
        cv_.wait(lock, [this] { return !idle_conns_.empty(); });
        auto conn = idle_conns_.front();
        idle_conns_.pop();
        return conn;
    }

    void ReturnConnection(Connection* conn) {
        std::lock_guard<std::mutex> lock(mtx_);
        idle_conns_.push(conn);
        cv_.notify_one();
    }
};

这里有几个坑,我踩过:

  • 连接泄漏:如果获取连接后程序崩溃了,连接就永远回不来了。我建议用 RAII 包装,确保析构时自动归还。
  • 心跳检测:空闲连接可能被数据库端断开。我习惯在获取连接时做一次轻量查询(比如 SELECT 1),如果失败就重建。
  • 最大等待时间:不要无限等待。设置一个超时,超时后抛出异常,避免线程全部卡死。

核心要点:

  • 事务隔离级别:READ COMMITTED 是大多数场景的平衡点
  • 乐观锁适合读多写少,悲观锁适合写多读少
  • MVCC 通过多版本实现读写不互斥,但要注意长事务问题
  • 连接池必须用互斥锁保护,配合条件变量实现等待/通知

数据库并发控制,说到底就是「在正确性和性能之间找平衡」。没有绝对正确的方案,只有适合你场景的方案。我做了这么多年,最大的体会就是:先保证正确,再优化性能。数据错了,再快也没用。

避坑指南: 我曾经在一个项目里,为了追求性能,把隔离级别降到了 READ UNCOMMITTED,结果数据对不上,花了三天排查。从那以后,我再也不敢在生产环境用低于 READ COMMITTED 的级别了。
公众号:蓝海资料掘金营,微信 deep3321