人人都会AI编程

17.3 并发场景下的数据安全方案

更新时间:2026-07-11

数据库自身的锁和事务机制只能解决“单个操作”的正确性。线上业务中,真正的难题往往出现在更高层:多个请求同时操作同一笔资金、同一件商品库存、同一条配置记录时,如何保证数据不错、不丢、不重复。这一节就聚焦于这些高频并发场景下的实用安全方案。

17.3.1 更新丢失:最常见的并发隐患

更新丢失(Lost Update)发生在“先读后写”的业务模式里。示例场景:两个请求同时读取用户余额为 100 元,然后各自加上 50 元,结果最终余额变成了 150 元,丢失了其中一个更新。要解决这类问题,不外乎三种方案。

方案一:悲观锁(Pessimistic Lock)

在 MySQL 中,可以在事务内使用 SELECT ... FOR UPDATE 对要操作的行加排他锁。事务 A 读取并锁住该行后,事务 B 再尝试 FOR UPDATE 就会被阻塞,避免了并发读写。

-- 事务 A
START TRANSACTION;
SELECT balance FROM accounts WHERE user_id = 1 FOR UPDATE;
-- 假设读到 100,在应用层计算出新余额 150
UPDATE accounts SET balance = 150 WHERE user_id = 1;
COMMIT;

但要注意:「FOR UPDATE 必须命中索引,否则会退化为表锁或间隙锁,严重影响并发。另外,锁定要尽量简短,避免创建长事务造成锁等待堆积。

方案二:乐观锁(Optimistic Lock)

乐观锁适用于竞争不激烈的场景。在表中增加一个版本号字段(比如 version),每次更新时检查版本号并在该次更新中递增它。

-- 业务 SQL
UPDATE accounts
SET balance = balance + 50, version = version + 1
WHERE user_id = 1 AND version = 5;  -- version 5 是之前读到的版本

如果受影响的行数为 0,说明版本已被其他事务更改,此次更新失败,可以重试或者返回业务错误。乐观锁不需要持有行锁,读操作完全无阻塞,并发性能好,但对业务代码有侵入性,需要处理重试逻辑。

方案三:原子更新(Atomic Update)

如果业务逻辑允许,最好直接利用数据库的原子操作,彻底避免“先读后写”。

-- 余额增加 50,完全在数据库内完成
UPDATE accounts SET balance = balance + 50 WHERE user_id = 1;

只要能通过 UPDATE ... SET col = col + delta 这种形式实现需求,就不需要悲观锁或乐观锁,简洁且性能最优。

17.3.2 防止超卖:库存场景的经典实操

“超卖”是电商里的头号并发问题。假设库存只剩 1 件,两个请求同时读到库存为 1,都决定扣减,最终卖出了 2 件。解决超卖的核心原则是:扣减库存必须是串行的,或者基于准确的条件判断。

基于行锁的库存扣减

直接在 MySQL 中利用行锁是最简单的方案:

START TRANSACTION;
-- 尝试锁定库存行
SELECT stock FROM products WHERE product_id = 100 FOR UPDATE;
-- 判断库存是否足够
如果 stock >= 购买数量,执行:
UPDATE products SET stock = stock - ? WHERE product_id = 100;
COMMIT;

缺点在于,高并发时所有请求都会在这行数据上排队,数据库压力很大。通常会将热点库存数据缓存到 Redis,在缓存层预扣减,再异步同步到数据库,这已经超出了数据库自身的范围。

基于乐观锁的库存扣减

UPDATE products
SET stock = stock - 1
WHERE product_id = 100 AND stock >= 1;

如果影响行数为 0,说明库存不足或并发修改失败。这种方式不会产生锁等待,但同样存在高并发下更新频繁冲突导致的重试风暴,需要结合限流或随机退避使用。

基于版本号的乐观锁扣减(更严格)

UPDATE products
SET stock = stock - 1, version = version + 1
WHERE product_id = 100 AND version = ? AND stock >= 1;

这种方式严格控制了版本变化,能进一步防止 ABA 问题(虽然库存场景通常不发生),缺点和上面一样。

对于真正的高并发秒杀,单靠数据库护不住的,通常会结合 Redis 原子操作(DECR 命令返回之后的值)做库存前置校验和扣减,数据库只作最终确保持久化,但本小节仍聚焦 MySQL 内建方案。

17.3.3 防止重复处理:分布式环境下的幂等保障

在网络不稳定、客户端重试、消息重复投递的环境中,同一笔订单可能被处理多次。幂等性方案在第 17.2 节已有基础说明,这里补充在并发场景下尤其需要注意的两种具体方案。

利用数据库唯一约束防重

先入为主的思路:在数据库增加一个唯一索引,把幂等键(如订单号 + 业务类型)作为约束,插入重复记录时直接返回错误或者忽略。

INSERT INTO order_log (order_id, status, biz_id) VALUES (?, ?, ?);

如果插入抛出唯一键冲突异常,表明已经处理过,可以查询现有状态并返回。这种方式要求幂等键选择恰当,且适合“处理即记录”的场景,性能开销极小。

利用悲观锁保证单线程处理

有的业务需要先查后改,无法靠插入唯一索引,这时可以在处理前对幂等键对应的行加锁。

SELECT status FROM orders WHERE order_id = ? FOR UPDATE;
-- 如果 status 表明还没处理,则执行业务并更新状态
UPDATE orders SET status = 'done' WHERE order_id = ?;

这里的 FOR UPDATE 保证同一订单号只有一个请求获得锁并执行,其他请求要么等待,要么获知已处理并直接返回。注意这依然依赖于幂等键存在,且表中有对应行。

分布式锁

当操作不限于一张数据库表,或者希望锁的粒度更粗、生命周期更短,可以使用 Redis 或 ZooKeeper 实现分布式锁。例如用 Redis 的 SET key value NX EX 10 获取锁,任务完成后删除。务必要注意锁的过期时间和自动续租,避免锁被错误释放。

17.3.4 热点数据竞争与分桶优化

当大量请求集中在同一行数据(例如爆款商品、大 V 的粉丝计数),行锁会成为性能瓶颈。常规优化思路是“打散热点”:

  • 计数器分桶:比如将关注数量拆分为 100 个桶,每个用户随机分配到一个桶,更新时更新对应桶,读取时汇总所有桶。这会牺牲一点查询性能,但写入压力被均匀分散。
  • 合并更新:对于某些允许短暂延迟的计数类操作,在内存进行批量合并,再定时或批量写回数据库,降低单行的写入频率。
  • 使用 Redis 做写缓冲:写入先在 Redis 中完成,用 INCRBY 原子变更,再异步同步到 MySQL,将热点从数据库剥离。

这类方案本质上已经超出了数据库自身并发控制的范围,但在实际架构中,它们与 MySQL 的锁和事务机制协同工作,共同保障并发数据安全。

17.3.5 实务决策树:什么场景用什么方案

  • 能原子更新不用锁:能用 UPDATE SET x = x + delta 一步解决,就不要先查后改。
  • 竞争不激烈用乐观锁:冲突率低,性能好,代码略复杂,记得加重试机制。
  • 必须串行化用悲观锁:获取锁的 FOR UPDATE 务必走索引,锁定时间尽可能短。
  • 防重入用唯一索引或状态机:幂等要求高,唯一索引是最简单有效的方案。
  • 超高并发热点用分桶或缓存:行锁扛不住时,及时转换思路,将热点打散或柔性处理。

并发场景下的数据安全,从来不是靠单一机制就能完全保证的,而是在理解数据库锁与事务原理的基础上,结合业务特点,选择最合适的组合方案。你的目的不是消灭所有并发,而是在数据正确的前提下,让系统能够平稳地应对真实世界的流量。