Demo项目地址
本篇博客参看书籍[深入浅出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://82.156.204.179:3306/test?useUnicode=true&characterEncoding=UTF-8&useSSL=false&serverTimezone=Asia/Shanghai
spring.datasource.username=root
spring.datasource.password=zaq12wsx
spring.datasource.type=com.zaxxer.hikari.HikariDataSource
##Hikari连接池配置 ------> 详细配置请访问:https://github.com/brettwooldridge/HikariCP
#此属性控制从池返回的连接的默认事务隔离级别。如果未指定此属性,则使用JDBC驱动程序定义的默认事务隔离级别。2-->READ_COMMITTED(读已提交)
spring.datasource.hikari.transaction-isolation=2
#此属性控制HikariCP尝试在池中维护的最小空闲连接数。如果空闲连接低于此值,并且池中的总连接数小于maximumPoolSize,HikariCP将尽最大努力快速高效地添加其他连接。但是,为了获得最佳性能和响应峰值需求,我们建议不要设置此值,而是允许HikariCP充当固定大小的连接池。默认值:与maximumPoolSize相同
spring.datasource.hikari.minimum-idle=5
#这是在从池向您提供连接之前执行的查询,以验证与数据库的连接是否仍处于活动状态
spring.datasource.hikari.connection-test-query=SELECT 1 FROM DUAL
#此属性控制池允许达到的最大大小,包括空闲连接和正在使用的连接。基本上,该值将确定到数据库后端的实际连接的最大数量。
spring.datasource.hikari.maximum-pool-size=20
#此属性控制从池返回的连接的默认自动提交行为。
spring.datasource.hikari.auto-commit=true
#此属性控制允许连接在池中处于空闲状态的最长时间。此设置仅适用于定义为小于maximumPoolSize的MinimumMidle。一旦池达到最小连接数,空闲连接将不会失效。
spring.datasource.hikari.idle-timeout=30000
#此属性表示连接池的用户定义名称,主要出现在日志和JMX管理控制台中,用于标识池和池配置
spring.datasource.hikari.pool-name=MyHikariCP
#此属性控制池中连接的最大生存期。正在使用的连接永远不会失效,只有当它关闭时才会被删除
spring.datasource.hikari.max-lifetime=60000
#此属性控制客户端(即您)等待池连接的最大毫秒数。如果在连接不可用的情况下超过此时间,将抛出SQLException。
spring.datasource.hikari.connection-timeout=30000
##mybatis相关配置
mybatis.mapper-locations=classpath*:/mapper/*.xml
#下划线转驼峰
mybatis.configuration.map-underscore-to-camel-case=true
#别名
#mybatis.type-aliases-package=com.mashirro.sale.entity
#logging.level设置某个包下日志输出级别
logging.level.com.mashirro.sale.mapper=debug
五.使用Apache Jmeter进行并发测试
1.数据库造一条产品数据
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 解决方案。
八.使用 Redis 处理高并发
1.在高并发的环境中,有时候数据库的方案过于缓慢,因为数据库是一个写入磁盘的过程,这个速度显然没有写入内存的 Redis 快。Redis 的机制也能够帮助我们克服超发现象,但是,因为其命令方式运算能力比较薄弱,所以往往采用 Redis Lua 去代替它原有的命令方式。Redis Lua 在 Redis 的执行中是具备原子性的,当它被执行时不会被其他客户端发送过来的命令打断,通过这样一种机制可以在需要高并发的环境下考虑使用 Redis 去代替数据库作为响应用户的数据载体。因为 Redis 的性能是数据库的数倍甚至数十倍,所以可以极大地提高数据响应的性能。但是 Redis 存储的不稳定却是一个要处理的问题,所以还需要有一定的机制将 Redis 存储的数据刷入数据库中。这里的设计分为以下两步:
- 先使用 Redis 响应高并发用户的请求。注意,这一步并不涉及任何数据库的操作,而只是涉及 Redis。因为 Redis 性能比数据库要快得多,所以在响应高并发时会比数据库要快得多。
- 因为 Redis 的存储不稳定,所以需要及时地将数据保存到数据库中。这里将会启用定时任务去查找 Redis 保存的购买信息,将它们保存到数据库中(这一步可以通过定时任务去同步我们这里不展示了)。
2.引入spring data redis依赖
<!-- spring‐boot‐starter‐data‐redis,默认解析Lettuce -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring‐boot‐starter‐data‐redis</artifactId>
</dependency>
<!-- Spring提供LettuceConnectionFactory来获取连接。要获得池连接工厂,我们需要在类路径上提供commons-pool2 -->
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-pool2</artifactId>
</dependency>
3.编写lua脚本
-- 先将用户购买的产品编号保存到集合中
redis.call('sadd', KEYS[1], ARGV[2])
-- 购买产品的list
local productPurchaseList = KEYS[2]..ARGV[2]
-- 用户编号
local userId = ARGV[1]
-- 产品id
local product = 'product_'..ARGV[2]
-- 购买产品数量
local quantity = tonumber(ARGV[3])
-- 当前库存
local stock = tonumber(redis.call('hget', product, 'stock'))
-- 单价
local price = tonumber(redis.call('hget', product, 'price'))
-- 购买日期
local purchase_date = ARGV[4]
-- 如果库存不足,返回0
if stock < quantity then return false end
-- 如果库存充足,减库存
stock = stock - quantity
redis.call('hset', product, 'stock', tostring(stock))
-- 计算总价
local sum = price * quantity
-- 拼接购买记录数据
local purchaseRecord = userId..','..quantity..','..sum..','..price..','..purchase_date
-- 将购买记录保存到list里
redis.call('rpush', productPurchaseList, purchaseRecord)
-- 返回成功
return true
4.在启动类配置加载lua脚本
@Bean
public RedisScript<Boolean> script() throws IOException {
ScriptSource scriptSource = new ResourceScriptSource(new ClassPathResource("scripts/checkandset.lua"));
return RedisScript.of(scriptSource.getScriptAsString());
}
5.在PurchaseServiceImpl类编写purchaseRedis方法
@Autowired
private StringRedisTemplate stringRedisTemplate;
// Redis购买记录list前缀
private static final String PURCHASE_PRODUCT_LIST = "purchase_list_";
// 抢购商品set
private static final String PRODUCT_SCHEDULE_SET = "product_schedule_set";
@Autowired
private RedisScript<Boolean> redisScript;
@Override
public Boolean purchaseRedis(String userId, String productId, int quantity) {
//购买时间
long purchaseDate = System.currentTimeMillis();
List<String> keys = Arrays.asList(PURCHASE_PRODUCT_LIST, PRODUCT_SCHEDULE_SET);
List<String> args = Arrays.asList(userId, productId, quantity + "", purchaseDate + "");
return stringRedisTemplate.execute(redisScript, keys, args);
}
6.在PurchaseController改一下调用purchaseRedis方法,使用Apache Jmeter进行并发测试。