在现代Web应用中,为了提升性能和用户体验,缓存(尤其是像Redis这样的内存数据库)的使用几乎无处不在。然而,当多个用户或进程同时尝试读取和修改同一份数据时,就会出现并发更新(Concurrent Updates)的问题。
如果处理不当,并发更新会像一个潜伏的幽灵,悄无声息地导致数据错乱、用户操作丢失,甚至引发严重的业务逻辑错误。
想象一下这样的场景:
这些场景的核心挑战在于数据一致性(Data Consistency)。我们需要确保,在并发环境下,系统中的数据(无论是数据库还是缓存)始终保持正确、有效,并且符合业务规则。
在深入解决方案之前,我们必须先清晰地认识到并发更新可能导致的具体问题。理解这些问题的本质,有助于我们更好地选择和应用相应的解决策略。
这是最直观也最常见的问题。
问题描述: 多个客户端(或线程/进程)几乎同时读取同一份数据的初始状态(例如,库存量为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)
问题描述: 一个事务(Transaction A)修改了数据,但尚未提交。此时,另一个事务(Transaction B)读取了这份被修改过但未提交的数据。如果事务A最终因为某种原因(如异常、业务规则校验失败)执行了回滚(Rollback),那么事务B读取到的数据就是“脏”的、无效的,因为它从未真正持久化。
场景模拟: 事务A给用户账户加钱,但中途失败回滚;事务B在事务A回滚前读取了增加后的(临时)余额,并基于这个错误的余额做了后续判断或操作。
说明: 这通常与数据库的事务隔离级别有关。在较低的隔离级别(如Read Uncommitted)下可能发生。虽然在典型的缓存操作(如Redis SET
/GET
)中不直接涉及数据库事务的回滚,但在涉及“缓存+数据库”更新的复杂流程中,如果逻辑处理不当,也可能读取到中间状态的、最终会被撤销的数据。
这个概念与“最后写入胜出”非常相似,但有时特指在读-改-写(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); // 读取价格为 100
if (product != null) {
// ... 可能有其他业务逻辑 ...
product.setPrice(newPrice); // 设置为 120
productRepository.save(product); // 尝试保存
}
}
// 事务B: 管理员B打折 (与事务A并发执行)
@Transactional
public void applyDiscount(Long productId, int discountPercent) {
Product product = productRepository.findById(productId).orElse(null); // 也读取价格为 100
if (product != null) {
BigDecimal currentPrice = product.getPrice();
BigDecimal discountedPrice = currentPrice.multiply(
BigDecimal.valueOf(1 - discountPercent / 100.0)); // 计算为 80
product.setPrice(discountedPrice); // 设置为 80
productRepository.save(product); // 尝试保存
}
}
// 如果事务B在事务A之后提交,最终价格为80,提价操作丢失
问题描述: 在同一个事务内,先后两次读取同一行数据,但得到的结果不同。这是因为在两次读取之间,有另一个已提交的事务修改了这行数据。
场景模拟: 事务A开始处理一个订单,先读取订单状态为“待支付”。接着执行一些耗时操作。在操作期间,事务B(用户支付操作)将该订单状态修改为“已支付”并提交。事务A随后再次读取该订单状态,发现变成了“已支付”,与其首次读取的结果不一致。这可能导致事务A后续的逻辑判断出错。
与缓存的关系: 如果缓存没有及时、正确地与数据库同步,也可能出现类似情况:第一次从缓存读到旧状态,第二次(缓存失效或穿透)从数据库读到新状态。
问题描述: 在同一个事务内,先后两次执行范围查询(例如,查询所有状态为“新注册”的用户),但第二次查询返回了第一次查询中没有的新行(“幻影”行)。这是因为在两次查询之间,有另一个已提交的事务插入了符合查询条件的新数据。
场景模拟: 事务A第一次查询所有“待处理”的任务,得到5条。它开始逐一处理这5条任务。处理过程中,事务B插入了一条新的“待处理”任务并提交。事务A处理完最初的5条后,为了确认,再次查询所有“待处理”任务,发现结果变成了6条(多了一条“幻影”任务)。
与不可重复读的区别: 不可重复读侧重于同一行数据被修改;幻读侧重于查询结果集范围内新增(或删除)了行。
这是一个相对复杂和微妙的并发问题。
问题描述: 两个事务各自读取一组数据(可能部分重叠),然后基于读取到的信息做出决策,并更新不同的数据项。这两个事务单独看都没有违反约束,但它们组合在一起时,却破坏了系统的某个整体业务约束。
经典场景: 医院排班系统规定至少要有一名医生在值班。现在有Alice和Bob两位医生在值班。
关键点: 事务读取数据,基于读取结果做判断,然后更新不相干的数据。
这是在使用缓存时最核心的并发问题之一。
问题描述: 对数据的更新操作未能同时、原子地完成对数据库和缓存的修改,导致缓存中的数据与数据库中的数据状态不同步。
常见失败模式 (以Cache-Aside为例):
后果: 用户看到过时或错误的信息,业务逻辑基于错误数据执行。
问题描述: 在分布式系统中,由于各个节点/服务的物理时钟可能存在偏差(时钟不同步),加上网络传输的延迟是不确定的,导致事件的实际发生顺序或被处理的顺序可能与预期的逻辑顺序不一致。
场景模拟: 用户快速连续两次修改个人资料。
影响: 导致下游服务的数据状态与最终一致状态相悖,或者处理逻辑混乱。
理解了这些并发问题,我们就能更有针对性地去寻找和应用解决方案了。接下来,我们看看为什么Redis常常是解决这些问题的得力助手。
Redis作为一个高性能的内存键值数据库,不仅仅是简单的缓存,它提供的丰富数据结构和特性使其在处理并发场景时具有显著优势:
INCR
(原子增)、DECR
(原子减)、SETNX
(Set if Not Exists,原子设置)等。这意味着当多个客户端同时对同一个键执行这些命令时,Redis内部会确保它们一个接一个地、不受干扰地完成,避免了竞态条件。这是实现乐观锁、计数器等并发控制机制的基础。SET key value NX PX milliseconds
命令(或老的 SETNX
+ EXPIRE
组合,需注意原子性)可以直接用于实现分布式锁,控制对共享资源的互斥访问。简而言之,Redis的速度、原子性操作和灵活性,使其成为在分布式系统中实现缓存、计数器、分布式锁、消息队列等功能的理想选择,进而有效地帮助我们应对并发更新带来的挑战。
面对形形色色的并发问题,并没有一个万能的“银弹”可以解决所有情况。我们需要根据具体的业务场景、对一致性的要求(强一致性 vs. 最终一致性)、性能要求、系统复杂度等因素,选择或组合使用不同的策略。
以下是一些常用的核心策略,后续章节将重点围绕Java和Redis进行详解:
锁 (Locking):
SELECT ... FOR UPDATE
)、同步代码块 (synchronized
) 或 ReentrantLock
(主要用于单体应用内)、分布式锁 (Redis, ZooKeeper)。 优点: 简单直接,能有效保证强一致性。 缺点: 性能开销大,可能产生死锁,降低并发度。原子操作 (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,实践上述策略中的关键部分,特别是乐观锁、分布式锁、原子操作和缓存一致性策略。
现在,我们进入实战环节,详细探讨如何使用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。
在使用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 {
@Autowired
private 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
@Version
private 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 {
@Autowired
private ProductRepository productRepository; // JpaRepository
@Transactional
public 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
(或其他具体的乐观锁异常)。除了依赖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 {
@Autowired
private ProductRepository productRepository;
@Transactional
public 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 更新为 newStock
System.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
未满足)。@Version
机制,但实现了相同的乐观并发控制效果。重试逻辑示例 (简化版):
@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
@Transactional
protected 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); // 可能抛出 ObjectOptimisticLockingFailureException
return true;
} else {
return false;
}
}
}
何时使用? 乐观锁非常适合读多写少,且对并发性能要求较高的场景。例如,更新用户信息、商品详情页(库存更新可能需要更强机制)、配置项修改等。对于写入冲突非常频繁的场景(如秒杀),单纯的乐观锁可能因大量重试而失效,需要结合其他策略。
当你的应用部署在多个实例(进程)上,或者有多个不同的服务需要访问同一个共享资源(如Redis中的某个键、数据库中的某行记录、或是一个需要互斥执行的任务)时,Java内置的 synchronized
或 ReentrantLock
就无能为力了,因为它们的作用范围仅限于单个JVM进程。这时,我们就需要分布式锁。
Redis因其高性能和原子操作特性,是实现分布式锁的热门选择。
想象一下商品秒杀场景,多个应用实例都在处理扣减库存的请求。如果不加控制,它们可能会并发执行类似“读取库存 -> 检查库存 -> 扣减库存”的操作,导致超卖(类似于前面讨论的Lost Update问题)。我们需要一种机制,确保在任何时刻,只有一个实例能够执行这段关键代码。分布式锁就是扮演这个协调者的角色。
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 {
@Autowired
private 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(); // 每个请求生成唯一ID
long 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)
: 这是Redis SET key value NX PX milliseconds
命令的Java实现,保证了设置key和设置过期时间这两个操作的原子性。如果分开执行 SETNX
和 EXPIRE
,在它们之间发生崩溃可能导致锁永不释放。holderId
: 必须是唯一的,用来标识当前锁的持有者。Lua脚本
: redisTemplate.execute(script, keys, args...)
用于执行Lua脚本。Lua脚本在Redis服务器端原子执行,是解决“检查锁归属并删除”这类复合操作原子性的关键。expireTime
,锁会自动释放,其他线程可能获取到锁,导致并发执行。对于执行时间不确定的业务,需要一种“看门狗”(Watchdog)机制,在锁持有期间定期延长锁的过期时间。手动实现比较复杂。虽然 setIfAbsent
解决了获取锁的原子性,安全释放锁也通过Lua解决,但锁续期问题仍然存在。我们可以通过更复杂的Lua脚本或客户端库来解决。
Redisson 是一个功能丰富的Java Redis客户端,它提供了对分布式锁的完善封装,内置了锁续期(看门狗)机制和可重入特性。推荐在生产环境中使用。
添加依赖 (Maven):
<dependency>
<groupId>org.redissongroupId>
<artifactId>redisson-spring-boot-starterartifactId>
<version>3.17.7version>
dependency>
配置 Redisson (application.yml/properties):
spring:
redis:
host: localhost
port: 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 {
@Autowired
private 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秒leaseTime
System.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) 机制。leaseTime
为 -1 时,获取锁成功后,Redisson 会在后台启动一个定时任务(默认每隔 leaseTime / 3
时间,例如默认 30s / 3 = 10s
)去检查持有锁的线程是否还在运行。如果还在运行,就自动将锁的过期时间重置为 leaseTime
(例如重置为30秒)。这样只要业务线程还在执行,锁就不会过期。当业务执行完毕调用 unlock()
时,看门狗会停止。如果持有锁的客户端崩溃,看门狗自然停止,锁会在最后一个 leaseTime
到期后自动释放。RLock
而不会被自己阻塞,释放锁时需要调用相应次数的 unlock()
。Redisson 内部通过 Redis 的 Hash 结构记录了锁的持有线程和重入次数。isHeldByCurrentThread()
: 在 finally
块中释放锁前,最好检查一下当前线程是否确实还持有该锁,避免异常情况下错误释放。unlock()
: 释放锁。如果是可重入锁,会减少重入计数,计数为0时才真正删除Redis中的锁。lockKey
)应该尽可能小,只保护必要的共享资源,以提高并发性。避免使用过于宽泛的锁。leaseTime
): 必须设置!防止死锁。leaseTime
应大于业务正常执行时间,但也不能太长,以免资源被无效占用。使用 Redisson 的看门狗是最佳实践。getFairLock
),它会按照请求锁的顺序来授予锁,但性能通常低于非公平锁。RedissonRedLock
实现。但 RedLock 实现复杂,且仍有争议,除非对锁的可靠性有极端要求,否则通常使用带看门狗的单实例或主从/哨兵模式下的 RLock
已足够。何时使用? 当需要在多个进程或服务实例间对共享资源进行互斥访问时,分布式锁是必要的。例如:防止并发扣减库存超卖、保证定时任务只有一个实例执行、限制某些操作的并发数等。
原子操作是指不可被中断的一个或一系列操作。在并发环境下,原子操作可以确保数据修改的完整性,避免了“读-改-写”过程中被其他线程干扰的问题。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 {
@Autowired
private 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 脚本。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 {
@Autowired
private 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 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 方法同上) ...
}
理解帮助:
redis.call()
用于在 Lua 脚本中调用 Redis 命令。redisTemplate.execute(script, keys, args...)
是执行 Lua 脚本的标准方式。keys
对应脚本中的 KEYS[n]
,args
对应 ARGV[n]
。tryDeductStock
方法中的并发问题,是秒杀场景下扣减 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 {
@Autowired
private 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>>() {
@Override
public List<Object> execute(RedisOperations operations) throws DataAccessException {
// 1. WATCH 库存 key
operations.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 的结果) 是否符合预期,例如 >= 0
System.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,然后原子执行。WATCH
/MULTI
/EXEC
更侧重于简单的事务打包和基于 WATCH
的 CAS。WATCH
机制在高并发冲突严重时,可能导致大量事务失败和重试,性能可能不如 Lua。INCR
生成序列号。原子操作是 Redis 在并发控制方面的强大武器,善用它可以构建出高性能且数据一致的系统。
在高并发写入场景下,即使使用了乐观锁或原子操作,如果瞬时请求量超过系统的处理能力,仍然可能导致性能瓶颈或大量失败/重试。异步处理,特别是利用消息队列(Message Queue, MQ),是一种有效的“削峰填谷”和保证最终一致性的策略。
核心思想:将需要保证顺序或可能产生冲突的写操作,不直接执行,而是封装成消息发送到消息队列中。由后端配置的消费者(可以是一个或有限个)从队列中按顺序拉取消息并执行实际的数据库或缓存更新。
场景:订单创建与库存扣减
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 {
@Autowired
private OrderRepository orderRepository; // 假设用于保存订单
@Autowired
private 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 {
@Autowired
private 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 等辅助方法 ...
}
何时使用? 当系统需要处理高并发写入,允许一定的延迟以换取更高的吞吐量和系统稳定性时,异步处理是绝佳选择。特别适用于:秒杀下单(将扣库存、生成流水等后续操作异步化)、日志收集、发送邮件/短信通知等场景。
在使用 Redis 作为缓存加速数据库访问时,如何保证缓存和数据库之间的数据一致性是核心挑战,尤其是在并发更新下。
这是最常用、最经典的缓存模式。应用程序逻辑需要同时维护缓存和数据库。
读操作:
写操作 (更新/删除): 这里存在两种主要策略:
策略一:先更新数据库,再删除缓存 (Update DB, then Delete Cache) (推荐)
策略二:先删除缓存,再更新数据库 (Delete Cache, then Update DB) (不推荐,易出问题)
策略三:先更新数据库,再更新缓存 (Update DB, then Update Cache) (一般不推荐)
为什么推荐“先更新DB,再删除Cache”?
为什么不推荐“先删除Cache,再更新DB”?
为什么一般不推荐“先更新DB,再更新Cache”?
在这种模式下,应用程序只与缓存交互。缓存服务自身负责与后端数据库进行数据的读取和写入同步。
读穿透 (Read-Through): 应用向缓存请求数据。如果缓存有,直接返回。如果缓存没有,缓存服务自动从数据库加载数据,存入缓存,然后返回给应用。对应用来说,数据库是透明的。
写穿透 (Write-Through): 应用向缓存写入数据。缓存服务先将数据写入缓存,然后同步将数据写入后端数据库。只有当缓存和数据库都写入成功后,才算操作完成。
优点: 应用逻辑简单,数据一致性较好(写操作是同步的)。
缺点:
也称为 Write-Behind。应用只与缓存交互。
写操作: 应用向缓存写入数据,缓存立即确认写入成功并返回。缓存服务将“脏”数据(已更新但未写入数据库的数据)标记起来,异步地、批量地或延时地将这些数据刷回(Flush)后端数据库。
读操作: 类似Read-Through,缓存未命中时从数据库加载。
优点: 写性能极高,因为应用无需等待数据库写入。适合写入非常频繁的场景。
缺点:
在 Cache-Aside 模式下,核心在于写操作如何处理缓存:
优先选择:“先更新DB,再删除Cache”
如何处理删除失败?
什么情况下可能考虑更新缓存?
延迟双删 (Double Deletion): 为了解决“先删除Cache,再更新DB”模式下的脏数据问题,有人提出了“延迟双删”:
基于消息队列的最终一致性 (推荐的删除策略保障):
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 {
@Autowired
private ProductRepository productRepository; // 操作数据库
@Autowired
private StringRedisTemplate redisTemplate; // 操作Redis缓存
@Autowired
private 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. 发送删除缓存的消息到MQ
String 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 {
@Autowired
private 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);
}
}
}
总结缓存一致性策略:
我们已经探讨了多种解决并发更新和数据一致性问题的策略,从悲观锁到乐观锁,从分布式锁到原子操作,再到异步处理和缓存一致性模式。那么,在实际项目中,面对具体问题该如何选择呢?
这通常需要一个权衡(Trade-off) 的过程,考虑以下几个关键因素:
一致性要求 (Consistency Requirement):
并发冲突的激烈程度:
@Version
或 CAS)通常是性能和实现复杂度之间的良好平衡点。性能要求 (Performance Requirement):
系统复杂度 (System Complexity):
@Version
乐观锁相对容易理解和实现。资源类型:
场景 | 一致性要求 | 冲突程度 | 性能要求 | 推荐策略 (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) | 标准缓存同步模式。 |
通常,解决复杂的并发问题需要组合使用多种策略。例如:
没有银弹,只有最适合当前场景的组合。 理解每种策略的原理、优缺点和适用场景,是做出正确技术选型的关键。
@Version
和基于条件的CAS更新是常用实现。INCRBY
/DECRBY
或 Lua 脚本实现不可中断的“读-改-写”,是处理计数器、秒杀库存等场景的关键。