在实际生产环境中,往往会遇到热门的产品,导致短时间内大量用户涌入。比如某款新手机上市,会在某个时间点开抢,这时就需要面对这个高并发现象。
我们就通过简单的模拟实验,复现这个场景并解决。
模拟过程(这里针对单一商品,一次只能购买一个)
直接使用Mybatis作为我们的持久层框架。
1、搭建Mybatis环境(参考:Mybatis快速开始)。建立如下两张表和相应持久层对象。
商品明细表(tb_product)
购买记录表(tb_purchase_record)
2、到层接口和对应的mapper文件(就查库存、减库存、增加购买记录三个相应的方法)
public interface ProductDao {
//根据id查询库存
int getProductRepertoryById(Integer productId);
//更改库存
int updateProductRepertoryById(Integer productId);
}
update tb_product set product_repertory = product_repertory - 1
where product_id = #{productId};
public interface PurchaseRecordDao {
//增加购买记录
int savePurchaseRecord(PurchaseRecord purchaseRecord);
}
insert into tb_purchase_record (record_person, record_time, product_id)
values
(#{recordPerson}, #{recordTime}, #{product.productId});
3、模拟场景(50个线程抢购30件商品,还可以自行使用其它的方式实现模拟场景哦)
单例下获取SqlSessionFactory
public class MybatisFactory {
private static SqlSessionFactory sqlSessionFactory = null;
public static SqlSessionFactory getSqlSessionFactory() {
if(sqlSessionFactory == null) {
synchronized(SqlSessionFactory.class) {
if(sqlSessionFactory == null) {
InputStream is = MybatisFactory.class.getResourceAsStream("/mybatis/mybatis-config.xml");
sqlSessionFactory = new SqlSessionFactoryBuilder().build(is);
}
}
}
return sqlSessionFactory;
}
}
执行对象
public class Execute implements Runnable {
@Override
public void run() {
SqlSessionFactory sqlSessionFactory = MybatisFactory.getSqlSessionFactory();
SqlSession sqlSession = sqlSessionFactory.openSession(TransactionIsolationLevel.READ_COMMITTED);
ProductDao productDao = sqlSession.getMapper(ProductDao.class);
PurchaseRecordDao purchaseRecordDao = sqlSession.getMapper(PurchaseRecordDao.class);
int productRepertory = productDao.getProductRepertoryById(1);
if(productRepertory > 0) {//如果库存大于0
productDao.updateProductRepertoryById(1);//减库存
PurchaseRecord purchaseRecord = new PurchaseRecord();
purchaseRecord.setRecordPerson(Thread.currentThread().getName());
purchaseRecord.setRecordTime(new Date());
purchaseRecord.setProduct(new Product(1, null, null));
purchaseRecordDao.savePurchaseRecord(purchaseRecord);//增加购买记录
}
sqlSession.commit();
sqlSession.close();
}
}
测试代码(电脑配置高,可以适当加点数量)
public class Test{
public static void main(String[] args) {
ExecutorService es = Executors.newCachedThreadPool();
for(int i=0;i<50;i++) {
es.execute(new Execute());
}
es.shutdown();
}
}
4、执行结果
执行
SELECT COUNT(*) FROM tb_purchase_record;
执行后可以看见,刚开始商品有30件,而且是商品库存大于0才能卖商品,购买记录也是39条。
而且是限制了读取到的库存大于0后才能执行购买操作减库存
还请注意一个地方
我获取的事务,是读已提交事务。就是说,当前一个线程将库存修改完,且提交事务后,下一个事务才会读取,不会出现脏读现象,但是,大量的用户同时涌入并不会排着队,一个一个访问,等A执行完毕,B在执行.......都是同时在访问。那么问题就出在:
在某一时间点,多个线程同时读取到商品库存大于0,那么这些线程也就都会随之执行减库存操作。也就导致了出现超卖现象。
接下来就解决这个问题。
按照前面的分析,既然大量的线程不是排着队在访问,我们就让他们排着队访问。当某一个线程,最先访问到目标商品的记录之后,我们就将其锁定,等待其操作完成之后释放锁,再由下一个线程进入。就像去一间房屋里夺宝一样,管你门外千百人,只要先进入房间的人,将门锁上,外面的人根本进不来。程序中锁的概念也是这么由来,就是在某一节点,给定一个标识,当存在这个标识之后,只有与之对应的线程才能在当前节点活动,其它线程发现了这个标识就不能活动,只有释放这个标识之后,其它线程才能从新获取标识再活动。
使用悲观锁,是在数据库层面上的,只需要将上述示例改动一个地方,如下:
在查询库存的时候,加上 for update语句。这样,在当前事务的执行过程中,就会锁定该行数据,其它事务就不能再对该行数据进行读写,直到持锁事务执行完成之后,才会释放锁,让其它事务进行读写,所以悲观锁又称为独占锁和排它锁。
接下来,恢复原数据之后,我们重新执行测试用例(注意先,记录下购买记录中的最大最小时间差,在后面比较下性能)。
执行完之后,会发现
超卖现象没有了,和购买记录中的统计信息也吻合。但是,再不加锁的情况下,50个线程购买30件商品几乎是在同一时间完成的,枷锁之后,最后一条记录和第一条记录,前后查了2s(当然取决于你电脑的性能,如果没有时间差,可以将线程数量和商品库存调大一点作加锁和不加锁的测试),我反复两次都是2S,说明不是巧合,从执行过程分析,也明确这不是巧合,因为有一个加锁和解锁的过程。这里是50对30,那提高数万倍之后,这个效率就可怕了,所以,为了解决效率,又出现了悲观锁。
题外话:
因为悲观锁锁住的是单行数据,所以属于行级锁,在数据库中,行级锁有两种表现形式。
一种称为共享锁:SELECT * FROM table_name WHERE ... LOCK IN SHARE MODE;
一种称为排他锁:SELECT * FROM table_name WHERE ... FOR UPDATE(示例的实现方式);
注意:对于UPDATE、DELETE和INSERT(DML)语句,InnoDB会自动给涉及数据集加排他锁(称为隐式加锁),不要尝试在DML语句后面手动加锁,
对于普通SELECT语句,InnoDB不会加任何锁,才可以在需要的地方手动加锁(称为显式加锁)。
共享锁主要用在需要数据依存关系时来确认某行记录是否存在,并确保没有事务对这个记录进行update或者delete。如果当前事务也需要对该记录进行更新操作,则很可能造成死锁,所以,对于锁定记录后需要进行更新操作的事务,应该使用排他锁。
前面分析,悲观锁的效率损失在加锁和解锁过程造成线程阻塞引起的,那么乐观锁就是一种不使用数据库锁和不阻塞线程并发的方案。因此乐观锁也称为非独占锁或无阻塞锁。
针对上述问题,乐观锁的实现方式就是,对需要被并发访问的数据加一个标识,姑且称为version(int 类型),因为该行数据没有加锁,所以所有线程都有可能在同一时间节点访问到该数据,对于获取到该行数据的线程,根据version保存起来(假定最初的version值为1,然后只要有线程对该行数据作了修改,version就依次递增,2,3,4....保证version不会重复)。那么每个获取到该行数据的线程就保存了version为1的整行数据(这里成为旧值)。当某个线程需要对该行数据进行update的时候,就拿出旧值中的version和该行数据的当前时间节点的version值进行比较,如果version没有变,则证明该行数据没有被其它线程修改过,就可以修改。假如线程A获取的版本为1,线程B获取的版本也是1,但是线程B执行更快,对该行数据作了修改,那么version就应该变为新version,为2.那么A线程要对该行数据作修改时,会拿就version(1)与新version(2)作比较,值不一样,那么线程A就放弃此次操作。
实现过程:
1.更改tb_product表的结构,增加一个version字段,并更改相应的持久化对象,删除原有的悲观锁(for update)
2、在减库存的操作的地方,增加乐观锁的SQL表现形式
update tb_product set product_repertory = product_repertory - 1,
version = version+1
where product_id = #{productId} and version = #{old_version};
3、相应的减库存操作的dao层接口也作改变,
//更改库存
int updateProductRepertoryById(Integer productId, Integer old_version);
4、查询库存的操作,更改为将库存和version一起查询出来,相应的dao层接口也作更改
//根据id查询库存和version
int getProductById(Integer productId);
4、测试用例和执行结果(还是针对同一件商品,按一次购买一个测试,可以自行更改数量)
public class Execute implements Runnable {
@Override
public void run() {
SqlSessionFactory sqlSessionFactory = MybatisFactory.getSqlSessionFactory();
//注意这里要读已提交,避免B线程对version作更改为2的时候,还未commit,却被A读取到version为1。
SqlSession sqlSession = sqlSessionFactory.openSession(TransactionIsolationLevel.READ_COMMITTED);
ProductDao productDao = sqlSession.getMapper(ProductDao.class);
PurchaseRecordDao purchaseRecordDao = sqlSession.getMapper(PurchaseRecordDao.class);
Product product = productDao.getProductById(1);//获取库存和version,作为旧值
if(product.getProductRepertory() > 0) {//如果库存大于0
//如果库存大于0则证明可以购买,将要购买的商品id和旧值version发送到数据库作比较
int result = productDao.updateProductRepertoryById(1, product.getVersion());
if(result != 1) {
//说明数据库的version已经被更改,即是在并发情况下被其它线程作了修改,因为旧version和数据库version已经匹配不上
//当前线程就放弃此次操作
return;
}
//如果购买成功,即result==1,就插入相应的购买记录
PurchaseRecord purchaseRecord = new PurchaseRecord();
purchaseRecord.setRecordPerson(Thread.currentThread().getName());
purchaseRecord.setRecordTime(new Date());
purchaseRecord.setProduct(new Product(1, null, null,null));
purchaseRecordDao.savePurchaseRecord(purchaseRecord);//增加购买记录
}
sqlSession.commit();
sqlSession.close();
}
}
通过上面的测试结果分析,超卖现象没有了,但是由于很多线程的旧值version和数据库的version不匹配,导致很多事务失败了,而且失败率还很高。(实际生产环境中,也许由于网络、业务更加复杂的情况下,线程执行效率没这么高,成功率会稍微高点,但也是不允许的)。
为了处理这个问题,乐观锁还可以引入重入机制,也就是一旦更新失败,就重新执行一遍,这里就是重做查库存和减库存的操作。所以有时候也可以称乐观锁为可重入锁。
但是,这个重入机制,不是不限制的重复,比如某个事务需要执行3条SQL完成,但是重复5次的话,相当于要执行15条SQL,那么在高并发场景下必会对数据库造成更大的压力,一般会考虑限制时间或重入次数,以压制这个问题。
这里就限定100ms,在这100ms内无限重复,如果100s还不能成功执行事务,则放弃。
这里就对执行逻辑作更改。
public class Execute implements Runnable {
@Override
public void run() {
SqlSessionFactory sqlSessionFactory = MybatisFactory.getSqlSessionFactory();
//注意这里要读已提交,避免B线程对version作更改为2的时候,还未commit,却被A读取到version为1。
SqlSession sqlSession = sqlSessionFactory.openSession(TransactionIsolationLevel.READ_COMMITTED);
ProductDao productDao = sqlSession.getMapper(ProductDao.class);
PurchaseRecordDao purchaseRecordDao = sqlSession.getMapper(PurchaseRecordDao.class);
long start_time = System.currentTimeMillis();//记录一个开始时间
while(true) {
long end_time = System.currentTimeMillis();//每次重试前验证时间戳
if((end_time - start_time) > 100) {
break;//超时就放弃
}
Product product = productDao.getProductById(1);//获取库存和version,作为旧值
if(product.getProductRepertory() > 0) {//如果库存大于0
//如果库存大于0则证明可以购买,将要购买的商品id和旧值version发送到数据库作比较
int result = productDao.updateProductRepertoryById(1, product.getVersion());
if(result != 1) {
//说明数据库的version已经被更改,即是在并发情况下被其它线程作了修改,因为旧version和数据库version已经匹配不上
//当前线程就放弃此次操作
continue;//失败就继续执行
}
//如果购买成功,即result==1,就插入相应的购买记录
PurchaseRecord purchaseRecord = new PurchaseRecord();
purchaseRecord.setRecordPerson(Thread.currentThread().getName());
purchaseRecord.setRecordTime(new Date());
purchaseRecord.setProduct(new Product(1, null, null,null));
purchaseRecordDao.savePurchaseRecord(purchaseRecord);//增加购买记录
}
}
sqlSession.commit();
sqlSession.close();
}
}
利用无限循环做重试,每个重试前验证是否超时。
执行结果
这里成功率是提高了,如果将时间戳再调大点(肯定不能一味的大,这里因为业务不复杂,所以线程执行快,失败率较高),成功率肯定更高。但是时间戳的弊端就是,随着系统自身的忙碌,而大大减少重入次数(假定循环体中逻辑很复杂,那么执行一次很慢),所以具体生产中只能择优选择。
public class Execute implements Runnable {
@Override
public void run() {
SqlSessionFactory sqlSessionFactory = MybatisFactory.getSqlSessionFactory();
//注意这里要读已提交,避免B线程对version作更改为2的时候,还未commit,却被A读取到version为1。
SqlSession sqlSession = sqlSessionFactory.openSession(TransactionIsolationLevel.READ_COMMITTED);
ProductDao productDao = sqlSession.getMapper(ProductDao.class);
PurchaseRecordDao purchaseRecordDao = sqlSession.getMapper(PurchaseRecordDao.class);
for(int i=0;i<3;i++) {//限定重入次数
Product product = productDao.getProductById(1);//获取库存和version,作为旧值
if(product.getProductRepertory() > 0) {//如果库存大于0
//如果库存大于0则证明可以购买,将要购买的商品id和旧值version发送到数据库作比较
int result = productDao.updateProductRepertoryById(1, product.getVersion());
if(result != 1) {
//说明数据库的version已经被更改,即是在并发情况下被其它线程作了修改,因为旧version和数据库version已经匹配不上
//当前线程就放弃此次操作
continue;//失败就继续执行
}
//如果购买成功,即result==1,就插入相应的购买记录
PurchaseRecord purchaseRecord = new PurchaseRecord();
purchaseRecord.setRecordPerson(Thread.currentThread().getName());
purchaseRecord.setRecordTime(new Date());
purchaseRecord.setProduct(new Product(1, null, null,null));
purchaseRecordDao.savePurchaseRecord(purchaseRecord);//增加购买记录
}
break;//如果库存不足或重试成功,跳出循环
}
sqlSession.commit();
sqlSession.close();
}
}
利用循环限定每个事务重试3此。
这里全部成功了(但是不能保证100%成功),而且也没超卖现象。观察购买记录中的时间,发现执行完成几乎在同一秒钟,说明哪怕加了重入机制的乐观锁,执行效率依然比悲观锁高不少。
在实际生产环境中,需要根据自身需求去决定用哪种方式,比如说,允许失败的情况下,让客户端手动重试,可以缓解大量的压力。在使用乐观锁的时候,就要选择可重入乐观锁。
当然,针对这一问题,还有更优的解决方式,就是利用中间件作为载体。简单点,就可以利用缓存,比如redis,分为两步
1、先利用redis快速响应客户端请求,并记录下用户的操作;
2、将记录下的用户操作及时(因为redis存储不稳定,所以要及时操作,比如可以利用定时任务不停的更新,更可靠的就是做好备份和容灾)的将用户的操作记录更新到数据库(这里就可以允许慢一点了);
当然,针对这一机制,还有专业的MQ中间件可以使用。