22、数据库并发控制:事务隔离级别、乐观锁与悲观锁、MVCC原理、连接池的线程安全实现
数据库并发控制,说白了就是解决「多个线程同时读写同一行数据,怎么保证不出错」的问题。我早年做交易系统时,就吃过并发控制的亏——两个线程同时扣库存,结果库存变成负数了。嗯,从那以后,我对这块就特别上心。
22.1 事务隔离级别:你到底能看到什么?
事务隔离级别,决定了并发事务之间「互相能看到多少」。标准 SQL 定义了四个级别,从松到严:
| 隔离级别 | 脏读 | 不可重复读 | 幻读 |
|---|---|---|---|
| READ UNCOMMITTED | 可能 | 可能 | 可能 |
| READ COMMITTED | 不可能 | 可能 | 可能 |
| REPEATABLE READ | 不可能 | 不可能 | 可能 |
| SERIALIZABLE | 不可能 | 不可能 | 不可能 |
我个人习惯,生产环境至少用 READ COMMITTED。为什么?因为 READ UNCOMMITTED 会读到别人还没提交的数据——万一对方回滚了,你读到的就是脏数据。我在项目中遇到过,一个报表查询用了 READ UNCOMMITTED,结果数据对不上,排查了半天才发现是脏读的问题。
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 的好处很明显:读操作几乎不需要加锁,性能极高。但要注意,如果事务长时间不提交,旧版本就无法清理,会导致 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 通过多版本实现读写不互斥,但要注意长事务问题
- 连接池必须用互斥锁保护,配合条件变量实现等待/通知
数据库并发控制,说到底就是「在正确性和性能之间找平衡」。没有绝对正确的方案,只有适合你场景的方案。我做了这么多年,最大的体会就是:先保证正确,再优化性能。数据错了,再快也没用。