共享冲突的本质是多个执行单元在没有协调的情况下访问同一份可变数据。数据库、缓存、文件系统甚至内存对象都可能出现这类问题。例如两个事务同时读取库存数量,都认为剩余10件,然后各自扣减1件,最终可能写入9件而不是8件。这种情况被称为丢失更新,是并发控制中最典型的共享冲突。要解决这些问题,不能只依赖单一SQL语句,而需要从锁机制和事务处理两个层面共同设计。

一、共享冲突的来源与锁的基本类型
共享冲突通常发生在读-写和写-写两种场景中。读-写冲突会让一个事务读取到另一个事务尚未提交的数据,也就是脏读;写-写冲突则可能直接覆盖对方的修改。锁机制的目标是通过限制并发访问顺序来避免这些问题。数据库中的锁可以按粒度分为表级锁和行级锁。表级锁实现简单、开销小,但会严重降低并发能力;行级锁只锁定相关记录,适合高并发写入场景,但需要更多内存和锁管理成本。
按照加锁方式,又可以分为共享锁和排他锁。共享锁允许多个事务同时读取同一资源,但不允许写入;排他锁则排斥其他事务的读锁和写锁。在数据库中,普通SELECT通常不加锁,而SELECT ... FOR UPDATE会加排他锁,SELECT ... LOCK IN SHARE MODE会加共享锁。下面是一个通过排他锁防止库存超卖的例子:
START TRANSACTION; SELECT stock FROM product WHERE id = 100 FOR UPDATE; UPDATE product SET stock = stock - 1 WHERE id = 100; COMMIT;
这段代码在读取库存时立即加排他锁,其他事务必须等待当前事务提交后才能继续读取或修改。这种方式简单直接,但缺点是锁等待可能导致连接堆积,尤其在高并发秒杀场景下会明显拖慢整体吞吐。
二、事务隔离级别与锁的联动
事务隔离级别定义了事务之间可见性和锁行为的规则。SQL标准定义了四种隔离级别:READ UNCOMMITTED、READ COMMITTED、REPEATABLE READ和SERIALIZABLE。隔离级别越低,并发度越高,但数据一致性风险越大。READ UNCOMMITTED允许脏读,几乎不适合生产环境;READ COMMITTED防止脏读,但仍可能出现不可重复读;REPEATABLE READ进一步防止不可重复读,是MySQL InnoDB的默认级别;SERIALIZABLE通过强制串行执行来避免幻读,但性能最差。
不同数据库对隔离级别的实现差异很大。MySQL InnoDB在REPEATABLE READ级别下使用一致性非锁定读和间隙锁来解决幻读问题。一致性非锁定读依赖MVCC机制,普通SELECT读取的是快照数据,不需要加锁;但如果执行SELECT ... FOR UPDATE,则会对扫描到的索引记录以及索引间隙加锁,阻止其他事务插入符合条件的记录。以下示例展示了如何设置隔离级别并显式加锁:
SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ; START TRANSACTION; SELECT * FROM orders WHERE user_id = 200 FOR UPDATE; INSERT INTO orders (user_id, amount, status) VALUES (200, 99.00, 'PAID'); COMMIT;
理解隔离级别与锁的联动关系,有助于开发者避免盲目使用SERIALIZABLE。很多业务场景只需要防止脏读和丢失更新,REPEATABLE READ配合行级锁已经能够满足要求。只有当业务需要完全串行化访问时,才应考虑提高隔离级别或使用显式表锁。
三、悲观锁与乐观锁的实现对比
悲观锁假设冲突总会发生,因此在读取数据时就加锁,直到事务结束才释放。它的优点是能够强一致地防止冲突,缺点是锁持有时间长,容易造成阻塞。乐观锁假设冲突概率较低,读取时不加锁,提交更新时通过版本号或时间戳校验数据是否被修改过。如果校验失败,应用程序可以选择放弃或重试。
乐观锁通常使用版本号字段实现。假设商品表有一个version列,每次更新时将version作为条件并自增。如果受影响行数为0,说明数据已经被其他事务修改,当前更新失败。SQL语句如下:
UPDATE product SET stock = stock - 1, version = version + 1 WHERE id = 100 AND version = 5;
应用程序检查受影响行数,如果为0则重新读取最新版本并重试。乐观锁不需要数据库维护长期锁,因此在读多写少、冲突概率低的场景下性能更好。但在高冲突场景下,大量重试会浪费CPU和数据库连接资源,反而可能不如悲观锁稳定。
四、死锁的成因与规避方法
死锁通常发生在两个或多个事务互相等待对方释放锁时。例如事务A先锁定id=1的记录,再尝试锁定id=2;事务B先锁定id=2,再尝试锁定id=1。如果两个事务同时执行,就会形成循环等待。数据库通常会自动检测死锁并回滚其中一个事务,但频繁死锁会严重影响系统稳定性。
-- 事务A START TRANSACTION; UPDATE accounts SET balance = balance - 100 WHERE id = 1; UPDATE accounts SET balance = balance + 100 WHERE id = 2; COMMIT; -- 事务B START TRANSACTION; UPDATE accounts SET balance = balance - 50 WHERE id = 2; UPDATE accounts SET balance = balance + 50 WHERE id = 1; COMMIT;
规避死锁最有效的方法是统一加锁顺序。在上述转账场景中,可以约定所有事务都先操作id较小的账户,再操作id较大的账户,这样就不会出现交叉等待。此外,尽量缩短事务执行时间、避免在事务中调用外部服务、合理使用索引减少锁范围,也能显著降低死锁概率。
对于已经发生的死锁,应用程序可以捕获死锁异常并进行有限次数重试。很多数据库驱动会抛出特定的死锁错误码,例如MySQL错误1213。捕获后可以随机等待几十毫秒再重试,通常会成功。需要注意的是,重试必须设置上限,否则可能陷入无限循环。
综合来看,锁机制和事务处理是解决共享冲突的两个核心支柱。锁决定并发访问的互斥程度,事务保证多步操作的一致性。开发人员应当根据业务读写比例、冲突概率和一致性要求选择合适的策略,而不是在所有场景下都使用同一种加锁方案。只有深入理解锁的粒度、隔离级别和死锁成因,才能设计出既安全又高效的并发控制方案。