玩转 Spring Boot 应用篇(解决菜菜店铺商品超卖问题)

2022-05-31 09:44:05 浏览数 (1)

0.

0.0. 历史文章整理

玩转 Spring Boot 入门篇

玩转 Spring Boot 集成篇(MySQL、Druid、HikariCP)

玩转 Spring Boot 集成篇(MyBatis、JPA、事务支持)

玩转 Spring Boot 集成篇(Redis)

玩转 Spring Boot 集成篇(Actuator、Spring Boot Admin)

玩转 Spring Boot 集成篇(RabbitMQ)

玩转 Spring Boot 集成篇(@Scheduled、静态、动态定时任务)

玩转 Spring Boot 集成篇(任务动态管理代码篇)

玩转 Spring Boot 集成篇(定时任务框架Quartz)

玩转 Spring Boot 原理篇(源码环境搭建)

玩转 Spring Boot 原理篇(核心注解知多少)

玩转 Spring Boot 原理篇(自动装配前凑之自定义Starter)

玩转 Spring Boot 原理篇(自动装配源码剖析)

玩转 Spring Boot 原理篇(启动机制源码剖析)

玩转 Spring Boot 原理篇(内嵌Tomcat实现原理&优雅停机源码剖析)

玩转 Spring Boot 应用篇(搭建菜菜的店铺)

0.1. 回顾

为了大家能够熟练应用 Spring Boot 相关技术,菜菜同学基于 Spring Boot 快速搭建了一个商品售卖网站,索性称为菜菜的店铺 V1 版本,简单对 V1 版本做一下回顾。

  • 技术实现:Spring Boot 2.6.3 MyBatis 3.5.9 Thymeleaf 3.0.14 Bootstrap 5.1.3 MySQL
  • 依赖环境:JDK 1.8 Maven 3.6.3
  • 开发工具:IntelliJ IDEA
  • 版本 V1 的实现思路
  • 遗留问题(商品超卖现象)

最后验证环节,模拟并发购买导致库存变成负数。

为什么高并发下,偶尔会出现超卖现象呢?

0.2. 分析商品超卖的原因

  • 上图 31 行:首先通过商品编号获取库中的商品信息,然后判断库存是否小于购买的数量,如果小于则表示库存不足,直接返回购买失败;如果有库存,则执行扣减库存操作。
  • 上图 38 行:进行扣减库存操作。

乍看逻辑没啥问题。不过,当并发量比较大的时候,会在 31 行并发判断有否有库存,也会存在 N 多个线程在 38 行并发执行扣减库存操作,以至于出现超卖现象。

不过,面对菜菜的店铺超卖的现象,该如何解决呢?不急,菜菜同学有妙招。

0.2. 解决商品超卖的道与术

接下来会对菜菜的店铺系统实现进行升级,从 V1 变成版本 V2,在此版本中会一起来学习预防商品超卖的道与术。

1. 悲观锁方式解决商品超卖

在关系数据库管理系统里,悲观并发控制(又名“悲观锁”,Pessimistic Concurrency Control,缩写“PCC”)是一种并发控制的方法。它可以阻止一个事务以影响其他用户的方式来修改数据。如果一个事务执行的操作读某行数据应用了锁,那只有当这个事务把锁释放,其他事务才能够执行与该锁冲突的操作。 --维基百科

悲观并发控制实际上是“先取锁再访问”的保守策略,为数据处理的安全提供了保证。但是在效率方面,处理加锁的机制会让数据库产生额外的开销,还有增加产生死锁的机会;另外,在只读型事务处理中由于不会产生冲突,也没必要使用锁,这样做只能增加系统负载;还有会降低了并行性,一个事务如果锁定了某行数据,其他事务就必须等待该事务处理完才可以处理那行数据。 --维基百科

上面是摘自维基百科对于悲观锁的解释,而所谓悲观锁个人感觉就相当于菜菜同学出门为了安全一定要开坦克,感觉任何人都充满敌意,谁都不能招惹到菜菜,坦克给菜菜加了一把安全锁,这么就会导致走的慢(效率低),不过相对安全。

1.1. 技术实现

代码语言:javascript复制
<select id="getGoodsById" resultMap="BaseResultMap">
    select id, name, image, stock, price,start_time,end_time,create_time, update_time 
    from t_goods where id = #{id,jdbcType=INTEGER} for update 
</select>

调整 GoodsMapper.xml 文件,只需在 SQL 后面追加一个 for update 就行了。

1.2. 模拟高并发验证

用菜菜写的模拟高并发的 HTML 静态页面,模拟高并发请求商品购买。

  • 服务端控制台日志输出
  • 数据库库存情况

整体效果还不错,试了好多次,都没有出现超卖的现象。

不过坊间说悲观锁有点效率低,只因为悲观锁就是先持有锁,然后方能对数据进行操作,其实说实话悲观锁就能够满足菜菜的需求了,不过菜菜是一个追求极致的技术人员,追求优化无止境,于是一心想提升效率。

2. 乐观锁方式解决商品超卖

在关系数据库管理系统里,乐观并发控制(又名“乐观锁”,Optimistic Concurrency Control,缩写“OCC”)是一种并发控制的方法。它假设多用户并发的事务在处理时不会彼此互相影响,各事务能够在不产生锁的情况下处理各自影响的那部分数据。在提交数据更新之前,每个事务会先检查在该事务读取数据后,有没有其他事务又修改了该数据。如果其他事务有更新的话,正在提交的事务会进行回滚。 --维基百科

乐观并发控制的事务包括以下阶段: 读取:事务将数据读入缓存,这时系统会给事务分派一个时间戳。 校验:事务执行完毕后,进行提交。这时同步校验所有事务,如果事务所读取的数据在读取之后又被其他事务修改,则产生冲突,事务被中断回滚。 写入:通过校验阶段后,将更新的数据写入数据库。 --维基百科

如上是摘自维基百科对于乐观锁的解释,汲取精华,简单而言:先把数据库的旧值拿出来,然后校验是否一致,只有一致方能进行数据修改操作,这恰恰就是多线程 CAS 的概念(CAS:Compare and Swap)。

CAS 会带来什么问题?假设原来库中的旧值为 A,中途被线程 B 修改成 B,然后又被修改为 A,对于线程 A 而言数据没有发生变化,没有感知到中途 B 的操作,这便是多线程 ABA 的问题。

坊间,引入一个递增的 version 字段来解决 CAS 方法带来的 ABA 的问题,对数据加一个版本的概念,而且此版本只允许递增,所以能有效避免数据 ABA 的操作。

2.1. 数据库引入 version 字段

考虑到方便,设置 version 的默认值为 0。

2.2. 实体类加入 version 属性

别忘记提供对应的 setter/getter 方法。

2.3. GoodsService 减库存方法扩展

代码语言:javascript复制
变更前:
public int reduceStock(Integer goodsId,int quantity)
变更后:
public int reduceStock(Integer goodsId,int quantity,Integer version);

详细代码如下:

代码语言:javascript复制
package org.growup.caicaishop.service;

import org.growup.caicaishop.entity.Goods;

import java.util.List;

public interface GoodsService {

    /**获取商品列表*/
    public List<Goods> findAllGoods();

    /**减库存*/
    public int reduceStock(Integer goodsId,int quantity,Integer version);
}

2.4. GoodsServiceImpl 减库存方法实现调整

减库存的 reduceStock 方法加入 version 参数传入。

代码语言:javascript复制
package org.growup.caicaishop.service.impl;

import org.growup.caicaishop.dao.GoodsDao;
import org.growup.caicaishop.entity.Goods;
import org.growup.caicaishop.service.GoodsService;
import org.springframework.stereotype.Service;

import javax.annotation.Resource;
import java.util.List;

@Service
public class GoodsServiceImpl implements GoodsService {

    @Resource
    private GoodsDao goodsDao;

    @Override
    public List<Goods> findAllGoods() {
        return goodsDao.selectAll();
    }

    @Override
    public int reduceStock(Integer goodsId,int quantity,Integer version) {
        return goodsDao.reduceStock(goodsId,quantity,version);
    }
}

2.5. GoodsDao 减库存方法扩展

减库存的 reduceStock 方法加入 version 参数的传入。

代码语言:javascript复制
package org.growup.caicaishop.dao;

import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.growup.caicaishop.entity.Goods;

import java.util.List;

@Mapper
public interface GoodsDao {

    /**
     * 查询商品
     */
    public Goods getGoodsById(@Param("id")Integer id);

    /**
     * 查询所有商品
     */
    public List<Goods> selectAll();

    /**
     * 减库存
     */
    public int reduceStock(@Param("id")Integer id, @Param("quantity") int quantity,@Param("version") Integer version);
}

2.6. GoodsMapper.xml 加入 version 字段

代码语言:javascript复制
<?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="org.growup.caicaishop.dao.GoodsDao">

    <resultMap id="BaseResultMap" type="org.growup.caicaishop.entity.Goods">
        <id column="id" property="id" jdbcType="INTEGER"/>
        <result column="name" property="name" jdbcType="VARCHAR"/>
        <result column="image" property="image" jdbcType="VARCHAR"/>
        <result column="stock" property="stock" jdbcType="INTEGER"/>
        <result column="price" property="price" jdbcType="DECIMAL"/>
        <result column="version" property="version" jdbcType="INTEGER"/>
        <result column="start_time" property="startTime" jdbcType="TIMESTAMP"/>
        <result column="end_time" property="endTime" jdbcType="TIMESTAMP"/>
        <result column="create_time" property="createTime" jdbcType="TIMESTAMP"/>
        <result column="update_time" property="updateTime" jdbcType="TIMESTAMP"/>
    </resultMap>

    <select id="getGoodsById" resultMap="BaseResultMap">
        select id, name, image, stock, price,version,start_time,end_time,create_time, update_time
        from t_goods where id = #{id,jdbcType=INTEGER}
    </select>

    <select id="selectAll" resultMap="BaseResultMap">
        select id, name, image, stock, price,version,start_time,end_time,create_time, update_time from t_goods
    </select>

    <update id="reduceStock">
        update t_goods set stock = stock - #{quantity},version = version   1 where id = #{id,jdbcType=INTEGER} and version = #{version,jdbcType=INTEGER}
    </update>
</mapper>

重点关注更新库存的 SQL,在做库存更新的时候会判断 version 的旧值是否发生变更,并且 version 会一直累加。

代码语言:javascript复制
update t_goods set stock = stock - #{quantity},version = version   1 where id = #{id,jdbcType=INTEGER} and version = #{version,jdbcType=INTEGER}

2.7. PurchaseServiceImpl 购买逻辑调整

如上图所示,在减库存时传入原始商品的版本号,如果更新失败直接返回购买失败。

2.8. 模拟高并发验证

  • 服务跑之前,记录一下商品库存情况。
  • 执行并发脚本

启动菜菜的商城服务,直接用浏览器打开菜菜同学编写的模拟并发的 HTML,并进行多次执行,看看效果如何。

  • 控制台日志输出

经过频繁并发请求,服务端控制台偶尔会存在大批扣减库存失败的情况。

  • 数据库商品库存情况

经过高并发访问后,发现 1000 个小黄人乐翻天的照片(骗)居然还有 806 个剩余,汗!

此时,会导致用户购买成功率下降,按照菜菜的性格,当然需要继续改进改进。

此时的情况就跟亚洲气质舞王尼古拉斯赵四说的一样:世界上没有什么事儿是一顿烧烤不能解决的,如果有,那就两顿。

这问题就迎刃而解了,如果一次购买失败,那就多重试几次呗。

3. 可重入锁方式解决商品超卖

当商品数据的版本 version 发生变更时,导致数据更新失败,可以多循环尝试几次扣减库存,提升一下购买成功率。

循环尝试可以基于固定时间范围内重试(例如 100 ms 内无限重试)或者固定次数重试,下面用固定次数来修改一下系统实现。

3.1. 代码修改

加入循环次数的限制,直到更新库存成功。

代码语言:javascript复制
@Override
@Transactional(isolation = Isolation.READ_COMMITTED)
public boolean purchase(Integer userId, Integer goodsId, int quantity) {
    // 加入尝试固定次数限制
    for (int i = 0; i < 3; i  ) {
        Goods goodsInfo = goodsDao.getGoodsById(goodsId);
        if (goodsInfo.getStock() < quantity) {
            // 库存不足
            logger.info("库存不足: "   goodsInfo.getStock());
            return false;
        }

        //扣减库存
        int res = goodsDao.reduceStock(goodsId, quantity, goodsInfo.getVersion());
        logger.info("扣减库存结果:"   res);
        if (res == 0) {
            logger.info("数据被修改,本次购买失败,继续尝试");
            continue;
        }
        //插入购买记录
        UserGoods userGoods = new UserGoods();
        userGoods.setUserId(userId);
        userGoods.setGoodsId(goodsId);
        userGoods.setQuantity(quantity);
        userGoods.setState(1);
        userGoods.setCreateTime(new Timestamp(System.currentTimeMillis()));
        int saveRes = userGoodsDao.insert(userGoods);
        logger.info("插入购买记录:"   saveRes);
        return saveRes == 1;
    }
    logger.info("重试 3 次后依然失败");
    return false;
}

3.2. 模拟高并发验证

  • 恢复库存,方便观察。

先恢复小黄人乐翻天商品的库存为 1000,数据版本 verison 为 0,然后记录服务运行前的商品库存情况。

  • 执行并发脚本

启动菜菜的商城服务,直接用浏览器打开菜菜同学编写的模拟并发的 HTML,并进行多次执行,看看效果如何。

  • 执行结果

服务端控制台偶尔存在“重试 3 次后依然失败“的输出,但是相对提升了成功率,运行一段时间后,数据库库存变成了 0。

  • 验证菜菜的店铺

访问菜菜的店铺,点击功夫熊猫的买它按钮,直接购买成功,本次改造成功,可谓是爽娃娃。

此时,商品超卖的问题解决,菜菜的店铺在一定程度上已经满足照片(骗)的售卖需求了。

4. 例行回顾

本文主要是对菜菜的店铺中的超卖问题进行分析,并引入了悲观锁、乐观锁、可重入锁来解决商品超卖的问题,并谈及了 CAS 的概念,以及 CAS 带来的 ABA 问题的解决方案。

另外若对 CAS、ABA 感兴趣的可以借助AtomicInteger、AtomicReference、AtomicStampedReference 源码来类比学习,非本次重点(不过可以单独开篇去谈,敬请期待)。

继续谈菜菜的店铺,目前这个版本的系统实现虽解决了超卖问题,不过终究是架不住粉丝的购买热情呀,当粉丝一股脑全扑上来时,架不住频繁查询数据库呀,势必会存在数据库的性能问题,所以还需要想点办法改进改进,且听下次分享。

‍理想再远大,也需要点滴的努力;口号再响亮,也需要实际的行动。不论你有多么宏伟的规划、多么昂扬的斗志,不去行动,一切终究是空中楼阁。千里之行,始于足下,不如就从此刻开始行动,向着目标进发。‍ --人民日报

参考资料:

https://spring.io/

https://start.spring.io/

https://spring.io/projects/spring-boot

https://github.com/spring-projects/spring-boot

https://docs.spring.io/spring-boot/docs/current/reference/htmlsingle/

https://stackoverflow.com/questions/tagged/spring-boot

《Spring Boot实战》《深入浅出Spring Boot 2.x》

《一步一步学Spring Boot:微服务项目实战(第二版)》

《Spring Boot揭秘:快速构建微服务体系》

0 人点赞