本篇博客参看书籍[深入浅出Spring Boot 2.x ,杨开振 ,2018.8]
通过抢购商品的实践阐述高并发与锁的问题。这里假设电商网站抢购的场景,电商网站往往存在很多的商品,有些商品会以低价限量推销,并且会在推销之前做广告以吸引网站会员购买。如果是十分热销的商品,就会有大量的会员等待在商品推出的那一刻,打开手机、电脑和平板电脑点击抢购,这个瞬间就会给网站带来很大的并发量,这便是一个高并发的场景,处理这些并发是互联网常见的场景之一。
搭建一个基本的增删改查架子,省略
执行一次购买的流程是,首先判定产品表的产品有没有足够的库存支持用户的购买,如果有则对产品表执行减库存,然后再将购买信息插入到购买记录表中;如果库存不足,则返回交易失败的信息。
CREATE TABLE `product` (
`id` varchar(38) NOT NULL COMMENT '主键',
`product_name` varchar(60) NOT NULL COMMENT '产品名称',
`stock` int(10) NOT NULL COMMENT '库存',
`price` decimal(16,2) NOT NULL COMMENT '单价',
`version` int(10) NOT NULL DEFAULT '0' COMMENT '版本号',
`note` varchar(255) DEFAULT NULL COMMENT '备注',
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT='产品信息表';
CREATE TABLE `purchase_record` (
`id` varchar(38) NOT NULL COMMENT '主键',
`user_id` varchar(38) NOT NULL COMMENT '用户id',
`product_id` varchar(38) NOT NULL COMMENT '产品id',
`price` decimal(16,2) NOT NULL COMMENT '价格',
`quantity` int(12) NOT NULL COMMENT '数量',
`sumprice` decimal(16,2) NOT NULL COMMENT '总价',
`note` varchar(255) DEFAULT NULL COMMENT '备注',
`create_time` datetime DEFAULT NULL COMMENT '创建时间',
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT='购买信息表';
1.业务层接口及实现类开发PurchaseService/PurchaseServiceImpl,实现类除了实现业务逻辑还要留意数据库事务的处理。purchase 方法上标注了@Transactional,这就意味着会启用数据库事务。对于事务,Spring Boot 会自动地根据配置来创建事务管理器,所以这里并不需要显式地配置事务管理器,而默认隔离级别的选择可以在 Spring Boot 的配置文件中处理。
public interface PurchaseService {
/**
* 处理购买业务
* @param userId 用户id
* @param productId 产品id
* @param quantity 购买数量
* @return 成功or失败
*/
Boolean purchase(String userId, String productId, int quantity);
}
@Service
public class PurchaseServiceImpl implements PurchaseService {
@Autowired
private PurchaseRecordMapper purchaseRecordMapper;
@Autowired
private ProductMapper productMapper;
//启用Spring数据库事务机制
@Transactional
@Override
public Boolean purchase(String userId, String productId, int quantity) {
//获取产品
Product product = productMapper.selectByPrimaryKey(productId);
//比较库存和购买数量
if (product.getStock() < quantity) {
return false;
}
//扣减库存
productMapper.decreaseProduct(quantity, productId);
PurchaseRecord purchaseRecord = initPurchaseRecord(userId, product, quantity);
purchaseRecordMapper.insertSelective(purchaseRecord);
return true;
}
//初始化购买记录
private PurchaseRecord initPurchaseRecord(String userId, Product product, int quantity) {
PurchaseRecord pr = new PurchaseRecord();
pr.setId(UUID.randomUUID().toString());
pr.setNote("购买日志,时间: " + System.currentTimeMillis());
pr.setProductId(product.getId());
pr.setPrice(product.getPrice());
pr.setQuantity(quantity);
BigDecimal sumPrice = product.getPrice().multiply(new BigDecimal(quantity));
pr.setSumprice(sumPrice);
pr.setUserId(userId);
pr.setCreateTime(new Date());
return pr;
}
}
@Mapper
public interface ProductMapper {
......
/**
* 减库存
* @return
*/
int decreaseProduct(@Param("quantity") int quantity, @Param("id") String id);
}
<update id="decreaseProduct">
UPDATE product SET stock = stock - #{quantity,jdbcType=INTEGER}
WHERE id = #{id,jdbcType=VARCHAR}
update>
2.控制层开发Rest风格
@RestController
public class PurchaseController {
@Autowired
private PurchaseService purchaseService;
@PostMapping("/purchase")
public Result purchase(String userId, String productId, Integer quantity) {
Boolean success = purchaseService.purchase(userId, productId, quantity);
if (success) {
return Result.success("抢购成功!", null);
} else {
return Result.error("抢购失败!");
}
}
}
3.配置文件application.properties
server.port=9001
#SpringBoot默认数据源 HikariDataSource
spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver
spring.datasource.url=jdbc:mysql://localhost:3306/shiro?&serverTimezone=UTC
spring.datasource.username=root
spring.datasource.password=root
#隔离级别:READ_COMMITTED(读已提交)
spring.datasource.hikari.transaction-isolation=2
#mybatis相关配置
mybatis.mapper-locations=classpath*:/mapper/*.xml
#logging.level设置某个包下日志输出级别
logging.level.com.mashirro.sale.mapper=debug
2.使用Apache Jmeter进行并发测试:模拟3000人抢购库存1000的眼霜场景,结果如下图
3.可以看到产品的库存变为了−8,这说明在高并发的环境下,系统出现了超发的现象,也就是原有的1000件产品,发放了1008件,这就是高并发存在的超发现象,这是一种错误。在高并发的环境下,除了考虑超发的问题外,还应该考虑性能问题,因为速度不能太慢,导致用户体验不佳而影响用户的体验。purchase_record表可以查看最后一条购买插入记录和第一条插入记录的时间相差时间。
4.超发现象分析
在线程1和线程2开始阶段都同时读入库存为1,但是在T3时刻线程1扣减库存后商品就没有库存了,线程2此时并不会感知线程1的这个操作,而是继续按自己原有的判断,按照库存为1进行扣减库存,这样就出现了T4时刻库存为−1,而T6时刻错误记录的超发场景。为了克服超发的现象,当前企业级的开发提出了乐观锁、悲观锁和使用Redis等多种方案
使用悲观锁处理高并发超发的问题。在高并发中出现超发现象,根本在于共享的数据(本章的例子是商品库存)被多个线程所修改,无法保证其执行的顺序。如果一个数据库事务读取到产品后,就将数据直接锁定,不允许别的线程进行读写操作,直至当前数据库事务完成才释放这条数据的锁,则不会出现之前看到的超发问题。
1.请注意,这里的代码与修改之前的代码并没有太大的不一样,只是在 SQL 的最后加入了 for update 语句。这样在数据库事务执行的过程中,就会锁定查询出来的数据,其他的事务将不能再对其进行读写,这样就避免了数据的不一致。单个请求直至数据库事务完成,才会释放这个锁,其他的请求才能重新得到这个锁。
2.重新使用Apache Jmeter进行并发测试:模拟3000人抢购库存1000的眼霜场景
3.说明结果是正确的,这说明上面的代码已经克服了超发现象。但是还存在一个问题,就是性能。性能比不加锁差,这里存在着性能的丢失。来分析一下原因。首先,当开启一个事务执行 for update 语句时,数据库就会给这条记录加入锁,让其他的事务等待,直至事务结束才会释放锁。假设事务 2 得到了商品信息的锁,那么事务 1,3,…,n 就必须等待持有商品信息的事务 2 结束然后释放商品信息,才能去抢夺商品信息,这样就有大量的线程被挂起和等待,所以性能就低下了。
4.悲观锁是使用数据库内部的锁对记录进行加锁,从而使得其他事务等待以保证数据的一致。但这样会造成过多的等待和事务上下文的切换导致缓慢,因为悲观锁中资源只能被一个事务锁持有,所以也被称为独占锁或者排他锁。为了解决这些问题,提高运行效率,一些开发者提出其他的方案,那就是乐观锁了。
乐观锁是一种不使用数据库锁和不阻塞线程并发的方案。
1.一个线程一开始先读取既有的商品库存数据,保存起来,我们把这些旧数据称为旧值,然后去执行一定的业务逻辑,等到需要对共享数据做修改时,会事先将保存的旧值库存与当前数据库的库存进行比较,如果旧值与当前库存一致,它就认为数据没有被修改过,否则就认为数据已经被修改过,当前计算将不被信任,所以就不再修改任何数据。
2.上述的CAS方案会引发一种ABA的问题,请自行百度ABA问题论述。为了克服这个问题,一些开发者引入了一些规则,典型的如增加版本号(version),并且规定:只要操作过程中修改共享值,无论是业务正常、回退还是异常,版本号(version)只能递增,不能递减。
3.代码实现
(1)去掉悲观锁 sql 中的 for update 语句,这样就没有之前分析悲观锁的阻塞其他线程并发的问题了。
(2)修改减库存sql,减库存在更新库存的同时也会递增版本号。此外,这里的条件除了产品id外,还有版本号,通过这个判断就可以让当前执行的事务知道,有没有别的事务已经修改过数据,一旦版本号判断失败,则什么数据也不会触发更新。
<update id="decreaseProduct">
UPDATE
product
SET
stock = stock - #{quantity,jdbcType=INTEGER},
version = version + 1
WHERE id = #{id,jdbcType=VARCHAR} AND version = #{version,jdbcType=INTEGER}
update>
/**
* 减库存
*/
int decreaseProduct(@Param("quantity") int quantity, @Param("id") String id, @Param("version") int version);
(3)修改业务代码PurchaseServiceImpl,从代码中可以看到,一个事务的开始就读入了产品的信息,并保存到旧值中。然后在做减库存时会先读出当前版本号,然后传递给后台的 SQL 去减库存,在 SQL 更新时会比较当前线程版本号和数据库版本号,如果一致则更新成功,并将版本号(version)加一,此时就会返回更新数据的条数不为 0,如果为 0,则表示当前线程版本号与数据库版本号不一致,则更新失败,此原因是其他线程已经先于当前线程修改过数据。
//启用Spring数据库事务机制
@Transactional
@Override
public Boolean purchase(String userId, String productId, int quantity) {
//获取产品(线程旧值)
Product product = productMapper.selectByPrimaryKey(productId);
//比较库存和购买数量
if (product.getStock() < quantity) {
//库存不足
return false;
}
//获取当前版本号
Integer version = product.getVersion();
//扣减库存,同时将当前版本号发送给后台进行比较
int result = productMapper.decreaseProduct(quantity, productId, version);
if (result == 0) {
//如果更新数据失败,说明数据在多线程中被其他线程修改,导致失败返回
return false;
}
//初始化购买记录
PurchaseRecord purchaseRecord = initPurchaseRecord(userId, product, quantity);
//插入购买记录
purchaseRecordMapper.insertSelective(purchaseRecord);
return true;
}
(4)测试如下图,我们会发现3000次请求后,产品为什么还会有剩余? 经检查库存剩余量加上购买记录数正好等于1000。因为加入了版本号的判断,所以大量的请求得到了失败的结果,而且这个失败率有点高。下面我们要处理这个问题。
(5)在上面的测试中,可以看到大量的请求更新失败。为了处理这个问题,乐观锁还可以引入重入机制,也就是一旦更新失败,就重新做一次,所以有时候也可以称乐观锁为可重入的锁。其原理是一旦发现版本号被更新,不是结束请求,而是重新做一次乐观锁流程,直至成功为止。但是这个流程的重入会带来一个问题,那就是可能造成大量的 SQL 被执行。例如,原本一个请求需要执行 3 条SQL,如果需要重入 4 次才能成功,那么就会有十几条 SQL 被执行,在高并发场景下,会给数据库带来很大的压力。为了克服这个问题,一般会考虑使用限制时间或者重入次数的办法,以压制过多的 SQL 被执行。下面通过代码来讨论重入的机制。
(6)使用时间戳限制重入的乐观锁实现抢购商品。将一个请求限制 100 ms 的生存期,如果在 100 ms 内发生版本号冲突而导致不能更新的,则会重新尝试请求,否则视为请求失败。测试如下图,可以看到,商品库存没有,这说明之前大量的请求失败的情况没有了。但是按时间戳的重入也有一个弊端,就是系统会随着自身的忙碌而大大减少重入的次数。因此有时候也会采用按次数重入的机制
//启用Spring数据库事务机制
@Transactional
@Override
public Boolean purchase(String userId, String productId, int quantity) {
//开始时间
long start = System.currentTimeMillis();
//循环尝试直至成功
while (true) {
long end = System.currentTimeMillis();
//如果循环时间大于100ms,返回终止循环
if (end - start > 100) {
return false;
}
//获取产品(线程旧值)
Product product = productMapper.selectByPrimaryKey(productId);
//比较库存和购买数量
if (product.getStock() < quantity) {
//库存不足
return false;
}
//获取当前版本号
Integer version = product.getVersion();
//扣减库存,同时将当前版本号发送给后台进行比较
int result = productMapper.decreaseProduct(quantity, productId, version);
if (result == 0) {
//如果更新数据失败,说明数据在多线程中被其他线程修改导致失败,则通过循环重入尝试购买产品
continue;
}
//初始化购买记录
PurchaseRecord purchaseRecord = initPurchaseRecord(userId, product, quantity);
//插入购买记录
purchaseRecordMapper.insertSelective(purchaseRecord);
return true;
}
}
(7)使用限定次数重入的乐观锁。测试如下图,可以看到请求失败的次数也会大大地降低。
//启用Spring数据库事务机制
@Transactional
@Override
public Boolean purchase(String userId, String productId, int quantity) {
//开始时间
long start = System.currentTimeMillis();
//限定循环三次
for (int i = 0; i < 3; i++) {
//获取产品(线程旧值)
Product product = productMapper.selectByPrimaryKey(productId);
//比较库存和购买数量
if (product.getStock() < quantity) {
//库存不足
return false;
}
//获取当前版本号
Integer version = product.getVersion();
//扣减库存,同时将当前版本号发送给后台进行比较
int result = productMapper.decreaseProduct(quantity, productId, version);
if (result == 0) {
//如果更新数据失败,说明数据在多线程中被其他线程修改导致失败,则通过循环重入尝试购买产品
continue;
}
//初始化购买记录
PurchaseRecord purchaseRecord = initPurchaseRecord(userId, product, quantity);
//插入购买记录
purchaseRecordMapper.insertSelective(purchaseRecord);
return true;
}
return false;
}
(8)总结一下乐观锁的机制:乐观锁是一种不使用数据库锁的机制,并且不会造成线程的阻塞,只是采用多版本号机制来实现。但是,因为版本的冲突造成了请求失败的概率剧增,所以这时往往需要通过重入的机制将请求失败的概率降低。但是,多次的重入会带来过多执行 SQL 的问题。为了克服这个问题,可以考虑使用按时间戳或者限制重入次数的办法。可见乐观锁还是一个相对比较复杂的机制。目前,有些企业已经开始使用 NoSQL 来处理这方面的问题,其中当属 Redis 解决方案。
略