在高并发的业务场景下,防止重复提交订单是一个重要的实践,以确保数据的一致性和系统的稳定性。防止重复提交通常涉及几个关键技术点:乐观锁、悲观锁、令牌桶、分布式锁等。
想象一下,我们的故事发生在一个繁忙的在线披萨店——“码农比萨”,这里不仅有最美味的比萨,还有最智能的下单系统。在这个系统中,防止顾客因手抖不小心点了两次“立即下单”按钮,导致收到双份比萨的尴尬情况,就显得尤为重要了。接下来,我们将以一种轻松愉快的方式,探讨如何用技术手段来守护这份“唯一”的爱(比萨)。
技术选型:乐观锁与Redis分布式锁的欢乐对决
乐观锁:轻盈的“比萨警察”
乐观锁就像是一个相信世界美好,大多数人都很诚实的“比萨警察”。它在每个比萨订单上贴了一个“已检查”标签(version),每当有新的修改尝试时,都会核对这个标签是否还是原来的那个。如果有人(另一个请求)已经偷偷改过了,这次修改就会被无情地拒绝。
// 比萨订单实体类,乐观锁版本号
@Entity
public class PizzaOrder {
@Id
private Long id;
// 其他属性...
@Version // 这就是那个神奇的“已检查”标签
private int version;
// 省略getter和setter
}
// 下单方法
@Transactional
public void placeOrder(PizzaOrder order) {
// 尝试从数据库取出发起时的订单状态(含version)
PizzaOrder originalOrder = orderRepository.findById(order.getId()).orElseThrow(() -> new RuntimeException("比萨不见了!"));
if(originalOrder.getVersion() != order.getVersion()) {
throw new IllegalStateException("哎呀,您的比萨订单似乎已经被处理过了!");
}
// 更新订单状态,比如标记为"制作中"
// 注意:这里的更新会自动比较version,如果不一致则抛出异常
order.setStatus("制作中");
orderRepository.save(order);
}
效果展示:
- 如果一切顺利,顾客只会收到一份热腾腾的比萨。
- 但如果有重复提交,“比萨警察”会发现版本号不匹配,拒绝第二次下单,顾客不会被双份比萨砸晕。
悲观锁:谨慎的“比萨侦探”
悲观锁是个谨慎过度的“比萨侦探”,总是假设最坏的情况会发生,所以一旦开始调查(读取数据),就立刻封锁现场(加锁),直到案件(事务)结束才放手。这种做法虽然效率上可能不如乐观锁,但在高度竞争的环境中,能确保数据的一致性,防止任何意外的干扰。
代码示例:
import org.springframework.data.jpa.repository.Lock;
import org.springframework.data.jpa.repository.JpaRepository;
public interface PizzaOrderRepository extends JpaRepository<PizzaOrder, Long> {
// 使用@Lock注解指定悲观锁,这里使用PESSIMISTIC_WRITE表示写锁
@Lock(LockModeType.PESSIMISTIC_WRITE)
Optional<PizzaOrder> findByIdWithPessimisticLock(Long id);
}
@Transactional
public void placeOrderWithPessimisticLock(Long orderId) {
// 开启悲观锁模式查询订单,相当于对订单进行了锁定
PizzaOrder order = orderRepository.findByIdWithPessimisticLock(orderId).orElseThrow(() -> new RuntimeException("比萨订单神秘失踪!"));
// 在这里执行下单逻辑,由于已经加锁,其他线程无法同时修改该订单
order.setStatus("正在准备");
orderRepository.save(order);
}
注解:@Lock(LockModeType.PESSIMISTIC_WRITE)
可用于JPA查询方法上,指定悲观锁。
效果展示:
- 悲观锁确保了在整个下单过程中,不会有其他操作干扰订单状态,安全级别最高,但可能增加等待时间。
- 就像侦探一样,它虽慢条斯理,却步步为营,确保每一个比萨订单的处理都万无一失。
令牌桶算法:节奏大师的“比萨节拍器”
如果说悲观锁和乐观锁是比萨店里的“秩序维护者”,那么令牌桶算法就是一位优雅的“节奏大师”。它预先准备了一定数量的令牌(请求配额),按照一定的速率发放。只有持有令牌的请求才能通过,这样既控制了请求的速率,又允许短时间内的突发流量,保持了服务的稳定性和响应速度。
令牌桶算法是一种流量控制算法,其核心思想是系统以恒定的速度生成令牌并放入一个固定大小的桶中,当有请求到来时,需要从桶中取出一个令牌才能进行处理。如果桶中没有令牌可取,则请求被拒绝或延迟处理。这种方式既限流又能应对一定程度的突发流量,适用于网络流量控制、API调用频率限制等场景。
代码示例:
public class TokenBucket {
private final int capacity; // 桶的容量,即最多能存放多少令牌
private int tokens; // 当前桶中的令牌数量
private final int refillRate; // 每秒向桶中添加令牌的数量,即填充速率
public TokenBucket(int capacity, int refillRate) {
this.capacity = capacity;
this.tokens = capacity; // 初始化时桶满
this.refillRate = refillRate;
// 实际应用中,令牌的补充通常需要一个后台线程或定时任务来实现,这里简化处理
}
public synchronized boolean allowRequest(int tokensToConsume) {
// 确保线程安全
refill(); // 模拟令牌补充过程
// 判断是否有足够的令牌来满足请求
if(tokens >= tokensToConsume) {
tokens -= tokensToConsume; // 消耗令牌
return true;
}
return false; // 令牌不足,拒绝请求
}
// 模拟令牌补充过程,简化处理,不考虑时间精确计算
private void refill() {
tokens = Math.min(capacity, tokens + refillRate);
}
}
在下单服务中的应用:
@Service
public class PizzaOrderService {
private final TokenBucket tokenBucket = new TokenBucket(100, 10); // 桶容量100,每秒补充10个令牌
public void handleOrderRequest() {
if(tokenBucket.allowRequest(1)) { // 每个请求消耗1个令牌
// 执行下单逻辑
// ...
} else {
throw new RuntimeException("比萨店太忙了,请稍后再试!");
}
}
}
效果展示:
- 在高峰期,令牌桶能够平滑请求流量,避免服务因瞬间高并发而崩溃,确保了顾客体验的连贯性。
- 就像是音乐会中的节拍器,即使面对热情如火的订单洪流,也能保持比萨店运营的和谐节奏。
关键点分析
-
构造函数:初始化令牌桶时,需要指定桶的容量(
capacity
)和每秒填充的令牌数(refillRate
)。桶初始时是满的。 -
allowRequest
方法:这是核心逻辑所在,它接受一个参数tokensToConsume
,表示处理请求需要消耗的令牌数。方法内部首先调用refill()
补充令牌,然后检查桶中令牌是否足够。如果足够,则减少相应数量的令牌并返回true
,表示请求被允许;否则返回false
,表示请求被拒绝或需等待。 -
refill
方法:模拟令牌的补充过程,这里为了简化,直接累加refillRate
个令牌到桶中,并确保令牌数量不超过桶的容量。实际应用中,这个过程应该是基于时间的,例如每过一定时间间隔(如1秒),就按照refillRate
的数量补充令牌。
Redis分布式锁:铁面无私的“比萨门神”
相比之下,Redis分布式锁就像是一位严肃的“比萨门神”,守在厨房门口,只允许一个“制作比萨”的请求通过。每个想进厨房的请求都得先拿到一把独一无二的钥匙(锁),没钥匙的只能乖乖排队或者回家等通知。
代码示例:
@Service
public class PizzaOrderService {
@Autowired
private RedisTemplate<String, String> redisTemplate;
public void placeOrderWithLock(Long orderId) {
// 锁的键名,确保每个订单的锁是唯一的
String lockKey = "pizza_order_lock_" + orderId;
// 尝试获取锁,设置超时时间防止死锁,如果5秒内无法获取到锁则返回false
if (!tryLock(lockKey, 5)) {
throw new RuntimeException("比萨太热门了,请稍后再试!");
}
try {
// 在这里执行真正的下单逻辑
// ...
} finally {
// 不管成功失败,记得释放锁
unlock(lockKey);
}
}
// 尝试获取Redis锁的方法
private boolean tryLock(String key, long timeoutSeconds) {
// setIfAbsent方法只有当key不存在时才会设置值,这样可以保证锁的互斥性
return Boolean.TRUE.equals(redisTemplate.opsForValue().setIfAbsent(key, "locked", timeoutSeconds, TimeUnit.SECONDS));
}
// 释放Redis锁的方法
private void unlock(String key) {
redisTemplate.delete(key);// 直接删除key即可释放锁
}
}
效果展示:
- 在高峰期,即使有多个顾客同时点击“下单”,也只有第一个拿到锁的请求能进入,避免了厨房里一堆重复订单的混乱。
- 其他人则会被礼貌告知“稍后再试”,有效控制了并发混乱,保持厨房秩序井然。
结果对比:做了与不做
-
做了: 使用以上几种方式,就像是给“码农比萨”的厨房装上了智能管理系统,有效避免了重复制作和派送比萨的问题,提升了顾客满意度,同时也优化了资源利用率,减少了不必要的成本开支。
-
不做: 假设没有这些防护措施,想象一下那个混乱的夜晚,无数重复的比萨订单如潮水般涌来,厨房一片狼藉,快递小哥跑断腿,顾客在家对着堆积如山的比萨发愁。长此以往,不仅经济上损失惨重,店铺的口碑也会一落千丈。
总之,选择合适的防重提交策略,就像是给你的在线业务穿上了一件“防抖马甲”,让每一次操作都更加稳健可靠。在这个过程中,无论是乐观锁的优雅,还是Redis分布式锁的强硬,都是我们守护数据准确性和系统稳定性的得力助手。