缓存并发更新的挑战
缓存并发更新的挑战
- 1. 引言:并发更新的挑战
- 2. 并发场景下的常见“坑”
- 最后写入胜出 (Last-Write-Wins)
- 脏读 (Dirty Read)
- 丢失更新 (Lost Update)
- 不可重复读 (Non-repeatable Read)
- 幻读 (Phantom Read)
- 写偏斜 (Write Skew)
- 缓存与数据库不一致
- 分布式系统中的时序问题
- 3. 为什么选择Redis应对并发挑战?
- 4. 核心策略:保证数据一致性
- 策略概览
- 5. 解决方案详解 (Java + Redis)
- 悲观锁:谨慎的保护者
- 数据库层面实现 (`SELECT ... FOR UPDATE`)
- Java (JPA) 实现
- 悲观锁的优缺点
- 乐观锁:无冲突则通行
- 基于版本号 (`@Version`)
- 基于条件更新 (CAS思想)
- 乐观锁的优缺点与重试
- 分布式锁:协调多方步调
- 为什么需要分布式锁?
- 基于Redis `SETNX` 的简易锁
- 进阶:保证原子性和锁续期 (Lua脚本)
- 工业级方案:Redisson
- 分布式锁的最佳实践与注意事项
- 原子操作:Redis的内功
- Redis原生原子命令 (`INCRBY`/`DECRBY`)
- 利用Lua脚本实现复杂原子操作
- Redis事务 (`WATCH`/`MULTI`/`EXEC`)
- 原子操作的适用场景
- 异步处理:削峰填谷,顺序执行
- 基于消息队列的异步更新模式
- 实现考量
- 缓存一致性模式与策略
- Cache-Aside Pattern (旁路缓存)
- 读/写穿透 (Read/Write-Through)
- 写回 (Write-Back)
- 缓存更新与删除策略的选择
- 延迟双删与消息队列保证最终一致性
- 6. 如何选择合适的策略?
- 场景分析与权衡
- 组合策略的应用
- 7. 总结
1. 引言:并发更新的挑战
在现代Web应用中,为了提升性能和用户体验,缓存(尤其是像Redis这样的内存数据库)的使用几乎无处不在。然而,当多个用户或进程同时尝试读取和修改同一份数据时,就会出现并发更新(Concurrent Updates)的问题。
如果处理不当,并发更新会像一个潜伏的幽灵,悄无声息地导致数据错乱、用户操作丢失,甚至引发严重的业务逻辑错误。
想象一下这样的场景:
- 商品秒杀: 成千上万的用户同时抢购有限的商品,库存数量的更新必须精确无误,否则就会超卖或少卖。
- 用户积分: 多个操作(如签到、购物、评论)可能同时增加用户积分,如果并发处理不当,最终的积分可能与预期不符。
- 共享文档编辑: 多人同时编辑同一文档,后保存的内容可能会覆盖之前用户的修改。
这些场景的核心挑战在于数据一致性(Data Consistency)。我们需要确保,在并发环境下,系统中的数据(无论是数据库还是缓存)始终保持正确、有效,并且符合业务规则。
2. 并发场景下的常见“坑”
在深入解决方案之前,我们必须先清晰地认识到并发更新可能导致的具体问题。理解这些问题的本质,有助于我们更好地选择和应用相应的解决策略。
最后写入胜出 (Last-Write-Wins)
这是最直观也最常见的问题。
-
问题描述: 多个客户端(或线程/进程)几乎同时读取同一份数据的初始状态(例如,库存量为100)。它们各自基于这个初始状态进行修改(A减1变99,B减2变98),然后先后写回。由于B的写入操作发生在A之后,B写入的98会覆盖A写入的99。最终结果是库存为98,但逻辑上正确的库存应该是100 - 1 - 2 = 97。A的操作 фактически丢失了。
-
简单示例 (逻辑):
// 初始库存: 100 // 线程A int stockA = database.getStock(); // 读取到 100 stockA = stockA - 1; // 计算为 99 // ... 可能有一些耗时操作 ... database.setStock(stockA); // 写入 99// 线程B (几乎同时发生) int stockB = database.getStock(); // 读取到 100 stockB = stockB - 2; // 计算为 98 // ... 可能有一些耗时操作 ... database.setStock(stockB); // 写入 98 (覆盖了A的写入)// 最终库存: 98 (错误, 应该是 97)
脏读 (Dirty Read)
-
问题描述: 一个事务(Transaction A)修改了数据,但尚未提交。此时,另一个事务(Transaction B)读取了这份被修改过但未提交的数据。如果事务A最终因为某种原因(如异常、业务规则校验失败)执行了回滚(Rollback),那么事务B读取到的数据就是“脏”的、无效的,因为它从未真正持久化。
-
场景模拟: 事务A给用户账户加钱,但中途失败回滚;事务B在事务A回滚前读取了增加后的(临时)余额,并基于这个错误的余额做了后续判断或操作。
-
说明: 这通常与数据库的事务隔离级别有关。在较低的隔离级别(如Read Uncommitted)下可能发生。虽然在典型的缓存操作(如Redis
SET
/GET
)中不直接涉及数据库事务的回滚,但在涉及“缓存+数据库”更新的复杂流程中,如果逻辑处理不当,也可能读取到中间状态的、最终会被撤销的数据。
丢失更新 (Lost Update)
这个概念与“最后写入胜出”非常相似,但有时特指在读-改-写(Read-Modify-Write)的操作序列中,一个事务的更新被另一个并发事务覆盖的情况。
-
问题描述: 两个事务(比如管理员A和管理员B)同时读取了某个商品的原始价格(100元)。管理员A想把价格提高到120元,管理员B想打8折变为80元。他们各自计算完新价格后写回数据库。如果B的事务后提交,那么最终价格就是80元,A的提价操作就丢失了。
-
与Last-Write-Wins的细微区别: Lost Update更强调基于旧值计算新值的场景下的覆盖问题。
-
Java代码示例 (JPA + 事务):
// 事务A: 管理员A提价 @Transactional public void updateProductPrice(Long productId, BigDecimal newPrice) {Product product = productRepository.findById(productId).orElse(null); // 读取价格为 100if (product != null) {// ... 可能有其他业务逻辑 ...product.setPrice(newPrice); // 设置为 120productRepository.save(product); // 尝试保存} }// 事务B: 管理员B打折 (与事务A并发执行) @Transactional public void applyDiscount(Long productId, int discountPercent) {Product product = productRepository.findById(productId).orElse(null); // 也读取价格为 100if (product != null) {BigDecimal currentPrice = product.getPrice();BigDecimal discountedPrice = currentPrice.multiply(BigDecimal.valueOf(1 - discountPercent / 100.0)); // 计算为 80product.setPrice(discountedPrice); // 设置为 80productRepository.save(product); // 尝试保存} } // 如果事务B在事务A之后提交,最终价格为80,提价操作丢失
不可重复读 (Non-repeatable Read)
-
问题描述: 在同一个事务内,先后两次读取同一行数据,但得到的结果不同。这是因为在两次读取之间,有另一个已提交的事务修改了这行数据。
-
场景模拟: 事务A开始处理一个订单,先读取订单状态为“待支付”。接着执行一些耗时操作。在操作期间,事务B(用户支付操作)将该订单状态修改为“已支付”并提交。事务A随后再次读取该订单状态,发现变成了“已支付”,与其首次读取的结果不一致。这可能导致事务A后续的逻辑判断出错。
-
与缓存的关系: 如果缓存没有及时、正确地与数据库同步,也可能出现类似情况:第一次从缓存读到旧状态,第二次(缓存失效或穿透)从数据库读到新状态。
幻读 (Phantom Read)
-
问题描述: 在同一个事务内,先后两次执行范围查询(例如,查询所有状态为“新注册”的用户),但第二次查询返回了第一次查询中没有的新行(“幻影”行)。这是因为在两次查询之间,有另一个已提交的事务插入了符合查询条件的新数据。
-
场景模拟: 事务A第一次查询所有“待处理”的任务,得到5条。它开始逐一处理这5条任务。处理过程中,事务B插入了一条新的“待处理”任务并提交。事务A处理完最初的5条后,为了确认,再次查询所有“待处理”任务,发现结果变成了6条(多了一条“幻影”任务)。
-
与不可重复读的区别: 不可重复读侧重于同一行数据被修改;幻读侧重于查询结果集范围内新增(或删除)了行。
写偏斜 (Write Skew)
这是一个相对复杂和微妙的并发问题。
-
问题描述: 两个事务各自读取一组数据(可能部分重叠),然后基于读取到的信息做出决策,并更新不同的数据项。这两个事务单独看都没有违反约束,但它们组合在一起时,却破坏了系统的某个整体业务约束。
-
经典场景: 医院排班系统规定至少要有一名医生在值班。现在有Alice和Bob两位医生在值班。
- 事务A(Alice想请假):检查当前值班医生数(读到2),发现大于1,允许请假。于是将Alice的状态更新为“休假中”。
- 事务B(Bob想请假,与事务A并发):也检查当前值班医生数(也读到2),发现大于1,允许请假。于是将Bob的状态更新为“休假中”。
- 结果:Alice和Bob都成功请假,系统中没有医生值班了,违反了“至少一人值班”的约束。问题在于,两个事务都基于“读到的值班人数 > 1”这个条件做了判断,但它们各自的更新(修改自己的状态)并没有直接冲突,数据库的常规锁机制可能无法阻止这种情况。
-
关键点: 事务读取数据,基于读取结果做判断,然后更新不相干的数据。
缓存与数据库不一致
这是在使用缓存时最核心的并发问题之一。
-
问题描述: 对数据的更新操作未能同时、原子地完成对数据库和缓存的修改,导致缓存中的数据与数据库中的数据状态不同步。
-
常见失败模式 (以Cache-Aside为例):
- 先更新数据库,再删除/更新缓存:
- 请求A更新数据库成功。
- 请求B读取数据,此时缓存未更新/删除,读到了旧值。
- 请求A删除/更新缓存成功。 (请求B已经读到了旧数据)
- 更糟的情况:
- 请求A更新数据库成功。
- 请求B更新数据库成功。
- 请求B更新缓存成功 (缓存现在是B的值)。
- 请求A更新缓存成功 (缓存现在是A的值,但数据库是B的值,不一致!)。这是由于网络延迟或应用处理耗时导致的操作交错。
- 先删除/更新缓存,再更新数据库:
- 请求A删除/更新缓存成功。
- 请求B读取数据,发现缓存不存在/已更新。
- 请求B从数据库读取旧值 (此时A还没更新数据库)。
- 请求B将旧值写入缓存。
- 请求A更新数据库成功。(数据库是新值,缓存是旧值,不一致!)
- 先更新数据库,再删除/更新缓存:
-
后果: 用户看到过时或错误的信息,业务逻辑基于错误数据执行。
分布式系统中的时序问题
-
问题描述: 在分布式系统中,由于各个节点/服务的物理时钟可能存在偏差(时钟不同步),加上网络传输的延迟是不确定的,导致事件的实际发生顺序或被处理的顺序可能与预期的逻辑顺序不一致。
-
场景模拟: 用户快速连续两次修改个人资料。
- 服务A处理第一次修改,更新数据库,发送“用户更新事件v1”消息(附带时间戳t1)。
- 服务A处理第二次修改,更新数据库,发送“用户更新事件v2”消息(附带时间戳t2,t2 > t1)。
- 由于网络波动,消息v2先于消息v1到达服务B。
- 服务B先处理了v2,更新本地缓存为新资料。
- 服务B后处理了v1,用旧资料覆盖了本地缓存。最终服务B的缓存是旧资料。
-
影响: 导致下游服务的数据状态与最终一致状态相悖,或者处理逻辑混乱。
理解了这些并发问题,我们就能更有针对性地去寻找和应用解决方案了。接下来,我们看看为什么Redis常常是解决这些问题的得力助手。
3. 为什么选择Redis应对并发挑战?
Redis作为一个高性能的内存键值数据库,不仅仅是简单的缓存,它提供的丰富数据结构和特性使其在处理并发场景时具有显著优势:
- 极高的性能: 基于内存操作,读写速度非常快(通常达到数十万QPS)。这使得在关键路径上加入Redis操作(如获取锁、原子计数)对整体性能影响较小。
- 原子操作: Redis的大部分命令都是原子执行的。例如
INCR
(原子增)、DECR
(原子减)、SETNX
(Set if Not Exists,原子设置)等。这意味着当多个客户端同时对同一个键执行这些命令时,Redis内部会确保它们一个接一个地、不受干扰地完成,避免了竞态条件。这是实现乐观锁、计数器等并发控制机制的基础。 - 丰富的数据结构: 除了简单的String类型,Redis还提供Lists, Sets, Sorted Sets, Hashes等,可以灵活地满足不同业务场景的需求。
- 内置的发布/订阅 (Pub/Sub): 可用于实现简单的消息通知,例如在数据更新后通知其他服务清理相关缓存。
- Lua脚本支持: 允许将多个Redis命令组合成一个原子执行的单元。这对于实现复杂的原子操作(如:检查并设置、释放锁时的归属判断)至关重要,避免了多次网络往返和潜在的竞态条件。
- 分布式锁的天然实现基础:
SET key value NX PX milliseconds
命令(或老的SETNX
+EXPIRE
组合,需注意原子性)可以直接用于实现分布式锁,控制对共享资源的互斥访问。 - 高可用与扩展性: Redis Sentinel(哨兵)和 Redis Cluster 提供了高可用和水平扩展能力,能够满足大规模应用的需求。
简而言之,Redis的速度、原子性操作和灵活性,使其成为在分布式系统中实现缓存、计数器、分布式锁、消息队列等功能的理想选择,进而有效地帮助我们应对并发更新带来的挑战。
4. 核心策略:保证数据一致性
面对形形色色的并发问题,并没有一个万能的“银弹”可以解决所有情况。我们需要根据具体的业务场景、对一致性的要求(强一致性 vs. 最终一致性)、性能要求、系统复杂度等因素,选择或组合使用不同的策略。
策略概览
以下是一些常用的核心策略,后续章节将重点围绕Java和Redis进行详解:
-
锁 (Locking):
- 悲观锁 (Pessimistic Locking): 假设冲突总是会发生。在操作数据前先获取锁,阻止其他事务访问,操作完成后释放锁。实现方式包括数据库行锁 (
SELECT ... FOR UPDATE
)、同步代码块 (synchronized
) 或ReentrantLock
(主要用于单体应用内)、分布式锁 (Redis, ZooKeeper)。 优点: 简单直接,能有效保证强一致性。 缺点: 性能开销大,可能产生死锁,降低并发度。 - 乐观锁 (Optimistic Locking): 假设冲突很少发生。读取数据时不加锁,但在更新时检查数据是否被其他事务修改过(通常通过版本号或时间戳)。如果未被修改,则更新成功;如果已被修改,则更新失败,通常需要重试或报错。 优点: 并发性能好,适用于读多写少的场景。 缺点: 实现相对复杂,冲突严重时大量重试会降低性能,需要应用层处理冲突。
- 分布式锁 (Distributed Locking): 用于跨多个服务或实例协调对共享资源的访问。Redis是实现分布式锁的常用工具。
- 悲观锁 (Pessimistic Locking): 假设冲突总是会发生。在操作数据前先获取锁,阻止其他事务访问,操作完成后释放锁。实现方式包括数据库行锁 (
-
原子操作 (Atomic Operations): 利用数据库或缓存(如Redis)提供的原子命令来执行“读-改-写”操作,确保其不可中断。例如Redis的
INCR
/DECR
,SET
(带NX
/XX
选项), 以及Lua脚本。 -
多版本并发控制 (MVCC - Multi-Version Concurrency Control): 主要由数据库实现(如InnoDB)。通过为数据保留多个版本,实现读写不阻塞。读取操作通常读取数据的一个快照版本,写入操作则创建新版本。它主要解决读写冲突,但对于写-写冲突(如Lost Update)仍需结合锁或其他机制。
-
事务隔离级别 (Transaction Isolation Levels): 数据库提供不同的隔离级别(Read Uncommitted, Read Committed, Repeatable Read, Serializable)来控制事务并发执行时数据的可见性,以避免脏读、不可重复读、幻读等问题。选择合适的隔离级别是一种策略,但通常隔离级别越高,并发性能越差。
-
异步处理 (Asynchronous Processing): 将可能产生冲突的写操作放入消息队列(如RabbitMQ, Kafka)。由单一或有限的消费者按顺序处理,将并发写转化为串行写,从而避免冲突。适用于对实时性要求不高,但需要保证最终顺序和结果正确的场景。
-
最终一致性 (Eventual Consistency): 接受系统在短时间内可能存在数据不一致的状态,但保证通过异步机制(如消息队列、定时任务补偿)最终达到一致。适用于对一致性要求不是非常严格,但对可用性和性能要求很高的分布式系统。
-
特定数据结构与算法: 如CRDTs (Conflict-free Replicated Data Types),用于在分布式环境下无需中央协调即可合并并发更新,常见于分布式数据库和协作编辑软件,但实现复杂。
接下来的章节,我们将聚焦于如何在Java应用中,结合Redis,实践上述策略中的关键部分,特别是乐观锁、分布式锁、原子操作和缓存一致性策略。
5. 解决方案详解 (Java + Redis)
现在,我们进入实战环节,详细探讨如何使用Java和Redis来实现各种并发控制策略。我们将提供代码示例,并解释其背后的原理和注意事项。
悲观锁:谨慎的保护者
悲观锁的核心思想是“先锁定,再操作”。它假设并发冲突是常态,因此在访问资源前必须获得独占权限。
数据库层面实现 (SELECT ... FOR UPDATE
)
最常见的悲观锁实现是在数据库层面使用行级锁。当一个事务需要更新某行数据时,它会先查询这行数据并加上排他锁(Write Lock)。其他试图获取写锁或读锁(取决于锁类型和隔离级别)的事务会被阻塞,直到持有锁的事务提交或回滚。
-
SQL示例 (MySQL InnoDB):
-- 事务A开始 BEGIN TRANSACTION;-- 查询商品ID为1的数据,并加上排他锁 (其他事务无法修改或加锁此行) SELECT * FROM products WHERE id = 1 FOR UPDATE;-- 基于查询结果进行操作 (比如检查库存,然后更新) -- 假设当前库存大于需要扣减的数量 UPDATE products SET stock = stock - 1 WHERE id = 1;-- 提交事务,释放锁 COMMIT;-- 如果事务B在事务A持有锁期间尝试执行 SELECT ... FOR UPDATE 或 UPDATE 同一行, -- 它将被阻塞,直到事务A COMMIT 或 ROLLBACK。
Java (JPA) 实现
在使用JPA(Java Persistence API)时,可以通过特定注解或查询提示(Query Hint)来使用数据库的悲观锁。
-
使用
@Lock
注解:import jakarta.persistence.LockModeType; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Lock; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; import org.springframework.transaction.annotation.Transactional;// Product 实体类 (省略) // @Entity // public class Product { ... }@Repository public interface ProductRepository extends JpaRepository<Product, Long> {// 定义一个使用悲观写锁查询的方法// PESSIMISTIC_WRITE 对应数据库的 FOR UPDATE// PESSIMISTIC_READ 对应数据库的 FOR SHARE (共享锁,允许其他事务读,但不允许写)@Lock(LockModeType.PESSIMISTIC_WRITE)@Query("SELECT p FROM Product p WHERE p.id = :id")Product findByIdForUpdate(@Param("id") Long id); }@Service public class ProductService {@Autowiredprivate ProductRepository productRepository;@Transactional // 必须在事务内执行才能获取并持有锁public boolean deductStockPessimistic(Long productId, int quantity) {// 调用加锁查询方法Product product = productRepository.findByIdForUpdate(productId);if (product == null) {throw new ProductNotFoundException("商品不存在: " + productId);}// 在持有锁的情况下检查和更新库存if (product.getStock() >= quantity) {product.setStock(product.getStock() - quantity);productRepository.save(product); // 保存更新System.out.println("线程 " + Thread.currentThread().getName() + ": 扣减库存成功,商品ID: " + productId);return true;} else {System.out.println("线程 " + Thread.currentThread().getName() + ": 库存不足,商品ID: " + productId);return false;}// 事务提交时,锁会自动释放} }
理解帮助:
@Transactional
: 悲观锁必须在数据库事务内才能生效和维持。事务开始时获取锁,事务结束(提交或回滚)时释放锁。@Lock(LockModeType.PESSIMISTIC_WRITE)
: 告诉JPA提供者(如Hibernate)在执行这个查询时,要向数据库请求一个排他写锁(通常是FOR UPDATE
)。- 阻塞行为: 如果多个线程同时调用
deductStockPessimistic
方法处理同一个productId
,只有一个线程能成功获取数据库行锁并执行findByIdForUpdate
后面的代码。其他线程会被阻塞在findByIdForUpdate
调用处,直到第一个线程的事务结束释放锁。这样就保证了库存检查和更新操作的原子性,避免了并发冲突。
悲观锁的优缺点
- 优点:
- 强一致性保证: 实现简单,能有效防止丢失更新等写冲突问题。
- 可靠性高: 逻辑清晰,不易出错。
- 缺点:
- 性能影响大: 加锁和解锁有开销,更重要的是,锁会阻塞其他事务,显著降低系统的并发处理能力。如果锁持有时间过长(例如事务中有耗时操作),性能瓶颈会非常明显。
- 可能产生死锁: 如果多个事务相互等待对方持有的锁,就会形成死锁,导致所有相关事务都无法继续执行。需要数据库或应用层面有死锁检测和处理机制。
- 不适用于高并发写场景: 在秒杀等场景下,大量请求争抢同一个商品的锁,会导致大量线程阻塞,系统吞吐量急剧下降。
何时使用? 悲观锁适用于并发写入冲突概率高,且对数据一致性要求非常严格,可以容忍一定性能损失的场景。例如,金融交易、核心账户余额操作等。对于大多数互联网应用的高并发场景,通常需要寻求性能更好的方案。
乐观锁:无冲突则通行
乐观锁假设并发冲突是小概率事件。它不对数据加锁,而是在更新时检查数据在此期间是否被其他事务修改过。
基于版本号 (@Version
)
这是JPA中实现乐观锁最常用的方式。
-
在实体类中添加版本号字段:
import jakarta.persistence.*; import java.time.LocalDateTime;@Entity public class Product {@Id@GeneratedValue(strategy = GenerationType.IDENTITY)private Long id;private String name;private int stock;// 版本号字段,使用 @Version 注解// JPA会在每次更新时自动检查和递增这个字段的值// 类型可以是 int, Integer, long, Long, short, Short, java.sql.Timestamp@Versionprivate int version;// (Getters and Setters 省略) }
-
更新操作:
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.springframework.orm.ObjectOptimisticLockingFailureException; // 捕获版本冲突异常@Service public class ProductService {@Autowiredprivate ProductRepository productRepository; // JpaRepository<Product, Long>@Transactionalpublic boolean deductStockOptimistic(Long productId, int quantity) {try {// 1. 读取数据(包含当前版本号)Product product = productRepository.findById(productId).orElseThrow(() -> new ProductNotFoundException("商品不存在: " + productId));int currentVersion = product.getVersion(); // 获取当前版本号System.out.println("线程 " + Thread.currentThread().getName() + ": 读取商品 " + productId + ", 当前库存 " + product.getStock() + ", 版本号 " + currentVersion);// 2. 检查库存 (业务逻辑)if (product.getStock() >= quantity) {// 3. 修改数据product.setStock(product.getStock() - quantity);// 模拟一些业务处理耗时,增加并发冲突的概率try {Thread.sleep(50); // 暂停50毫秒} catch (InterruptedException e) { Thread.currentThread().interrupt(); }// 4. 尝试保存 (更新)// JPA/Hibernate 在执行 save (对于已存在的实体是 merge/update) 时:// a. 会自动将 version 字段加 1// b. 生成的 UPDATE 语句会包含 WHERE id = ? AND version = ? 条件// 例如: UPDATE products SET stock = ?, version = ? WHERE id = ? AND version = ?// 参数: 新库存, 新版本, ID, 读取时的旧版本System.out.println("线程 " + Thread.currentThread().getName() + ": 尝试更新商品 " + productId + " 至版本 " + (currentVersion + 1));productRepository.save(product);System.out.println("线程 " + Thread.currentThread().getName() + ": 更新商品 " + productId + " 成功,新版本 " + product.getVersion());return true; // 更新成功} else {System.out.println("线程 " + Thread.currentThread().getName() + ": 商品 " + productId + " 库存不足");return false; // 库存不足}} catch (ObjectOptimisticLockingFailureException e) {// 捕获乐观锁异常 (StaleObjectStateException in Hibernate)// 这表示在读取数据后、尝试更新前,数据已被其他事务修改(版本号变化)System.out.println("线程 " + Thread.currentThread().getName() + ": 更新商品 " + productId + " 失败,发生乐观锁冲突!");// 这里可以选择: 返回失败、记录日志、或者进行重试return false; // 更新失败} catch (ProductNotFoundException e) {System.err.println(e.getMessage());return false;}} }
理解帮助:
@Version
注解标记的字段由JPA容器管理。- 当调用
save
(或merge
) 更新一个带有@Version
字段的实体时,JPA会自动在UPDATE
语句的WHERE
子句中加入version = [读取时的版本号]
的条件。 - 如果
UPDATE
语句执行时,数据库中该行的version
仍然等于读取时的版本号,说明没有其他事务修改过它,更新成功,并且version
字段的值会自动加1。 - 如果
UPDATE
执行时,数据库中该行的version
已经不等于读取时的版本号(说明被其他事务捷足先登修改了),那么UPDATE
语句的WHERE
条件不满足,更新影响的行数为0。JPA检测到这种情况,就会抛出ObjectOptimisticLockingFailureException
(或其他具体的乐观锁异常)。 - 核心思想: 通过比较版本号,确保更新操作是基于最新的数据状态进行的。
基于条件更新 (CAS思想)
除了依赖JPA的 @Version
,我们也可以在代码或SQL层面手动实现类似CAS(Compare-and-Swap)的逻辑。即,在更新时明确指定一个前提条件(例如,库存必须等于读取时的值)。
-
Repository层实现:
import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository;@Repository public interface ProductRepository extends JpaRepository<Product, Long> {// 使用 @Modifying 注解表示这是一个更新查询// SQL/JPQL: 只有当id匹配且当前库存(p.stock)等于期望的旧库存(expectedOldStock)时,// 才将库存更新为新库存(newStock)。@Modifying@Query("UPDATE Product p SET p.stock = :newStock WHERE p.id = :id AND p.stock = :expectedOldStock")int updateStockIfMatch(@Param("id") Long id,@Param("newStock") int newStock,@Param("expectedOldStock") int expectedOldStock);// 如果不使用乐观锁,单纯更新库存的方法 (可能导致Last-Write-Wins)@Modifying@Query("UPDATE Product p SET p.stock = :newStock WHERE p.id = :id")int updateStockUnsafe(@Param("id") Long id, @Param("newStock") int newStock); }
-
Service层调用:
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional;@Service public class ProductService {@Autowiredprivate ProductRepository productRepository;@Transactionalpublic boolean deductStockCas(Long productId, int quantity) {// 1. 读取当前库存 (这里不加锁)Product product = productRepository.findById(productId).orElseThrow(() -> new ProductNotFoundException("商品不存在: " + productId));int currentStock = product.getStock();System.out.println("线程 " + Thread.currentThread().getName() + ": 读取商品 " + productId + ", 当前库存 " + currentStock);// 2. 检查库存if (currentStock < quantity) {System.out.println("线程 " + Thread.currentThread().getName() + ": 商品 " + productId + " 库存不足");return false;}// 3. 计算新库存int newStock = currentStock - quantity;// 模拟耗时try { Thread.sleep(50); } catch (InterruptedException e) { Thread.currentThread().interrupt(); }// 4. 执行条件更新 (CAS)// 尝试将库存从 currentStock 更新为 newStockSystem.out.println("线程 " + Thread.currentThread().getName() + ": 尝试CAS更新商品 " + productId + " 从 " + currentStock + " 到 " + newStock);int updatedRows = productRepository.updateStockIfMatch(productId, newStock, currentStock);// 5. 检查更新结果if (updatedRows > 0) {// 更新成功,说明在我们读取和更新之间,库存值没有被其他线程改变System.out.println("线程 " + Thread.currentThread().getName() + ": CAS更新商品 " + productId + " 成功");return true;} else {// 更新失败 (updatedRows == 0)// 说明在我们读取库存(currentStock)之后,到执行updateStockIfMatch之前,// 数据库中的库存值已经被其他线程修改了,不再是 currentStock 了。System.out.println("线程 " + Thread.currentThread().getName() + ": CAS更新商品 " + productId + " 失败,发生冲突");// 同样,这里可以返回失败、记录日志或重试return false;}} }
理解帮助:
- 这种方法将比较和更新两个操作合并到了数据库的一条原子
UPDATE
语句中。 updateStockIfMatch
方法返回的是实际被更新的行数。如果行数大于0,表示CAS成功;如果等于0,表示CAS失败(因为WHERE
条件中的p.stock = :expectedOldStock
未满足)。- 这种方式不依赖JPA的
@Version
机制,但实现了相同的乐观并发控制效果。
- 这种方法将比较和更新两个操作合并到了数据库的一条原子
乐观锁的优缺点与重试
- 优点:
- 高并发性能: 读取数据时不加锁,允许多个事务同时读取。只有在写入(提交)时才进行冲突检测,大大提高了并发度。
- 适用于读多写少: 在读取操作远多于写入操作的场景下,性能优势明显。
- 缺点:
- 冲突处理复杂: 当冲突发生时(抛出异常或CAS返回0),需要应用层决定如何处理:是直接失败报错,还是进行重试?
- 重试机制: 如果选择重试,需要重新读取数据、重新计算、再次尝试更新。在高冲突场景下,大量重试会消耗CPU和数据库资源,甚至可能导致重试风暴,性能反而下降。重试逻辑需要仔细设计(如限制重试次数、增加退避时间)。
- ABA问题: (主要针对CAS)如果一个值从A变为B,再变回A,CAS检查会认为数据没有变过,但实际上中间发生过变化。使用版本号可以避免ABA问题,因为版本号是单向递增的。如果使用时间戳,只要保证单调递增且足够精确,通常也能避免。
重试逻辑示例 (简化版):
@Service
public class ProductService {// ... (Autowired repository)private static final int MAX_RETRIES = 3; // 最大重试次数// 使用 @Version 的重试public boolean deductStockOptimisticWithRetry(Long productId, int quantity) {int attempts = 0;while (attempts < MAX_RETRIES) {try {if (deductStockOptimisticInternal(productId, quantity)) { // 调用之前的 @Version 逻辑return true; // 成功}// 如果是库存不足等业务失败,直接返回false,不重试return false;} catch (ObjectOptimisticLockingFailureException e) {attempts++;System.out.println("线程 " + Thread.currentThread().getName() + ": 乐观锁冲突,商品 " + productId + ",尝试次数 " + attempts);if (attempts >= MAX_RETRIES) {System.err.println("线程 " + Thread.currentThread().getName() + ": 达到最大重试次数,更新失败");return false; // 达到最大次数,失败}// 可以选择等待一小段时间再重试 (退避策略)try { Thread.sleep(10 * attempts); } catch (InterruptedException ie) { Thread.currentThread().interrupt(); }} catch (Exception e) {// 处理其他异常System.err.println("发生其他错误: " + e.getMessage());return false;}}return false; // 正常逻辑不应该执行到这里}// 使用 CAS 的重试 (类似逻辑)public boolean deductStockCasWithRetry(Long productId, int quantity) {int attempts = 0;while(attempts < MAX_RETRIES) {// 1. 读取当前库存Product product = productRepository.findById(productId).orElse(null);if (product == null) return false; // 商品不存在int currentStock = product.getStock();if (currentStock < quantity) return false; // 库存不足int newStock = currentStock - quantity;// 2. 尝试 CAS 更新int updatedRows = productRepository.updateStockIfMatch(productId, newStock, currentStock);if (updatedRows > 0) {return true; // 更新成功} else {// CAS 失败,表示冲突attempts++;System.out.println("线程 " + Thread.currentThread().getName() + ": CAS冲突,商品 " + productId + ",尝试次数 " + attempts);if (attempts >= MAX_RETRIES) {System.err.println("线程 " + Thread.currentThread().getName() + ": 达到最大重试次数,更新失败");return false;}try { Thread.sleep(10 * attempts); } catch (InterruptedException ie) { Thread.currentThread().interrupt(); }}}return false;}// 内部的乐观锁逻辑,需要能抛出 ObjectOptimisticLockingFailureException@Transactionalprotected boolean deductStockOptimisticInternal(Long productId, int quantity) throws ObjectOptimisticLockingFailureException {// ... 实现同 deductStockOptimistic,但不捕获 ObjectOptimisticLockingFailureException ...// 让异常抛给调用者 deductStockOptimisticWithRetry 处理Product product = productRepository.findById(productId).orElseThrow(() -> new ProductNotFoundException("商品不存在: " + productId));int currentVersion = product.getVersion();if (product.getStock() >= quantity) {product.setStock(product.getStock() - quantity);// 可能的耗时操作...productRepository.save(product); // 可能抛出 ObjectOptimisticLockingFailureExceptionreturn true;} else {return false;}}
}
何时使用? 乐观锁非常适合读多写少,且对并发性能要求较高的场景。例如,更新用户信息、商品详情页(库存更新可能需要更强机制)、配置项修改等。对于写入冲突非常频繁的场景(如秒杀),单纯的乐观锁可能因大量重试而失效,需要结合其他策略。
分布式锁:协调多方步调
当你的应用部署在多个实例(进程)上,或者有多个不同的服务需要访问同一个共享资源(如Redis中的某个键、数据库中的某行记录、或是一个需要互斥执行的任务)时,Java内置的 synchronized
或 ReentrantLock
就无能为力了,因为它们的作用范围仅限于单个JVM进程。这时,我们就需要分布式锁。
Redis因其高性能和原子操作特性,是实现分布式锁的热门选择。
为什么需要分布式锁?
想象一下商品秒杀场景,多个应用实例都在处理扣减库存的请求。如果不加控制,它们可能会并发执行类似“读取库存 -> 检查库存 -> 扣减库存”的操作,导致超卖(类似于前面讨论的Lost Update问题)。我们需要一种机制,确保在任何时刻,只有一个实例能够执行这段关键代码。分布式锁就是扮演这个协调者的角色。
基于Redis SETNX
的简易锁
最基础的Redis分布式锁实现思路是利用 SET key value NX
命令。NX
选项表示 “Not Exists”,即只有当 key
不存在时,SET
命令才会成功并设置 value
。
- 获取锁: 尝试执行
SET lock_key random_value NX PX lock_timeout_milliseconds
。- 如果命令返回
OK
(或 1),表示获取锁成功。random_value
是一个唯一的标识符(如UUID),用于标识锁的持有者,防止误删他人持有的锁。PX lock_timeout_milliseconds
设置了一个锁的过期时间,防止持有锁的客户端崩溃导致锁无法释放(死锁)。 - 如果命令返回
nil
(或 0),表示lock_key
已存在,获取锁失败。
- 如果命令返回
- 释放锁: 执行
DEL lock_key
。但是,直接DEL
是有风险的:如果客户端A获取锁后,业务执行时间超过了锁的超时时间,锁被Redis自动释放了;此时客户端B获取了该锁;然后客户端A执行完了业务,执行DEL
操作,结果把客户端B持有的锁给删除了!
安全的释放锁需要“原子地”判断锁是否仍然是自己持有,然后再删除。这通常需要Lua脚本。
-
Java代码示例 (使用
RedisTemplate
):import org.springframework.beans.factory.annotation.Autowired; import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.data.redis.core.script.DefaultRedisScript; import org.springframework.stereotype.Component;import java.time.Duration; import java.util.Collections; import java.util.UUID; import java.util.concurrent.TimeUnit;@Component public class SimpleRedisLock {@Autowiredprivate StringRedisTemplate redisTemplate;// Lua脚本,用于安全地释放锁// KEYS[1]: 锁的key// ARGV[1]: 锁的value (持有者标识)// 逻辑: 如果 GET KEYS[1] 的值等于 ARGV[1],说明锁还是当前持有者持有,执行 DEL KEYS[1] 并返回1;否则返回0。private static final String RELEASE_LOCK_LUA_SCRIPT ="if redis.call('get', KEYS[1]) == ARGV[1] then " +"return redis.call('del', KEYS[1]) " +"else return 0 end";private static final DefaultRedisScript<Long> RELEASE_LOCK_SCRIPT =new DefaultRedisScript<>(RELEASE_LOCK_LUA_SCRIPT, Long.class);/*** 尝试获取锁 (非阻塞)* @param lockKey 锁的键* @param holderId 锁持有者的唯一标识 (例如 UUID)* @param expireTime 锁的过期时间* @param unit 时间单位* @return true 如果获取成功, false 如果获取失败*/public boolean tryLock(String lockKey, String holderId, long expireTime, TimeUnit unit) {Boolean success = redisTemplate.opsForValue().setIfAbsent(lockKey,holderId,Duration.ofMillis(unit.toMillis(expireTime)) // 使用 Duration 设置过期时间);// setIfAbsent 返回 true 表示设置成功 (获取锁成功)return Boolean.TRUE.equals(success);}/*** 释放锁 (必须传入获取锁时使用的 holderId)* @param lockKey 锁的键* @param holderId 锁持有者的唯一标识* @return true 如果释放成功, false 如果锁不存在或持有者不匹配*/public boolean unlock(String lockKey, String holderId) {Long result = redisTemplate.execute(RELEASE_LOCK_SCRIPT,Collections.singletonList(lockKey), // KEYS 参数列表holderId // ARGV 参数列表);// Lua脚本返回 1 表示删除成功return Long.valueOf(1L).equals(result);}// 使用示例public void performLockedOperation(String resourceId) {String lockKey = "lock:resource:" + resourceId;String holderId = UUID.randomUUID().toString(); // 每个请求生成唯一IDlong expireTimeMillis = 30000; // 锁过期时间30秒boolean locked = false;try {locked = tryLock(lockKey, holderId, expireTimeMillis, TimeUnit.MILLISECONDS);if (locked) {System.out.println("线程 " + Thread.currentThread().getName() + " 获取锁成功: " + lockKey);// --- 执行需要互斥保护的业务逻辑 ---System.out.println("线程 " + Thread.currentThread().getName() + " 正在执行业务...");Thread.sleep(1000); // 模拟业务耗时System.out.println("线程 " + Thread.currentThread().getName() + " 业务执行完毕");// ---------------------------------} else {System.out.println("线程 " + Thread.currentThread().getName() + " 获取锁失败: " + lockKey);// 获取锁失败,可以根据业务决定是等待、重试还是直接返回错误}} catch (InterruptedException e) {Thread.currentThread().interrupt();System.err.println("线程被中断");} finally {if (locked) {boolean unlocked = unlock(lockKey, holderId);if (unlocked) {System.out.println("线程 " + Thread.currentThread().getName() + " 释放锁成功: " + lockKey);} else {// 释放失败,可能是锁已过期被自动删除,或者锁被别人持有(理论上安全释放脚本会处理)System.err.println("线程 " + Thread.currentThread().getName() + " 释放锁失败或锁已不属于自己: " + lockKey);}}}} }
理解帮助:
setIfAbsent(key, value, duration)
: 这是RedisSET key value NX PX milliseconds
命令的Java实现,保证了设置key和设置过期时间这两个操作的原子性。如果分开执行SETNX
和EXPIRE
,在它们之间发生崩溃可能导致锁永不释放。holderId
: 必须是唯一的,用来标识当前锁的持有者。Lua脚本
:redisTemplate.execute(script, keys, args...)
用于执行Lua脚本。Lua脚本在Redis服务器端原子执行,是解决“检查锁归属并删除”这类复合操作原子性的关键。- 锁续期问题 (Watchdog): 上述简易锁有一个潜在问题:如果业务执行时间超过了锁的
expireTime
,锁会自动释放,其他线程可能获取到锁,导致并发执行。对于执行时间不确定的业务,需要一种“看门狗”(Watchdog)机制,在锁持有期间定期延长锁的过期时间。手动实现比较复杂。
进阶:保证原子性和锁续期 (Lua脚本)
虽然 setIfAbsent
解决了获取锁的原子性,安全释放锁也通过Lua解决,但锁续期问题仍然存在。我们可以通过更复杂的Lua脚本或客户端库来解决。
工业级方案:Redisson
Redisson 是一个功能丰富的Java Redis客户端,它提供了对分布式锁的完善封装,内置了锁续期(看门狗)机制和可重入特性。推荐在生产环境中使用。
-
添加依赖 (Maven):
<dependency><groupId>org.redisson</groupId><artifactId>redisson-spring-boot-starter</artifactId><!-- 使用合适的版本 --><version>3.17.7</version> <!-- 示例版本,请检查最新稳定版 --> </dependency>
-
配置 Redisson (application.yml/properties):
spring:redis:host: localhostport: 6379# password: yourpassword # 如果有密码 # Redisson 配置 (如果需要更复杂的配置,例如集群、哨兵) # redisson: # config: classpath:redisson-config.yaml
Redisson Spring Boot Starter 会自动配置
RedissonClient
bean。 -
使用
RLock
:import org.redisson.api.RLock; import org.redisson.api.RedissonClient; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service;import java.util.concurrent.TimeUnit;@Service public class RedissonLockService {@Autowiredprivate RedissonClient redissonClient;/*** 使用 Redisson 执行加锁操作* @param resourceId 资源ID,用于生成锁的键*/public void performLockedOperationWithRedisson(String resourceId) {String lockKey = "redisson:lock:" + resourceId;RLock lock = redissonClient.getLock(lockKey); // 获取RLock对象boolean locked = false;try {// 尝试获取锁// tryLock(long waitTime, long leaseTime, TimeUnit unit)// waitTime: 最多等待获取锁的时间。如果为0,尝试一次就返回。如果小于0,则一直等待。// leaseTime: 持有锁的时间(锁的有效时间)。如果为-1,则启用看门狗机制(默认30秒,可配置)。// unit: 时间单位// 示例1: 尝试获取锁,最多等待10秒,如果获取成功,则持有锁30秒 (到期自动释放)// locked = lock.tryLock(10, 30, TimeUnit.SECONDS);// 示例2: 尝试获取锁,最多等待5秒,如果获取成功,则启用看门狗(默认每10秒续期一次,锁总时长30秒)locked = lock.tryLock(5, -1, TimeUnit.SECONDS); // 推荐方式,利用看门狗// 示例3: 阻塞式获取锁,一直等待直到获取成功,启用看门狗// lock.lock(); // 相当于 tryLock(-1, -1, unit)// locked = true; // 如果lock()没有抛异常,说明获取成功if (locked) {System.out.println("线程 " + Thread.currentThread().getName() + " 获取 Redisson 锁成功: " + lockKey);// --- 执行需要互斥保护的业务逻辑 ---System.out.println("线程 " + Thread.currentThread().getName() + " 正在执行业务...");// 模拟一个较长的业务耗时,测试看门狗Thread.sleep(40000); // 假设业务需要40秒 > 默认30秒leaseTimeSystem.out.println("线程 " + Thread.currentThread().getName() + " 业务执行完毕");// ---------------------------------} else {System.out.println("线程 " + Thread.currentThread().getName() + " 获取 Redisson 锁失败: " + lockKey);}} catch (InterruptedException e) {Thread.currentThread().interrupt();System.err.println("线程被中断");} finally {// 必须检查当前线程是否仍然持有锁再释放if (locked && lock.isHeldByCurrentThread()) {lock.unlock();System.out.println("线程 " + Thread.currentThread().getName() + " 释放 Redisson 锁: " + lockKey);}}} }
理解帮助 (Redisson
RLock
):getLock(key)
: 获取一个RLock
实例,它与 Redis 中的一个 key 关联。tryLock(waitTime, leaseTime, unit)
:waitTime
: 决定了尝试获取锁的行为是阻塞的(>0 或 <0)还是非阻塞的 (0)。leaseTime
: 锁的租约时间。关键在于-1
,当leaseTime
为 -1 时,Redisson 会启动看门狗 (Watchdog) 机制。
- 看门狗 (Watchdog): 当
leaseTime
为 -1 时,获取锁成功后,Redisson 会在后台启动一个定时任务(默认每隔leaseTime / 3
时间,例如默认30s / 3 = 10s
)去检查持有锁的线程是否还在运行。如果还在运行,就自动将锁的过期时间重置为leaseTime
(例如重置为30秒)。这样只要业务线程还在执行,锁就不会过期。当业务执行完毕调用unlock()
时,看门狗会停止。如果持有锁的客户端崩溃,看门狗自然停止,锁会在最后一个leaseTime
到期后自动释放。 - 可重入 (Reentrant): 同一个线程可以多次获取同一个
RLock
而不会被自己阻塞,释放锁时需要调用相应次数的unlock()
。Redisson 内部通过 Redis 的 Hash 结构记录了锁的持有线程和重入次数。 isHeldByCurrentThread()
: 在finally
块中释放锁前,最好检查一下当前线程是否确实还持有该锁,避免异常情况下错误释放。unlock()
: 释放锁。如果是可重入锁,会减少重入计数,计数为0时才真正删除Redis中的锁。
分布式锁的最佳实践与注意事项
- 锁的粒度: 锁定的范围(
lockKey
)应该尽可能小,只保护必要的共享资源,以提高并发性。避免使用过于宽泛的锁。 - 锁的超时时间 (
leaseTime
): 必须设置!防止死锁。leaseTime
应大于业务正常执行时间,但也不能太长,以免资源被无效占用。使用 Redisson 的看门狗是最佳实践。 - 唯一持有者标识: 用于安全释放锁,防止误删。Redisson 内部自动处理。
- 获取锁失败的处理: 应用需要明确失败后的策略:是立即返回错误、等待一段时间后重试(注意控制重试次数和间隔)、还是将请求放入队列稍后处理?
- 可重入性: 根据业务场景判断是否需要可重入锁。Redisson 默认提供可重入锁。
- 公平性: Redisson 也提供公平锁 (
getFairLock
),它会按照请求锁的顺序来授予锁,但性能通常低于非公平锁。 - Redis 故障:
- 单点 Redis: 如果 Redis 宕机,所有锁服务都不可用。
- 主从 Redis: 如果 Master 宕机,发生切换,可能存在锁丢失问题(Master 持有的锁未同步到 Slave)。
- RedLock 算法: 为了解决主从切换时的锁安全性问题,Redis 作者提出了 RedLock 算法,需要向多个独立的 Redis 实例(通常是奇数个,如5个)申请锁,大部分实例(如3个)成功才算获取锁成功。Redisson 也提供了
RedissonRedLock
实现。但 RedLock 实现复杂,且仍有争议,除非对锁的可靠性有极端要求,否则通常使用带看门狗的单实例或主从/哨兵模式下的RLock
已足够。
何时使用? 当需要在多个进程或服务实例间对共享资源进行互斥访问时,分布式锁是必要的。例如:防止并发扣减库存超卖、保证定时任务只有一个实例执行、限制某些操作的并发数等。
原子操作:Redis的内功
原子操作是指不可被中断的一个或一系列操作。在并发环境下,原子操作可以确保数据修改的完整性,避免了“读-改-写”过程中被其他线程干扰的问题。Redis 提供了多种原子操作能力。
Redis原生原子命令 (INCRBY
/DECRBY
)
对于简单的数值增减,Redis 提供了原生的原子命令。
INCR key
: 将 key 存储的数字值增一。如果 key 不存在,先初始化为 0 再执行 INCR。返回增一后的值。DECR key
: 将 key 存储的数字值减一。如果 key 不存在,先初始化为 0 再执行 DECR。返回减一后的值。INCRBY key increment
: 将 key 存储的数字值增加increment
。返回增加后的值。DECRBY key decrement
: 将 key 存储的数字值减少decrement
。返回减少后的值。
这些命令都是原子执行的,即使多个客户端同时对同一个 key 执行 INCRBY
,Redis 内部也会保证它们的效果是串行叠加的,不会丢失更新。
-
Java代码示例 (使用
RedisTemplate
):import org.springframework.beans.factory.annotation.Autowired; import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.stereotype.Service;@Service public class RedisAtomicCounter {@Autowiredprivate StringRedisTemplate redisTemplate;private static final String STOCK_KEY_PREFIX = "product:stock:";/*** 初始化或设置库存 (非原子,仅用于准备数据)* @param productId 商品ID* @param stock 初始库存*/public void setStock(Long productId, int stock) {redisTemplate.opsForValue().set(STOCK_KEY_PREFIX + productId, String.valueOf(stock));}/*** 获取当前库存 (非原子读)* @param productId 商品ID* @return 当前库存,如果不存在则返回null或处理异常*/public Integer getStock(Long productId) {String stockStr = redisTemplate.opsForValue().get(STOCK_KEY_PREFIX + productId);return (stockStr != null) ? Integer.parseInt(stockStr) : null;}/*** 原子性地扣减库存* @param productId 商品ID* @param quantity 要扣减的数量* @return 扣减后的库存值。如果 key 不存在或非数值,会报错。* 注意:这里没有预先检查库存是否足够,可能导致库存变为负数。*/public Long deductStockAtomic(Long productId, int quantity) {// DECRBY 命令是原子的return redisTemplate.opsForValue().decrement(STOCK_KEY_PREFIX + productId, quantity);}/*** 原子性地增加库存* @param productId 商品ID* @param quantity 要增加的数量* @return 增加后的库存值。*/public Long increaseStockAtomic(Long productId, int quantity) {// INCRBY 命令是原子的return redisTemplate.opsForValue().increment(STOCK_KEY_PREFIX + productId, quantity);}// 示例:尝试扣减库存,但需要先检查是否足够 (非完全原子)public boolean tryDeductStock(Long productId, int quantity) {String key = STOCK_KEY_PREFIX + productId;// 注意:这里的 get 和 decrement 不是原子组合// 可能在 get 之后,decrement 之前,库存被其他线程修改String currentStockStr = redisTemplate.opsForValue().get(key);if (currentStockStr != null) {int currentStock = Integer.parseInt(currentStockStr);if (currentStock >= quantity) {// 尝试扣减Long remainingStock = redisTemplate.opsForValue().decrement(key, quantity);// 再次检查扣减后是否为负 (可能由于并发导致过度扣减)if (remainingStock != null && remainingStock >= 0) {System.out.println("线程 " + Thread.currentThread().getName() + ": 原子扣减成功,商品 " + productId + " 剩余 " + remainingStock);return true;} else {// 扣减后库存不足 (变为负数),需要回滚 (原子增加回去)redisTemplate.opsForValue().increment(key, quantity); // 补偿System.out.println("线程 " + Thread.currentThread().getName() + ": 原子扣减失败 (结果为负),已回滚,商品 " + productId);return false;}} else {System.out.println("线程 " + Thread.currentThread().getName() + ": 库存不足 (预检查),商品 " + productId);return false; // 库存不足}} else {System.out.println("线程 " + Thread.currentThread().getName() + ": 商品库存键不存在 " + productId);return false; // 商品不存在}} }
理解帮助:
increment()
和decrement()
方法直接映射到 Redis 的INCRBY
/DECRBY
命令,它们本身是原子的。- 注意
tryDeductStock
方法中的问题:get()
读取库存和decrement()
扣减库存是两个独立的网络请求,它们之间不是原子的。在高并发下,可能多个线程都读取到足够的库存,然后都执行了decrement
,导致超卖(库存变为负数)。虽然事后检查并补偿(increment
回去)可以纠正单个操作,但这种“先检查后操作”的模式在并发下是不可靠的。我们需要将“检查库存并扣减”合并成一个原子操作。
利用Lua脚本实现复杂原子操作
为了解决上述“检查并操作”的原子性问题,我们可以使用 Lua 脚本。Redis 会保证整个 Lua 脚本的执行是原子的。
-
Lua脚本 (检查库存并扣减):
-- KEYS[1]: 库存的 key (例如 "product:stock:123") -- ARGV[1]: 需要扣减的数量 (quantity)-- 获取当前库存,如果不存在则视为 0 local current_stock = tonumber(redis.call('get', KEYS[1]) or '0') local quantity_to_deduct = tonumber(ARGV[1])-- 检查库存是否足够 if current_stock >= quantity_to_deduct then-- 库存足够,执行扣减 (原子地)local remaining_stock = redis.call('decrby', KEYS[1], quantity_to_deduct)-- 返回 1 表示成功,或者返回剩余库存也可以return 1 else-- 库存不足,返回 0 表示失败return 0 end
-
Java代码 (使用
RedisTemplate
执行 Lua):import org.springframework.beans.factory.annotation.Autowired; import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.data.redis.core.script.DefaultRedisScript; import org.springframework.scripting.support.ResourceScriptSource; // 用于从文件加载脚本 import org.springframework.core.io.ClassPathResource; // 用于加载类路径资源 import org.springframework.stereotype.Service;import java.util.Collections;@Service public class RedisAtomicWithLua {@Autowiredprivate StringRedisTemplate redisTemplate;// 脚本内容可以直接写在字符串里,或者从文件加载private static final String DEDUCT_STOCK_LUA_SCRIPT_STR ="local current_stock = tonumber(redis.call('get', KEYS[1]) or '0')\n" +"local quantity_to_deduct = tonumber(ARGV[1])\n" +"if current_stock >= quantity_to_deduct then\n" +" redis.call('decrby', KEYS[1], quantity_to_deduct)\n" +" return 1\n" + // 返回 1 代表成功"else\n" +" return 0\n" + // 返回 0 代表失败 (库存不足)"end";private static final DefaultRedisScript<Long> DEDUCT_STOCK_SCRIPT =new DefaultRedisScript<>(DEDUCT_STOCK_LUA_SCRIPT_STR, Long.class);// 也可以从 .lua 文件加载脚本 (推荐)// 假设在 src/main/resources/scripts/deduct_stock.lua/*private static final DefaultRedisScript<Long> DEDUCT_STOCK_SCRIPT_FROM_FILE;static {DEDUCT_STOCK_SCRIPT_FROM_FILE = new DefaultRedisScript<>();DEDUCT_STOCK_SCRIPT_FROM_FILE.setScriptSource(new ResourceScriptSource(new ClassPathResource("scripts/deduct_stock.lua")));DEDUCT_STOCK_SCRIPT_FROM_FILE.setResultType(Long.class);}*/private static final String STOCK_KEY_PREFIX = "product:stock:";/*** 使用Lua脚本原子性地检查并扣减库存* @param productId 商品ID* @param quantity 要扣减的数量* @return true 如果扣减成功, false 如果库存不足*/public boolean deductStockWithLua(Long productId, int quantity) {String stockKey = STOCK_KEY_PREFIX + productId;Long result = redisTemplate.execute(DEDUCT_STOCK_SCRIPT, // 要执行的脚本Collections.singletonList(stockKey), // KEYS 参数列表String.valueOf(quantity) // ARGV 参数列表);// Lua 脚本返回 1 表示成功,0 表示失败if (Long.valueOf(1L).equals(result)) {System.out.println("线程 " + Thread.currentThread().getName() + ": Lua扣减成功,商品 " + productId);return true;} else {System.out.println("线程 " + Thread.currentThread().getName() + ": Lua扣减失败 (库存不足),商品 " + productId);return false;}}// ... (setStock, getStock 方法同上) ... }
理解帮助:
- Lua 脚本在 Redis 服务器端原子执行,不会被其他命令插入。
redis.call()
用于在 Lua 脚本中调用 Redis 命令。- 脚本将“读取当前值”、“比较”、“条件执行扣减”这三个步骤合并成了一个不可分割的操作。
redisTemplate.execute(script, keys, args...)
是执行 Lua 脚本的标准方式。keys
对应脚本中的KEYS[n]
,args
对应ARGV[n]
。- 这种方式完美解决了
tryDeductStock
方法中的并发问题,是秒杀场景下扣减 Redis 库存的常用方案。
Redis事务 (WATCH
/MULTI
/EXEC
)
Redis 也提供了基础的事务功能,但它与关系型数据库的事务(ACID)不同。Redis 事务主要提供命令打包和乐观锁(通过 WATCH
)的能力。
-
WATCH key [key ...]
: 监视一个或多个 key。如果在MULTI
执行前,任何被WATCH
的 key 被其他命令修改,那么整个事务将被取消,EXEC
返回nil
。 -
MULTI
: 标记事务块的开始。后续命令会进入队列,但不会立即执行。 -
EXEC
: 原子地执行所有在MULTI
后入队的命令。返回一个包含所有命令执行结果的列表。 -
DISCARD
: 取消事务,清空命令队列,并取消WATCH
。 -
UNWATCH
: 取消对所有 key 的WATCH
。 -
使用场景 (模拟CAS):
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.dao.DataAccessException; import org.springframework.data.redis.core.RedisOperations; import org.springframework.data.redis.core.SessionCallback; import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.stereotype.Service;import java.util.List;@Service public class RedisTransactionCAS {@Autowiredprivate StringRedisTemplate redisTemplate;private static final String STOCK_KEY_PREFIX = "product:stock:";/*** 使用 WATCH/MULTI/EXEC 实现 CAS 扣减库存* @param productId 商品ID* @param quantity 要扣减的数量* @return true 如果成功, false 如果失败 (库存不足或冲突)*/public boolean deductStockWithWatch(Long productId, int quantity) {String stockKey = STOCK_KEY_PREFIX + productId;// SessionCallback 允许在同一个 Redis 连接上执行多个操作List<Object> results = redisTemplate.execute(new SessionCallback<List<Object>>() {@Overridepublic List<Object> execute(RedisOperations operations) throws DataAccessException {// 1. WATCH 库存 keyoperations.watch(stockKey);// 2. 读取当前库存 (在 WATCH 之后)Object stockValue = operations.opsForValue().get(stockKey);if (stockValue == null) {System.out.println("商品不存在或库存未初始化: " + productId);operations.unwatch(); // 取消监视return null; // 表示失败}int currentStock = Integer.parseInt(stockValue.toString());// 3. 检查库存if (currentStock < quantity) {System.out.println("库存不足: " + productId);operations.unwatch(); // 取消监视return null; // 表示失败}// 4. 开启事务operations.multi();// 5. 在事务中设置新库存operations.opsForValue().decrement(stockKey, quantity); // 使用 decrement 更简洁// 6. 执行事务// 如果在 watch 之后,EXEC 执行之前,stockKey 被其他客户端修改了,// EXEC 会返回 null,表示事务失败。// 如果没有被修改,EXEC 会返回一个列表,包含事务中每个命令的结果。// 对于 decrement,结果是操作后的值。return operations.exec();}});// 7. 检查事务执行结果if (results != null && !results.isEmpty()) {// results 不为 null 表示事务成功执行 (WATCH 的 key 未被修改)// 可以在这里检查 results.get(0) 的值 (即 decrement 的结果) 是否符合预期,例如 >= 0System.out.println("线程 " + Thread.currentThread().getName() + ": WATCH/MULTI/EXEC 扣减成功,商品 " + productId + ",事务结果: " + results);return true;} else {// results 为 null 表示事务失败 (发生 WATCH 冲突)System.out.println("线程 " + Thread.currentThread().getName() + ": WATCH/MULTI/EXEC 扣减失败 (冲突或业务逻辑失败),商品 " + productId);// 可以选择重试return false;}}// ... (setStock, getStock 方法同上) ... }
理解帮助:
WATCH
提供了乐观锁机制。它监视 key,如果在事务执行 (EXEC
) 前被修改,事务就失败。MULTI
到EXEC
之间的命令被打包发送给 Redis,然后原子执行。- 与 Lua 的比较:
- Lua 脚本通常性能更好,因为它在服务器端执行,减少了网络往返。
- Lua 脚本的逻辑可以更复杂,而
WATCH
/MULTI
/EXEC
更侧重于简单的事务打包和基于WATCH
的 CAS。 WATCH
机制在高并发冲突严重时,可能导致大量事务失败和重试,性能可能不如 Lua。
- 对于需要“检查并原子更新”的场景,Lua 脚本通常是更推荐的方式。
原子操作的适用场景
- 计数器: 网站访问计数、用户积分增减、限流计数等。
- 库存扣减: 在秒杀、抢购等高并发场景下,使用 Lua 脚本原子地检查和扣减 Redis 中的库存是核心技术。
- 简单状态标记: 原子地设置或检查某个标志位。
- 分布式ID生成: 利用
INCR
生成序列号。
原子操作是 Redis 在并发控制方面的强大武器,善用它可以构建出高性能且数据一致的系统。
异步处理:削峰填谷,顺序执行
在高并发写入场景下,即使使用了乐观锁或原子操作,如果瞬时请求量超过系统的处理能力,仍然可能导致性能瓶颈或大量失败/重试。异步处理,特别是利用消息队列(Message Queue, MQ),是一种有效的“削峰填谷”和保证最终一致性的策略。
基于消息队列的异步更新模式
核心思想:将需要保证顺序或可能产生冲突的写操作,不直接执行,而是封装成消息发送到消息队列中。由后端配置的消费者(可以是一个或有限个)从队列中按顺序拉取消息并执行实际的数据库或缓存更新。
-
场景:订单创建与库存扣减
- 用户下单请求到达 Web 服务。
- Web 服务执行快速的操作:创建订单记录(状态可能为“处理中”),并将一个“扣减库存”的消息(包含订单ID、商品ID、数量等信息)发送到 MQ(例如 RabbitMQ 的特定队列)。
- Web 服务立即响应用户“下单成功,正在处理”。
- 库存服务(一个独立的消费者进程或线程池)监听该 MQ 队列。
- 库存服务按顺序从队列中取出“扣减库存”消息。
- 库存服务执行实际的库存扣减逻辑(可以使用数据库悲观锁、乐观锁或 Redis Lua 原子操作来保证单次扣减的准确性)。由于消费者是按顺序处理同一个商品的消息(如果队列设计得当),避免了 Web 层直接并发扣减库存的冲突。
- 库存服务更新订单状态为“已扣减库存”或相应状态。
-
Java代码示例 (概念性,使用 Spring AMQP for RabbitMQ):
// --- Web 服务 (生产者) --- import org.springframework.amqp.rabbit.core.RabbitTemplate; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service;// 假设 Order 和 StockDeductionMessage 是简单的 POJO // class StockDeductionMessage { Long orderId; Long productId; int quantity; }@Service public class OrderService {@Autowiredprivate OrderRepository orderRepository; // 假设用于保存订单@Autowiredprivate RabbitTemplate rabbitTemplate; // Spring AMQP 提供的模板private static final String EXCHANGE_NAME = "order.exchange";private static final String ROUTING_KEY = "stock.deduct";public Order createOrderAsync(User user, Long productId, int quantity) {// 1. 创建订单 (状态: PROCESSING)Order order = new Order();order.setUserId(user.getId());order.setProductId(productId);order.setQuantity(quantity);order.setStatus("PROCESSING");Order savedOrder = orderRepository.save(order);// 2. 构建扣库存消息StockDeductionMessage message = new StockDeductionMessage(savedOrder.getId(), productId, quantity);// 3. 发送消息到 RabbitMQ// convertAndSend 会自动序列化对象 (如JSON)rabbitTemplate.convertAndSend(EXCHANGE_NAME, ROUTING_KEY, message);System.out.println("订单创建成功,发送扣库存消息: OrderId=" + savedOrder.getId());return savedOrder; // 立即返回给用户} }// --- 库存服务 (消费者) --- import org.springframework.amqp.rabbit.annotation.RabbitListener; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Component;@Component public class StockConsumer {@Autowiredprivate ProductService productService; // 包含实际扣库存逻辑的服务// 监听名为 "stock-deduction-queue" 的队列// 需要在 RabbitMQ 中预先定义好 Exchange, Queue 以及它们的绑定关系@RabbitListener(queues = "stock-deduction-queue")public void handleStockDeduction(StockDeductionMessage message) {System.out.println("收到扣库存消息: OrderId=" + message.getOrderId() +", ProductId=" + message.getProductId() +", Quantity=" + message.getQuantity());try {// 执行实际的扣库存操作// 这里可以使用前面介绍的任何一种并发控制方法// 例如,使用 Redis Lua 脚本boolean success = productService.deductStockWithLua(message.getProductId(), message.getQuantity());if (success) {System.out.println("库存扣减成功 for OrderId=" + message.getOrderId());// 更新订单状态为 "STOCK_DEDUCTED" 或类似状态 (可能需要调用订单服务或直接操作数据库)// updateOrderStatus(message.getOrderId(), "STOCK_DEDUCTED");} else {System.err.println("库存扣减失败 (库存不足) for OrderId=" + message.getOrderId());// 库存不足,需要处理,例如:// 1. 更新订单状态为 "STOCK_FAILED"// 2. 触发退款流程 (如果已支付)// 3. 发送通知给用户或运营// updateOrderStatus(message.getOrderId(), "STOCK_FAILED");}} catch (Exception e) {System.err.println("处理扣库存消息时发生异常 for OrderId=" + message.getOrderId() + ": " + e.getMessage());// 异常处理:// 1. 记录日志// 2. 根据配置决定是否重试 (MQ通常支持自动重试和死信队列)// 3. 更新订单状态为 "ERROR"// 4. 人工介入// 考虑幂等性:如果消息被重试,需要确保扣库存操作是幂等的// (例如,检查订单状态是否已扣减,或使用唯一业务ID防重)// updateOrderStatus(message.getOrderId(), "ERROR");// 可以考虑抛出特定异常让MQ进行重试或进入死信队列// throw new AmqpRejectAndDontRequeueException("处理失败,进入死信队列");}}// ... updateOrderStatus 等辅助方法 ... }
实现考量
- 消息队列的选择: RabbitMQ, Kafka, RocketMQ 等各有特点。RabbitMQ 对消息顺序性(单一队列内)支持较好,适合此类场景。Kafka 吞吐量高,但保证分区内有序,全局有序需特殊设计。
- 消息顺序性: 如果需要严格保证同一商品的库存操作按顺序执行,需要将同一商品的消息路由到同一个队列或同一个分区,并且由单个消费者线程处理该队列/分区。可以通过商品ID计算哈希值来决定路由。
- 消费者并发: 如果希望提高处理速度,可以启动多个消费者实例,但需要确保同一商品的消息总是被同一个消费者处理(如使用一致性哈希路由)。
- 幂等性 (Idempotency): 网络问题或消费者处理失败可能导致消息被重复投递。消费者必须设计成幂等的,即多次处理同一条消息的效果与处理一次相同。常见方法:
- 在数据库中记录已处理的消息ID或业务ID,处理前检查。
- 利用数据库唯一约束。
- 执行操作前检查目标状态是否已经是最终状态(如订单状态是否已是“已扣减库存”)。
- 事务消息: 确保“业务操作成功”和“消息发送成功”这两个步骤要么都成功,要么都失败。
- 本地消息表 (常用): 1. 启动本地数据库事务。 2. 执行业务操作(如创建订单)。 3. 将消息内容插入本地“消息表”(状态:待发送)。 4. 提交本地事务。 5. (事务外) 尝试发送消息到MQ。 6. 如果发送成功,更新本地消息表状态为“已发送”。 7. (后台任务) 定期扫描本地消息表中“待发送”的消息,重新发送,直到成功。
- MQ提供的事务消息 (如 RocketMQ): 利用MQ自身的两阶段提交协议。
- 最终一致性: 异步处理通常实现的是最终一致性。从用户下单到库存最终扣减完成会有一个时间窗口,系统在此期间可能处于中间状态。需要业务上能接受这种延迟。
- 失败处理与补偿: 消费者处理失败(如库存不足、数据库异常)需要有明确的处理流程,如记录日志、发送告警、更新订单状态、触发补偿逻辑(如退款)或将消息移入死信队列等待人工处理。
何时使用? 当系统需要处理高并发写入,允许一定的延迟以换取更高的吞吐量和系统稳定性时,异步处理是绝佳选择。特别适用于:秒杀下单(将扣库存、生成流水等后续操作异步化)、日志收集、发送邮件/短信通知等场景。
缓存一致性模式与策略
在使用 Redis 作为缓存加速数据库访问时,如何保证缓存和数据库之间的数据一致性是核心挑战,尤其是在并发更新下。
Cache-Aside Pattern (旁路缓存)
这是最常用、最经典的缓存模式。应用程序逻辑需要同时维护缓存和数据库。
-
读操作:
- 应用先尝试从缓存(Redis)读取数据。
- 如果缓存命中(Cache Hit),直接返回数据。
- 如果缓存未命中(Cache Miss):
a. 应用从数据库(DB)读取数据。
b. 将从数据库读取到的数据写入缓存(Redis)。
c. 返回数据。
-
写操作 (更新/删除): 这里存在两种主要策略:
-
策略一:先更新数据库,再删除缓存 (Update DB, then Delete Cache) (推荐)
- 应用将新数据写入数据库。
- 成功写入数据库后,应用删除缓存中对应的条目。
-
策略二:先删除缓存,再更新数据库 (Delete Cache, then Update DB) (不推荐,易出问题)
- 应用先删除缓存中的条目。
- 应用将新数据写入数据库。
-
策略三:先更新数据库,再更新缓存 (Update DB, then Update Cache) (一般不推荐)
- 应用将新数据写入数据库。
- 成功写入数据库后,应用将新数据写入缓存。
-
-
为什么推荐“先更新DB,再删除Cache”?
- 相对简单高效: 删除操作通常比更新缓存更快,且能避免缓存存储脏数据(如果更新缓存失败)。
- 懒加载: 删除缓存后,下次读取时会自然地从数据库加载最新数据到缓存 (Cache Miss -> Load from DB -> Write to Cache)。
- 并发问题相对较小:
- 场景1 (极小概率):
- 线程A更新数据库。
- 线程B读取,缓存未命中。
- 线程B从数据库读取到旧值。
- 线程A删除缓存。
- 线程B将旧值写入缓存。 (导致缓存是旧值)
- 分析: 这个问题的发生窗口非常短(数据库读操作在数据库写和缓存删之间)。而且即使发生,也只是暂时的不一致,下次缓存过期或再次被删除后会恢复。可以通过设置较短的缓存过期时间来缓解。
- 场景2 (删除缓存失败): 如果更新数据库成功,但删除缓存失败,会导致数据库是新值,缓存是旧值。需要有重试机制来确保缓存最终被删除(例如,使用消息队列异步删除)。
- 场景1 (极小概率):
-
为什么不推荐“先删除Cache,再更新DB”?
- 主要问题 (高概率):
- 线程A删除缓存。
- 线程B读取,缓存未命中。
- 线程B从数据库读取到旧值。
- 线程A将新值写入数据库。
- 线程B将它之前读取到的旧值写入缓存。
- 结果: 数据库是新值,缓存是旧值,并且这个不一致状态会持续到缓存过期或下次更新。在高并发读写场景下,这个问题非常容易出现。
- 主要问题 (高概率):
-
为什么一般不推荐“先更新DB,再更新Cache”?
- 写两次开销: 需要同时维护数据库和缓存的写入。
- 缓存写入失败: 如果更新DB成功,更新缓存失败,仍然导致不一致。
- 并发问题 (线程安全):
- 线程A更新DB (值 V1)。
- 线程B更新DB (值 V2)。
- 线程B更新缓存 (值 V2)。
- 线程A更新缓存 (值 V1)。
- 结果: 数据库是V2,缓存是V1,数据不一致。虽然可以通过加分布式锁解决并发写缓存的问题,但这增加了复杂性和性能开销。删除缓存通常更简单。
读/写穿透 (Read/Write-Through)
在这种模式下,应用程序只与缓存交互。缓存服务自身负责与后端数据库进行数据的读取和写入同步。
-
读穿透 (Read-Through): 应用向缓存请求数据。如果缓存有,直接返回。如果缓存没有,缓存服务自动从数据库加载数据,存入缓存,然后返回给应用。对应用来说,数据库是透明的。
-
写穿透 (Write-Through): 应用向缓存写入数据。缓存服务先将数据写入缓存,然后同步将数据写入后端数据库。只有当缓存和数据库都写入成功后,才算操作完成。
-
优点: 应用逻辑简单,数据一致性较好(写操作是同步的)。
-
缺点:
- 写性能较低: 每次写操作都需要等待缓存和数据库都完成,增加了延迟。
- 实现复杂: 需要缓存提供者(或自定义代理层)支持该模式。通用缓存如 Redis 本身不直接提供完整的 Read/Write-Through 功能,通常需要结合其他框架或自行封装。
写回 (Write-Back)
也称为 Write-Behind。应用只与缓存交互。
-
写操作: 应用向缓存写入数据,缓存立即确认写入成功并返回。缓存服务将“脏”数据(已更新但未写入数据库的数据)标记起来,异步地、批量地或延时地将这些数据刷回(Flush)后端数据库。
-
读操作: 类似Read-Through,缓存未命中时从数据库加载。
-
优点: 写性能极高,因为应用无需等待数据库写入。适合写入非常频繁的场景。
-
缺点:
- 数据丢失风险: 如果缓存服务在将脏数据刷回数据库之前宕机,这部分数据就会丢失。需要配合持久化机制(如Redis的AOF/RDB)和高可用方案。
- 一致性延迟: 数据写入数据库存在延迟,期间数据库和缓存可能不一致。
缓存更新与删除策略的选择
在 Cache-Aside 模式下,核心在于写操作如何处理缓存:
-
优先选择:“先更新DB,再删除Cache”
- 这是通用场景下的最佳实践,平衡了简单性、性能和一致性。
- 需要处理“删除缓存失败”的问题,通常通过异步重试解决。
-
如何处理删除失败?
- 消息队列: 更新DB成功后,发送一条“删除缓存”的消息到MQ。由专门的消费者负责从MQ接收消息并执行缓存删除。利用MQ的可靠投递和重试机制确保删除操作最终成功。这是最可靠的方式。
- 定时任务/延迟队列: 将需要删除的缓存key放入延迟队列或由定时任务扫描,在一段时间后尝试删除。
- 订阅数据库变更日志 (Canal + MQ): 使用工具(如Canal)监听MySQL的binlog,当检测到数据变更时,自动产生消息发送到MQ,由消费者根据消息删除相应的缓存。这种方式将缓存同步逻辑与业务代码解耦,更为优雅,但架构复杂度更高。
-
什么情况下可能考虑更新缓存?
- 如果缓存的计算成本非常高,且读请求非常频繁,不希望每次删除后都重新计算。
- 如果能接受“更新DB,再更新Cache”带来的并发问题(例如通过加锁解决,或业务上对短暂不一致不敏感)。
延迟双删与消息队列保证最终一致性
-
延迟双删 (Double Deletion): 为了解决“先删除Cache,再更新DB”模式下的脏数据问题,有人提出了“延迟双删”:
- 先删除缓存。
- 再更新数据库。
- 延迟一段时间(例如几百毫秒或1秒,需要大于读DB+写Cache的典型时间)。
- 再次删除缓存。
- 目的: 通过第二次删除,清理掉在步骤2和步骤3之间可能被写入缓存的脏数据。
- 缺点: 延迟时间难以精确把握;增加了系统复杂度;第二次删除仍可能失败;在高并发下仍不能完全保证一致性。通常不推荐作为首选方案。
-
基于消息队列的最终一致性 (推荐的删除策略保障):
- 流程:
- 应用更新数据库。
- 应用发送一条包含要删除的缓存key的消息到可靠的消息队列。
- (可选)应用可以尝试立即删除一次缓存(尽力而为)。
- MQ消费者接收消息,执行缓存删除。如果失败,MQ负责重试。
- 优点: 将缓存删除操作与主业务流程解耦,利用MQ保证了删除操作的最终执行,是保证“先更新DB,再删除Cache”策略可靠性的常用方法。
- 流程:
-
Java代码示例 (基于MQ的异步删除缓存):
// --- 业务服务 (更新DB并发送消息) --- import org.springframework.amqp.rabbit.core.RabbitTemplate; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional;@Service public class ProductServiceWithCacheEviction {@Autowiredprivate ProductRepository productRepository; // 操作数据库@Autowiredprivate StringRedisTemplate redisTemplate; // 操作Redis缓存@Autowiredprivate RabbitTemplate rabbitTemplate; // 发送MQ消息private static final String CACHE_KEY_PREFIX = "product:cache:";private static final String EVICTION_EXCHANGE = "cache.eviction.exchange";private static final String EVICTION_ROUTING_KEY = "product.evict";@Transactional // 保证数据库更新和消息发送(如果用本地消息表)的原子性public void updateProductAndEvictCache(Product product) {// 1. 更新数据库productRepository.save(product);System.out.println("数据库更新成功: ProductId=" + product.getId());// 2. 发送删除缓存的消息到MQString cacheKey = CACHE_KEY_PREFIX + product.getId();// 发送缓存key作为消息内容rabbitTemplate.convertAndSend(EVICTION_EXCHANGE, EVICTION_ROUTING_KEY, cacheKey);System.out.println("发送缓存删除消息到MQ: Key=" + cacheKey);// (可选) 尝试立即删除一次缓存,减少不一致时间窗口try {redisTemplate.delete(cacheKey);System.out.println("尝试立即删除缓存成功: Key=" + cacheKey);} catch (Exception e) {// 立即删除失败也没关系,MQ会保证最终删除System.err.println("尝试立即删除缓存失败: Key=" + cacheKey + ", Error: " + e.getMessage());}}public Product getProductWithCacheAside(Long productId) {String cacheKey = CACHE_KEY_PREFIX + productId;// 1. 读缓存String cachedProductJson = redisTemplate.opsForValue().get(cacheKey);if (cachedProductJson != null) {System.out.println("缓存命中: Key=" + cacheKey);// 反序列化 (假设使用JSON)return deserializeProduct(cachedProductJson);}// 2. 缓存未命中,读数据库System.out.println("缓存未命中: Key=" + cacheKey);Product productFromDb = productRepository.findById(productId).orElse(null);// 3. 写回缓存if (productFromDb != null) {String productJson = serializeProduct(productFromDb);// 设置缓存和过期时间 (例如5分钟)redisTemplate.opsForValue().set(cacheKey, productJson, Duration.ofMinutes(5));System.out.println("从DB加载并写入缓存: Key=" + cacheKey);} else {// 防止缓存穿透:如果DB不存在,可以缓存一个特殊值(如"NULL")或空对象,并设置较短过期时间// redisTemplate.opsForValue().set(cacheKey, "NULL", Duration.ofSeconds(60));}return productFromDb;}// 序列化和反序列化方法 (简单示例)private String serializeProduct(Product p) { /* ... use Jackson or Gson ... */ return "{\"id\":"+p.getId()+",...}"; }private Product deserializeProduct(String json) { /* ... use Jackson or Gson ... */ return new Product(); } }// --- 缓存清理服务 (MQ消费者) --- import org.springframework.amqp.rabbit.annotation.RabbitListener; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.stereotype.Component;@Component public class CacheEvictionConsumer {@Autowiredprivate StringRedisTemplate redisTemplate;@RabbitListener(queues = "cache-eviction-queue") // 监听删除缓存的队列public void handleCacheEviction(String cacheKey) { // 接收消息内容 (缓存key)System.out.println("收到缓存删除任务: Key=" + cacheKey);try {Boolean deleted = redisTemplate.delete(cacheKey);if (Boolean.TRUE.equals(deleted)) {System.out.println("缓存删除成功: Key=" + cacheKey);} else {// Key 可能已被删除或不存在System.out.println("缓存删除操作完成 (Key不存在或已被删除): Key=" + cacheKey);}} catch (Exception e) {// Redis 操作异常,MQ应该配置重试System.err.println("删除缓存时发生Redis异常: Key=" + cacheKey + ", Error: " + e.getMessage());// 抛出异常,让MQ重试throw new RuntimeException("Failed to delete cache key: " + cacheKey, e);}} }
总结缓存一致性策略:
- 首选 Cache-Aside 模式。
- 写策略优先选择“先更新DB,再删除Cache”。
- 必须处理“删除Cache失败”的情况,推荐使用消息队列异步重试删除。
- 了解其他模式(Read/Write-Through, Write-Back)的优缺点,在特定场景下可能适用,但通常更复杂或有数据丢失风险。
6. 如何选择合适的策略?
我们已经探讨了多种解决并发更新和数据一致性问题的策略,从悲观锁到乐观锁,从分布式锁到原子操作,再到异步处理和缓存一致性模式。那么,在实际项目中,面对具体问题该如何选择呢?
这通常需要一个权衡(Trade-off) 的过程,考虑以下几个关键因素:
-
一致性要求 (Consistency Requirement):
- 强一致性 (Strong Consistency): 要求任何时刻读取到的数据都是最新的。如果业务绝对不能容忍任何短暂的不一致(如金融交易的核心记账),可能需要悲观锁、强同步的写穿透缓存、或分布式事务(2PC/TCC,但复杂度高)。
- 最终一致性 (Eventual Consistency): 允许系统在一段时间内存在数据不一致,但保证最终会达到一致状态。如果业务能容忍短暂延迟(如商品库存同步、用户信息更新),乐观锁、原子操作、基于MQ的异步处理、“Update DB then Delete Cache” + MQ重试等策略是很好的选择。
-
并发冲突的激烈程度:
- 冲突频繁 (High Contention): 如秒杀场景,大量请求争抢同一资源。
- 悲观锁会导致严重性能瓶颈。
- 乐观锁会因大量重试而失效。
- Redis原子操作 (Lua脚本) + 消息队列异步处理 是应对秒杀库存等场景的常用组合拳。先用原子操作快速处理内存中的预扣减,然后通过MQ异步完成后续数据库落地和状态更新。
- 分布式锁也可能成为瓶颈,需要优化锁粒度或采用分段锁等技术。
- 冲突较少 (Low Contention): 如更新用户个人资料、编辑普通文章等。
- 乐观锁(基于
@Version
或 CAS)通常是性能和实现复杂度之间的良好平衡点。 - 如果写操作本身很少,甚至可以不加特别的并发控制(依赖数据库默认隔离级别),但要小心Last-Write-Wins。
- 乐观锁(基于
- 冲突频繁 (High Contention): 如秒杀场景,大量请求争抢同一资源。
-
性能要求 (Performance Requirement):
- 低延迟,高吞吐量: 优先考虑乐观锁、原子操作、异步处理。避免使用重量级的悲观锁或分布式事务。
- 对性能要求不高,但一致性优先: 可以考虑悲观锁。
-
系统复杂度 (System Complexity):
- 简单性优先: 数据库悲观锁、JPA
@Version
乐观锁相对容易理解和实现。 - 性能优先,可接受复杂度: Redis 分布式锁 (Redisson)、Lua 脚本原子操作、基于 MQ 的异步架构。
- 极高复杂度: 分布式事务 (TCC, SAGA)。
- 简单性优先: 数据库悲观锁、JPA
-
资源类型:
- 数据库行记录: 悲观锁 (FOR UPDATE), 乐观锁 (@Version, CAS Update)。
- Redis Key (计数器/状态): Redis 原子命令 (INCR/DECR), Lua 脚本。
- 需要跨进程/服务互斥访问的代码段: 分布式锁 (Redis/Redisson)。
- 缓存数据: Cache-Aside + “Update DB then Delete Cache” + MQ 重试。
场景分析与权衡
场景 | 一致性要求 | 冲突程度 | 性能要求 | 推荐策略 (Java + Redis) | 备注 |
---|---|---|---|---|---|
秒杀商品库存扣减 | 最终一致 | 极高 | 极高 | Redis Lua 原子脚本 (检查并扣减) + 消息队列 (异步落库) | 内存库存快速响应,MQ保证最终落地。可能需要配合限流、令牌桶等。 |
普通商品库存更新 | 强/最终 | 中/低 | 高 | 乐观锁 (@Version 或 CAS) 或 Redis Lua 原子脚本 (如果库存也在Redis维护) | 如果并发不高,乐观锁足够。如果库存核心逻辑在Redis,用Lua更直接。 |
用户积分增减 | 强/最终 | 中 | 高 | Redis 原子命令 (INCRBY ) 或 乐观锁 (DB) | Redis原子命令最简单高效。如果积分与复杂业务关联,可能需要在DB层面乐观锁。 |
用户余额操作 | 强一致 | 中/低 | 中 | 数据库悲观锁 (FOR UPDATE ) 或 乐观锁 (CAS, 需严格重试/失败处理) | 资金安全优先,悲观锁更稳妥。乐观锁需谨慎处理冲突。 |
更新文章/配置 | 最终一致 | 低 | 高 | 乐观锁 (@Version) + Cache-Aside (Update DB then Delete Cache + MQ) | 典型的读多写少,乐观锁性能好。缓存更新采用标准模式。 |
定时任务 (单实例执行) | 强一致 | N/A | N/A | 分布式锁 (Redisson) | 确保集群中只有一个实例执行任务。 |
防止表单重复提交 | 强一致 | 中 | 高 | 分布式锁 (Redisson, 锁请求标识) 或 Redis SETNX (带过期时间) | 用请求的唯一标识 (如Token) 作为锁的key。 |
缓存与DB同步 | 最终一致 | N/A | 高 | Cache-Aside (Update DB then Delete Cache + MQ/Canal) | 标准缓存同步模式。 |
组合策略的应用
通常,解决复杂的并发问题需要组合使用多种策略。例如:
- 秒杀系统: 可能同时用到 API层限流 + Redis Lua原子减库存 + 消息队列异步下单 + 数据库乐观锁更新订单状态 + 分布式锁处理退款等补偿逻辑。
- 内容管理系统: 可能用到 数据库乐观锁编辑文章 + Cache-Aside模式同步缓存 + 消息队列异步通知订阅者。
没有银弹,只有最适合当前场景的组合。 理解每种策略的原理、优缺点和适用场景,是做出正确技术选型的关键。
7. 总结
- 识别问题: 我们首先明确了并发场景下常见的“坑”,如最后写入胜出、丢失更新、缓存与数据库不一致等。
- 理解Redis优势: Redis的高性能、原子操作、Lua脚本和分布式锁能力使其成为解决并发问题的有力武器。
- 掌握核心策略:
- 悲观锁: 适用于强一致性要求、冲突概率高、可容忍性能损失的场景。
- 乐观锁: 适用于读多写少、性能要求高、可接受冲突后处理(重试)的场景。JPA
@Version
和基于条件的CAS更新是常用实现。 - 分布式锁: 用于跨进程/服务协调共享资源访问。Redisson 提供了带看门狗的完善实现。
- 原子操作: 利用 Redis 的
INCRBY
/DECRBY
或 Lua 脚本实现不可中断的“读-改-写”,是处理计数器、秒杀库存等场景的关键。 - 异步处理: 通过消息队列削峰填谷,将并发写转化为串行处理,保证最终一致性,提高系统吞吐量。
- 缓存一致性: Cache-Aside 模式下的“先更新DB,再删除Cache”是推荐策略,并需结合 MQ 等机制保证删除操作的可靠性。
Happy coding!