问题: 高并发下商品抢购引起的超发现象
1、利用SpringBoot创建商品抢购工程
(1)配置pom.xml文件
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.1.5.RELEASE</version>
<relativePath/>
</parent>
<groupId>com.ming</groupId>
<artifactId>high_concurrency</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>high_concurrency</name>
<description>High concurrency project for Spring Boot</description>
<properties>
<java.version>1.8</java.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.mybatis.spring.boot</groupId>
<artifactId>mybatis-spring-boot-starter</artifactId>
<version>2.0.1</version>
</dependency>
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.apache.tomcat.embed</groupId>
<artifactId>tomcat-embed-jasper</artifactId>
</dependency>
<dependency>
<groupId>javax.servlet</groupId>
<artifactId>jstl</artifactId>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>
(2)工程目录结构

(3)配置application.yml文件
#服务器配置
server:
port: 8090
#spring配置
spring:
#设置页面编码
http:
encoding:
charset: UTF-8
force: true
enabled: true
#数据源配置
datasource:
#配置mysql数据库(默认数据源为HikariDataSource)
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://localhost/high_concurrency_task?serverTimezone=CTT
username: root
password: 1314
#配置JSP视图
mvc:
view:
prefix: /WEB-INF/jsp/
suffix: .jsp
#配置mybatis框架
mybatis:
#定义Mapper接口映射文件位置
mapper-locations: classpath:sqlmap/*.xml
#定义实体类位置
type-aliases-package: com.ming.high_concurrency.pojo
#控制台日志配置
logging:
level:
#打印sql语句
com.ming.high_concurrency.dao: debug
#打印事务
org.springframework.jdbc: debug
#打印sqlsession
org.mybatis.spring: debug
2、持久层实现
UcProductMapper:
package com.ming.high_concurrency.dao;
import com.ming.high_concurrency.pojo.Product;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
/**
* 项目名称: high_concurrency
* 包名称: com.ming.high_concurrency.dao
* 类名称: UcProductMapper
* 类描述: 操作产品表
* 创建人: fanglm
* 创建时间: 2019/6/19 11:27
*/
@Mapper
public interface UcProductMapper {
/**
* 获取产品
* @param productId 编号
* @return 产品对象
*/
Product getProduct(Integer productId);
/**
* 减少产品库存
* @param productId 产品编号
* @param quantity 产品购买数量
* @return 更新的行数
*/
Integer decreaseProduct(@Param("productId") Integer productId, @Param("quantity") Integer
quantity);
UcProductMapper.xml:
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper
PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.ming.high_concurrency.dao.UcProductMapper">
<select id="getProduct" parameterType="int" resultType="Product">
select
product_id as productId, product_name as productName, stock, price, version, note
from uc_product
where
product_id = #{productId}
</select>
<update id="decreaseProduct">
update uc_product
set stock = stock - #{quantity}
where
product_id = #{productId}
</update>
</mapper>
UcPurchaseRecordMapper:
package com.ming.high_concurrency.dao;
import com.ming.high_concurrency.pojo.PurchaseRecord;
import org.apache.ibatis.annotations.Mapper;
/**
* 项目名称: high_concurrency
* 包名称: com.ming.high_concurrency.dao
* 类名称: UcPurchaseRecordMapper
* 类描述: 操作购买记录表
* 创建人: fanglm
* 创建时间: 2019/6/19 11:35
*/
@Mapper
public interface UcPurchaseRecordMapper {
/**
* 插入购买记录
* @param purchaseRecord 产品对象
* @return 插入的行数
*/
Integer insertPurchaseRecord(PurchaseRecord purchaseRecord);
}
UcPurchaseRecordMapper.xml:
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper
PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.ming.high_concurrency.dao.UcPurchaseRecordMapper">
<insert id="insertPurchaseRecord" parameterType="PurchaseRecord">
insert into
uc_purchase_record(user_id, product_id, price, quantity, total_price, purchase_date, note)
values(#{userId}, #{productId}, #{price}, #{quantity}, #{totalPrice}, #{purchaseDate}, #{note})
</insert>
</mapper>
3、业务层实现
PurchaseProductService:
package com.ming.high_concurrency.service;
/**
* 项目名称: high_concurrency
* 包名称: com.ming.high_concurrency.service
* 类名称: PurchaseProductService
* 类描述: 抢购产品业务层
* 创建人: fanglm
* 创建时间: 2019/6/19 11:45
*/
public interface PurchaseProductService {
/**
* 抢购产品
* @param userId 用户编码
* @param productId 产品编码
* @param quantity 抢购数量
* @return 是否抢购成功
*/
Boolean purchaseProduct(Integer userId, Integer productId, Integer quantity);
}
PurchaseProductServiceImpl:
package com.ming.high_concurrency.service.impl;
import com.ming.high_concurrency.dao.UcProductMapper;
import com.ming.high_concurrency.dao.UcPurchaseRecordMapper;
import com.ming.high_concurrency.pojo.Product;
import com.ming.high_concurrency.pojo.PurchaseRecord;
import com.ming.high_concurrency.service.PurchaseProductService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
import java.sql.Timestamp;
/**
* 项目名称: high_concurrency
* 包名称: com.ming.high_concurrency.service.impl
* 类名称: PurchaseProductServiceImpl
* 类描述: 抢购产品业务层实现
* 创建人: fanglm
* 创建时间: 2019/6/19 11:50
*/
@Service
public class PurchaseProductServiceImpl implements PurchaseProductService {
@Autowired
private UcProductMapper productMapper;
@Autowired
private UcPurchaseRecordMapper purchaseRecordMapper;
@Override
@Transactional
public Boolean purchaseProduct(Integer userId, Integer productId, Integer quantity) {
Product product = productMapper.getProduct(productId);
if (product != null && product.getStock() < quantity) {
return false;
}
productMapper.decreaseProduct(productId, quantity);
PurchaseRecord purchaseRecord = wrapperPurchaseRecord(userId, quantity, product);
purchaseRecordMapper.insertPurchaseRecord(purchaseRecord);
return true;
}
private PurchaseRecord wrapperPurchaseRecord(Integer userId, Integer quantity, Product product) {
Long purchaseDate = System.currentTimeMillis();
PurchaseRecord purchaseRecord = new PurchaseRecord();
purchaseRecord.setUserId(userId);
purchaseRecord.setQuantity(quantity);
purchaseRecord.setProductId(product.getProductId());
purchaseRecord.setPrice(product.getPrice());
BigDecimal totalPrice = new BigDecimal(quantity.toString());
totalPrice = totalPrice.multiply(product.getPrice());
purchaseRecord.setTotalPrice(totalPrice);
purchaseRecord.setPurchaseDate(new Timestamp(purchaseDate));
purchaseRecord.setNote("购买日志,时间:" + purchaseDate);
return purchaseRecord;
}
}
4、控制层实现
PurchaseProductController:
package com.ming.high_concurrency.controller;
import com.ming.high_concurrency.service.PurchaseProductService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.servlet.ModelAndView;
/**
* 项目名称: high_concurrency
* 包名称: com.ming.high_concurrency.controller
* 类名称: PurchaseProductController
* 类描述: 抢购产品控制层
* 创建人: fanglm
* 创建时间: 2019/6/19 11:53
*/
@RestController
public class PurchaseProductController {
@Autowired
private PurchaseProductService purchaseProductService;
@GetMapping("/index")
public ModelAndView indexPage() {
ModelAndView index = new ModelAndView("index");
return index;
}
@PostMapping("/purchase")
public void purchaseProduct(Integer userId, Integer productId, Integer quantity) {
Boolean success = purchaseProductService.purchaseProduct(userId, productId, quantity);
String messsge = success ? "抢购成功" : "抢购失败";
System.out.println("产品抢购结果:" + messsge);
}
}
index.jsp:
<%@ page contentType="text/html; charset=UTF-8" pageEncoding="UTF-8"%>
<!DOCTYPE html PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN" "http://www.w3.org/TR/html4/loose.dtd">
<html>
<head>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8"/>
<title>抢购产品测试页面</title>
<script type="text/javascript" src="https://code.jquery.com/jquery-3.2.1.js"></script>
</head>
<body>
<script type="text/javascript">
for (var i = 0; i < 3000; i++) {
var params = {
userId: 1,
productId: 1,
quantity: 1
};
$.post("./purchase", params)
}
</script>
<h2>抢购产品测试</h2>
</body>
</html>
5、工程测试
数据库产品表产品信息如下:

启动工程后,在浏览器地址栏访问http://localhost:8090/index,待请求执行完毕后,查看数据库产品表:

产品的库存量变为负数,出现超发现象。
解决方案:
1、利用悲观锁
在UcProductMapper.xml中的getProduct的SQL语句增加 for update。这样在数据库事务执行的过程中,就会锁定查询出来的数据,其他事务将不能再对其进行读写,这样就避免了数据的不一致。单个请求直至数据库事务完成,才会释放这个锁,其他请求才能重新得到这个锁。
<select id="getProduct" parameterType="int" resultType="Product">
select
product_id as productId, product_name as productName, stock, price, version, note
from uc_product
where
product_id = #{productId}
for update
</select>
再启动工程后,在浏览器地址栏访问http://localhost:8090/index,待请求执行完毕后,查看数据库产品表:

产品的库存量变为0,超发现象消失。
查看数据库产品抢购记录表,产品抢购完成时间为49s:

悲观锁是使用数据库内部的锁对记录进行加锁,从而使得其他事务等待以保证数据的一致性。但这样会造成过多的事务阻塞和事务上下文的切换导致业务处理缓慢,因为悲观锁中资源只能被一个事务锁(独占锁或排他锁)持有。为了解决这些问题,提高业务处理速度,提出了乐观锁方案。
2、利用乐观锁
乐观锁是一种不使用数据库锁和不阻塞线程并发的方案。其实现机制是:
一个事务开始时就读入产品的信息,并保存到旧值中。然后在做减产品库存时会读出当前版本号,然后传递给后台去做减库存操作,如果一致则更新成功,并将版本号加1,此时返回更新记录数不为0,如果为0,则表示当前版本号与数据库版本还不一致,则更新失败,这是因为其他线程已经优于当前线程修改过数据。
UcProductMapper:
/**
* 减少产品库存
* @param productId 产品编号
* @param quantity 产品购买数量
* @return 更新的行数
*/
Integer decreaseProduct(@Param("productId") Integer productId, @Param("quantity") Integer
quantity, @Param("version") Integer version);
UcProductMapper.xml:
<update id="decreaseProduct">
update uc_product
set stock = stock - #{quantity}, version = version + 1
where
product_id = #{productId} and version = #{version}
</update>
PurchaseProductServiceImpl:
@Override
@Transactional(isolation = Isolation.READ_COMMITTED)
public Boolean purchaseProduct(Integer userId, Integer productId, Integer quantity) {
Product product = productMapper.getProduct(productId);
if (product != null && product.getStock() < quantity) {
return false;
}
Integer version = product.getVersion();
Integer result = productMapper.decreaseProduct(productId, quantity, version);
if (result == 0) {
return false;
}
PurchaseRecord purchaseRecord = wrapperPurchaseRecord(userId, quantity, product);
purchaseRecordMapper.insertPurchaseRecord(purchaseRecord);
return true;
}
启动工程后,在浏览器地址栏访问http://localhost:8090/index,待请求执行完毕后,查看数据库产品表和产品抢购记录表:


产品的库存量为525,已抢购产品数量为475,两者和为1000,表明未发生超发现象。但是,由于加入了版本号的判断,所以大量的请求得到了抢购失败的结果。而悲观锁实现方案,确保每一个请求都会得到抢购成功的结果,直到产品库存量为0。
为了处理乐观锁引起的请求失败问题,乐观锁引入了重入机制,也就是一旦更新失败,就重新做一次。其原理是一旦发现版本号被更新,不是结束请求,而是重新做一次乐观锁流程,直到成功为止。但是则个流程的重入会带来一个问题,那就是造成大量的SQL被执行,在高并发的场景下,会给数据库带来很大的压力。为了克服这个问题,一般有两种解决方案:重入时间限制和重入次数限制。
1、重入时间限制
PurchaseProductServiceImpl:
@Override
@Transactional(isolation = Isolation.READ_COMMITTED)
public Boolean purchaseProduct(Integer userId, Integer productId, Integer quantity) {
Long begainTime = System.currentTimeMillis();
while (true) {
Long endTime = System.currentTimeMillis();
if (endTime - begainTime > 100) {
return false;
}
Product product = productMapper.getProduct(productId);
if (product != null && product.getStock() < quantity) {
return false;
}
Integer version = product.getVersion();
Integer result = productMapper.decreaseProduct(productId, quantity, version);
if (result == 0) {
return false;
}
PurchaseRecord purchaseRecord = wrapperPurchaseRecord(userId, quantity, product);
purchaseRecordMapper.insertPurchaseRecord(purchaseRecord);
return true;
}
}
启动工程后,在浏览器地址栏访问http://localhost:8090/index,待请求执行完毕后,查看数据库产品表和产品抢购记录表:


产品库存量为0,产品抢购记录1000条,未出现超发现象,产品抢购完成时间为49s。
2、重入次数限制
@Override
@Transactional(isolation = Isolation.READ_COMMITTED)
public Boolean purchaseProduct(Integer userId, Integer productId, Integer quantity) {
for (int i = 0; i < 3; i++) {
Product product = productMapper.getProduct(productId);
if (product != null && product.getStock() < quantity) {
return false;
}
Integer version = product.getVersion();
Integer result = productMapper.decreaseProduct(productId, quantity, version);
if (result == 0) {
continue;
}
PurchaseRecord purchaseRecord = wrapperPurchaseRecord(userId, quantity, product);
purchaseRecordMapper.insertPurchaseRecord(purchaseRecord);
return true;
}
return false;
}
启动工程后,在浏览器地址栏访问http://localhost:8090/index,待请求执行完毕后,查看数据库产品表和产品抢购记录表:


产品库存量为0,产品抢购记录1000条,未出现超发现象,产品抢购完成时间为47s。