业务场景:
笔者负责的功能需要调用其他系统的进行审批,而接口的调用过程耗时有点长(可能长达10秒),一个订单能被多个人提交审批,当订单已提交后会更改为审批中,不能再次审批(下游系统每调用一次会产生一次笔新数据)。前端在点击编辑前会进行一次查询,处于审批中的订单无法点击提交审批。问题代码如下所示
// 在数据加载时, 用户点击编辑详情时会请求当前方法, 方法会校验订单状态决定是否允许用户获取订单详情
public AjaxResult selectOrderDetailToEdit(Long orderId) {
OrderDetail orderDetail = OrderMapper.selectOrderId(orderId);
if ("PROCESS".equals(orderDetail.getDealStatus()) {
return AjaxResult.error("审批中的订单不能编辑");
}
return AjaxResult.success(orderDetail);
}
/**
* 提交审批的逻辑
*/
@Transactional
public AjaxResult submitOrderApprove(OrderDetail orderDetail, boolean isProcess) {
// 省略其他处理逻辑...
// 调用审批接口
if (isProcess) {
OrderDetail toApproveOrder = OrderMapper.selectOrderId(orderId);
AjaxResult approvedRs = approveOrderProcess(toApproveOrder);
// 接口调用成功, 将接口返回的编码和处理中状态写入数据库
if (approvedRs.isSuccess()) {
toApproveOrder.setProcessCode(approvedRs.getProcessCode());
toApproveOrder.setDealStatus("PROCESS");
updateOrderDealStatus(toApproveOrder);
}
}
return AjaxResult.success(orderDetail);
}
原因分析:
出现的问题就是当A和B两个用户同时在订单未审批状态时进入了订单的编辑状态,然后A用户进行了提交,订单状态实际已经是审批中,B用户由于页面上订单状态未更新,也显示的是未审批,也可以提交审批,B用户点击提交后,就导致接口调用了两次。上面的描述可能不太直观,可以看下面的时序图。
解决方案:
多个用户处理一个单据的情况在业务中时常见的,针对这种问题,单机服务和分布式服务的应用采用的解决方案也不相同,下面也记录下不同部署方式的解决方案以供参考。
单机应用处理方式
synchronize代码块
锁粒度比较大,不能控制到按订单加锁,当不同人操作不同的订单也需要等待其他订单处理完成后才能继续处理下一个订单。
public AjaxResult submitOrderApprove(OrderDetail orderDetail, boolean isProcess) {
synchronize(当前类名.class) {
if ("PROCESS".equals(orderDetail.getDealStatus())) {
return AjaxResult("订单已处理");
}
}
}
ConcurrentHashMap
使用ConcurrentHashMap时需要注意使用完后要remove掉,避免出现其他线程获取不到锁甚至内存溢出的问题
其中使用了putIfAbsent
方法保证原子操作,下面直接给出代码示例
// 全局静态的ConcurrentHashMap
private static ConcurrentHashMap<Long, String> orderLockMap = new ConcurrentHashMap<>();
public void submitOrderApprove(OrderDetail orderDetail) {
long orderId = orderDetail.getOrderId();
// map中的值是当前线程名称,remove时需要判断等于当前线程时才移除,避免移除了其他线程的锁值
String threadName = Thread.currentThread().getName();
try {
/* map中的值是当前线程名称,用于在remove时判断当前线程,避免移除其他线程的锁值
使用ConcurrentHashMap的putIfAbsent方法, 如果put成功返回null, 键已存在则返回已存在键的值
*/
if (OrderLock.orderLockMap.putIfAbsent(orderId, threadName) != null) {
System.out.println(orderId + "订单正在处理中,请稍后");
} else {
System.out.println("加锁成功, 当前订单ID:" + orderId);
}
// 模拟其他业务处理逻辑
Thread.sleep(5000L);
} finally {
if (threadName.equals(orderLockMap.get(orderId))) {
orderLockMap.remove(orderId);
}
}
}
分布式服务处理方式
通过数据库锁限制
在提交逻辑中查询单据状态并增加查询行锁进行判断,这样另外一个线程查询时也会等待锁执行完成后才查询返回,for update
是关键
-- 假设是写在mybatis的mapper中的queryOrderProcessStatus()方法的查询sql
select order_id, deal_status from order_detail where order_id = #{orderId} for update;
@Transaction
public void submitOrderApprove(OrderDetail orderDetail) {
OrderDetail orderDetail = orderMapper.queryOrderProcessStatus(orderId);
if ("PROCESS".equals(orderDetail.getDealStatus())) {
return AjaxResult("订单已处理");
}
// 业务处理逻辑
}
当然在数据库中建立一张表作为事务处理表亦可,因篇幅所限,此处不展示这种处理方法。
通过Redis分布式锁限制
现有的Redis
在java方面的api很多,我们实现起来也很方便快捷了,下面使用RedisTemplate
实现Redis
加锁逻辑。
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.script.DefaultRedisScript;
import org.springframework.stereotype.Component;
import java.util.ArrayList;
import java.util.Collections;
import java.util.concurrent.TimeUnit;
@Component
public class LockUtil {
/**
* lua脚本 释放锁,因为有多步操作,需要保证原子性使用lua脚本
*/
private static final String REDIS_DEL_LOCK_SCRIPT = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
/**
* redis锁分类目录
*/
private static final String LOCK_PREFIX = "LOCK:";
/**
* redis操作服务
*/
protected RedisTemplate<String, String> redisTemplate;
@Autowired
public LockUtil(RedisTemplate redisTemplate) {
this.redisTemplate = redisTemplate;
}
/**
* @param lockKey 锁的唯一key
* @param lockTime 锁超时时间(毫秒)
* @param reqNum 请求锁的次数
* @param reqWaitLockTime 每次请求锁的间隔时间(毫秒)
* @return
* @throws InterruptedException
*/
public Boolean tryLock(String lockKey, Long lockTime, Integer reqNum, Long reqWaitLockTime) {
Boolean isSuccessLock = false;
String redisKey = LOCK_PREFIX + lockKey;
for (int count = 1; count <= reqNum; count++) {
isSuccessLock = redisTemplate.opsForValue().setIfAbsent(redisKey, Thread.currentThread().getName(), lockTime, TimeUnit.MILLISECONDS);
if (Boolean.TRUE.equals(isSuccessLock)) {
return true;
}
try {
Thread.sleep(reqWaitLockTime);
} catch (InterruptedException e) {
unLock(lockKey);
throw new RuntimeException("加锁失败,锁ID【" + lockKey + "】");
}
}
return isSuccessLock;
}
/**
* 释放锁
*
* @param lockKey 锁的唯一key
*/
public void unLock(String lockKey) {
DefaultRedisScript<Long> redisScript = new DefaultRedisScript<>(REDIS_DEL_LOCK_SCRIPT, Long.class);
redisTemplate.execute(redisScript, new ArrayList<>(Collections.singleton(LOCK_PREFIX + lockKey)), Thread.currentThread().getName());
}
}
使用示例
@Autowired
private LockUtil lockUtil;
public void submitOrderApprove(OrderDetail orderDetail) {
// 如果加锁成功, tryLock方法会返回true
if (!lockUtil.tryLock("ORDER_APPROVE:" + orderDetail.getOrderId, 3L, 5, 1L)) {
return AjaxResult.error("点击过于频繁,请稍后再操作");
}
try {
// 处理业务逻辑
} finally {
lockUtil.unlock();
}
}