前提说明
在电商项目中,下订单时通常会涉及多个表操作,比如商品表、订单表和订单状态表等。为了保证下订单这一操作的原子性,所以下单时需要保证这些表的操作要么同时成功要么同时失败。通常情况下我们都会在下订单方法中加上事务处理,常用的都是借助Spring来进行事务管理。
同时下单时需要进行检查库存,扣减库存这些操作。为了防止超卖问题,通常我们会在这些操作加上同步锁来保证原子操作。
本次我采用了Spring声明式事务@Transaction
和synchronized
标注的方法,发现在单体的架构下还是出现了商品超卖的问题。下面我们一起来模拟这个出现超卖的流程。
模拟超卖流程
1. 数据库准备
数据库准备三张表,分别时order、order_item和product。往商品表中插入一条商品的信息,设置库存为1。
# 商品表
CREATE TABLE `product` (
`id` int(11) NOT NULL,
`product_name` varchar(255) NOT NULL,
`count` int(5) NOT NULL,
`create_time` time NOT NULL,
PRIMARY KEY (`id`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
# 订单信息表
CREATE TABLE `order_item` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`order_id` int(11) NOT NULL,
`product_id` int(11) NOT NULL,
`create_time` time NOT NULL,
PRIMARY KEY (`id`) USING BTREE
) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8;
# 订单状态表
CREATE TABLE `order_status` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`order_status` int(1) NOT NULL,
`create_time` time NOT NULL,
PRIMARY KEY (`id`) USING BTREE
) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8;
[图片上传失败...(image-a782ee-1619323919457)]
2. 测试程序
service层
@Service
@Slf4j
public class OrderService {
/**
* 模拟下订单方法
* @param productId
* @param productNum
* @return
* @throws Exception
*/
@Transactional(rollbackFor = Exception.class)
public synchronized Integer createOrder(Integer productId, Integer productNum) throws Exception {
// 查询商品
Product product = productMapper.selectByPrimaryKey(productId);
// 购买的商品不存在
if (product == null) {
throw new Exception("购买商品:"+productId+"不存在");
}
// 获取库存数量
Integer currentCount = product.getCount();
// 打印出每个线程获取到的库存数
System.out.println(Thread.currentThread().getName() + "库存数: " + currentCount);
// 检查库存
if (productNum > currentCount) {
throw new Exception("商品"+productId+"仅剩"+currentCount+"件,无法购买");
}
// 更新库存
productMapper.updateProductCount(productNum, product.getId());
// 设置订单状态
Order order = new Order();
order.setOrderStatus(1);
order.setCreateTime(new Date());
orderMapper.insertSelective(order);
// 设置订单信息
OrderItem orderItem = new OrderItem();
orderItem.setOrderId(order.getId());
orderItem.setProductId(product.getId());
orderItem.setCreateTime(new Date());
// 返回订单ID
return order.getId();
}
}
Contrller
@RestController
@RequestMapping("/order")
@Slf4j
public class OrderController {
//购买商品id
private static final int productId = 100100;
//购买商品数量
private static final int productNum = 1;
@Autowired
private OrderService orderService;
@RequestMapping("/createOrder")
public Integer createOrder() {
Integer orderId = null;
try {
orderId = orderService.createOrder2(productId, productNum);
} catch (Exception exception) {
log.warn(exception.getMessage());
}
return orderId;
}
}
3. 并发测试
本次使用Jmeter来模拟并发下单的流程。新建一个线程组,设置0s内同时向服务器发送5个下订单的请求。
结果分析
我们发现product表的商品数量为-1,order_status和order_item表均有2条订单的记录。这是典型的发生了超卖的现象。
[图片上传失败...(image-2ab974-1619323919457)]
查看后台日志发现,线程4和线程10均拿到了商品库存的数量为1,所以跳过库存检查的判断,直接跑到更新库存的方法,从而导致超卖的现象。
那么问题就来了,为什么有两个线程都拿到相同的库存呀,我明明加入synchronized同步锁的,为什么方法没有同步?
其实也很好理解,既然两个线程都能拿到相同的库存,那就说明同步方法已经执行完,锁也释放了,但是数据库的值还没更新,还是旧值。
这就是@Transaction
的坑点所在,锁释放了,事务却没提交。并发场境下,存在多个线程拿到的数据库的旧值而不是最新值的情况,从而出现多线程安全的问题。
其实也很好理解,既然两个线程都能拿到相同的库存,那就说明同步方法已经执行完,锁也释放了,但是数据库的值还没更新,还是旧值。
这就是@Transaction
的坑点所在,锁释放了,事务却没提交。并发场境下,存在多个线程拿到的数据库的旧值而不是最新值的情况,从而出现多线程安全的问题。
那怎么解决?既然是锁释放了,事务没有提交的问题。那么我们可以在同步方法中,手动提交一下这个事务就OK啦。