Skip to content

Daily Study

更新: 10/26/2025 字数: 0 字 时长: 0 分钟

Daily Plan

#todo

  • [ ]

mcp仓库

国内一些mcp仓库:

Mysql中Select for update

核心问题:竞争条件 (Race Condition) 与丢失更新 (Lost Update)

想象一个在线票务系统,两个用户在同一瞬间尝试预订最后一张演唱会门票。如果没有适当的保护措施,可能会发生以下情况:

  1. 两个用户的请求同时查询数据库。

  2. 系统都告诉他们“还有一张票”。

  3. 两个用户都继续执行购票操作。

这种多个线程/进程争夺同一资源而导致意外结果的现象,被称为竞争条件 (Race Condition)。在上述场景中,它会导致一张票被卖了两次,这种情况通常被称为丢失更新 (Lost Update)

解决方案:使用 SELECT FOR UPDATE 实现悲观锁

当一个事务执行 SELECT ... FOR UPDATE 语句时,它会对查询到的数据行加上一个排他锁 (Exclusive Lock)。这个锁会带来以下效果:

  • 阻塞其他事务的修改和删除操作:任何其他事务想对这些锁定的行执行 UPDATEDELETE,都必须等待,直到持有锁的事务提交 (COMMIT) 或回滚 (ROLLBACK)。

  • 阻塞其他事务的加锁请求:其他事务无法对这些行再施加 FOR UPDATEFOR SHARE 类型的锁。

现在,我们再用 SELECT FOR UPDATE 来重演一次票务系统的场景:

时间点事务 1 (用户 A)事务 2 (用户 B)数据库状态 (余票)
T1BEGIN;1
T2BEGIN;1
T3SELECT ... FROM events WHERE event_id = 123 FOR UPDATE; (查询并锁定该行)1
T4SELECT ... FROM events WHERE event_id = 123 FOR UPDATE; (等待锁释放)1
T5UPDATE events SET available_seats = 0 WHERE event_id = 123;(仍在等待)0
T6COMMIT; (提交事务,释放锁)(仍在等待)0
T7(获取到锁,开始执行查询,此时查到余票为 0)0
T8(发现余票为 0,通知用户 B 购票失败)0
T9ROLLBACK; (或 COMMIT;)0

SELECT FOR UPDATE 的常见应用场景

SELECT FOR UPDATE 特别适用于那些需要“先读取、再修改、最后写入”且数据一致性要求极高的场景:

  • 库存管理系统:防止多个订单同时出售最后一件商品。

  • 金融交易系统:确保一个账户的余额不会被两个交易同时修改,避免最终金额计算错误。

  • 预订和预约系统:保证一个座位、一个房间或一个时间段不会被重复预订。

  • 任务队列处理:允许一个工作进程从队列中取出一个任务并将其锁定,确保没有其他进程会重复处理同一个任务。

陷阱

  • 降低并发性:因为锁会阻塞其他事务,如果一个锁被长时间持有,会迫使其他请求排队等待,可能成为系统的性能瓶颈
  • WHERE 条件要精确:只锁定你真正需要修改的行。在 WHERE 子句中使用索引至关重要。如果没有合适的索引,数据库可能会将行锁升级为表锁,从而可能会导致死锁,这将严重影响整个系统的并发性能。

针对 Race Condition 问题其他解决方案

方式一:利用数据库的唯一约束 (UNIQUE Constraint)

这是最简单、最直接,也是最容易被忽略的方式。它的核心思想是让数据库本身来保证数据的唯一性,从而从根本上杜绝脏数据的产生。

  • 核心思想:事后监督,不符合规则的直接拒绝。

  • 如何实现:在表中的一个或多个列上创建 UNIQUE 索引或约束。当有重复数据插入或更新时,数据库会直接报错,应用程序捕获这个错误并处理即可。

  • 适用场景

    • 用户注册:用户名或邮箱不能重复。给 username 列加上唯一约束。

    • 秒杀抢购:一个用户只能抢购一次。在订单表里建立 (user_id, product_id) 的联合唯一索引。

    • 预订座位:一个座位只能被一个人预订。在预订表里建立 (event_id, seat_number) 的联合唯一索引。

  • 优点

    • 极其简单:只需一条 DDL 语句定义约束,无需修改应用逻辑。

    • 性能极高:依赖数据库底层实现,高效可靠。

    • 最终保障:是数据一致性的最后一道防线,即使应用逻辑有漏洞,数据库也能守住底线。

  • 缺点

    • 不灵活:只能处理“重复”问题,对于更复杂的“状态更新”竞争(如库存扣减)无能为力。

    • 需要处理异常:应用代码需要优雅地捕获并处理数据库抛出的唯一性冲突异常。

SQL
-- 为用户表的'username'列添加唯一约束
ALTER TABLE users ADD CONSTRAINT uk_username UNIQUE (username);

-- 应用程序尝试插入,如果用户名已存在,数据库会报错
INSERT INTO users (username, password) VALUES ('testuser', '123456');
-- 如果'testuser'已存在,这里会失败,你的代码需要catch这个Exception。

方式二:使用原子操作 (Atomic Operations)

这个方式的核心是将“读取-修改-写入”这个过程合并成一个不可分割的数据库操作,从而消除竞争条件发生的窗口。

  • 核心思想:一步到位,不给竞争留机会。

  • 如何实现:利用 UPDATE 语句的 WHERE 条件来实现“检查与更新”的原子性。

  • 适用场景

    • 库存扣减:这是最经典的场景。

    • 账户余额更新:在扣款前检查余额是否充足。

  • 优点

    • 性能好:单条 SQL 语句,数据库执行效率高,没有事务加锁的开销。

    • 逻辑简单:应用代码逻辑清晰,只需根据 UPDATE 语句影响的行数来判断操作是否成功。

  • 缺点

    • 适用性有限:只适用于简单的、可以用单条 SQL 表达的“检查并更新”逻辑。
SQL
UPDATE products
SET stock = stock - 1
WHERE id = 123 AND stock > 0;

方式三:使用乐观锁 (Optimistic Locking)

乐观锁与悲观锁(SELECT FOR UPDATE)相对,它假设数据冲突是小概率事件。它不会在操作前加锁,而是在提交更新时检查数据是否已经被别人修改过

  • 核心思想:先斩后奏,提交时再检查。

  • 如何实现

    1. 在数据表中增加一个 version (版本号) 列或 timestamp (时间戳) 列。

    2. 读取数据:查询数据时,把 version 字段也一并读出。

    3. 提交更新:执行 UPDATE 时,把 version 作为一个条件。只有当数据库中的 version 与你当初读到的一致时,才执行更新,并同时将 version 加一。

  • 适用场景

    • 读多写少的场景,例如编辑商品信息、更新文章内容。冲突概率低,使用乐观锁可以避免加锁带来的性能开销。
  • 优点

    • 并发性高:在整个操作过程中都不会阻塞其他事务的读取,只有在最后更新的瞬间才会产生冲突检测。
  • 缺点

    • 实现稍复杂:需要应用程序自己处理版本号和更新失败后的逻辑(如重试或提示用户)。

    • “ABA”问题:如果使用版本号,可以避免这个问题。但如果使用时间戳,理论上存在数据从 A -> B -> A 的变化,但乐观锁无法感知。版本号是更稳妥的选择。

实践(以更新商品价格为例)

SQL
-- 1. 读取商品信息,包括版本号
SELECT price, version FROM products WHERE id = 123;
-- 假设查到 price = 100, version = 1

-- 2. 应用程序计算新价格,比如打九折
new_price = 90;

-- 3. 提交更新,带上版本号作为条件
UPDATE products
SET price = 90, version = version + 1
WHERE id = 123 AND version = 1;

总结与如何选择

解决方法核心思想优点缺点适用场景
唯一约束让数据库兜底,事后监督简单、高效、可靠不灵活,只能防重复用户注册、防重提交等
原子操作一步到位,不留竞争窗口性能好,逻辑简单适用性有限,仅限简单逻辑库存扣减、余额变更
乐观锁先执行,提交时再检查并发性极高实现稍复杂,需要应用处理冲突读多写少的场景,如编辑信息
悲观锁 (SELECT FOR UPDATE)事先加锁,防止一切冲突严谨,数据绝对安全并发性差,可能死锁写多读少,冲突概率高的场景

菜就多练

本站访客数 人次 本站总访问量