Spring Boot学习实践:简单商品抢购的设计与开发以及高并发问题的处理

商品抢购设计与开发

该过程包括如下几个方面的设计与开发:

  • 商品抢购过程设计
  • 数据库表结构设计
  • 实体类设计与开发
  • 业务逻辑设计与开发

该部分和第二部分中处理高并发问题一起,使用了如下的技术:

  1. Spring Boot
  2. Spring MVC
  3. MyBatis+MySQL
  4. JSP+EasyUI
  5. Redis+Lua
  6. Spring Scheduling

商品抢购过程的设计

商品抢购过程的基础过程其实是一个商品正常购买的过程,其中包含了两个主要的步骤:商品库存减少和插入商品购买记录。这里仅考虑着两个主要的过程,以简化商品购买的整个流程。该过程的流程图设计如下:

数据库表结构设计

数据表具体实现如下:

create table tb_product (
	id int(12) not null auto_increment 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(256) null comment '备注',
	primary key (id)
);

create table tb_purchase_record (
	id int(12) not null auto_increment comment '编号',
	user_id int(12) not null comment '用户编号',
	product_id int(12) not null comment '产品编号',
	price decimal(16,2) not null comment '价格',
	quantity int(12) not null comment '数量',
	total_price decimal(16,2) not null comment '总价',
	purchase_time timestamp not null default now() comment '购买日期',
	note varchar(512) null comment '备注',
	primary key (id)
);

实体类设计与实现

 对于上述表中的user_id字段不设置与其对应的User对象,仅创建ProductPO和PurchaseRecordPO两个主要是实体对象,ProductPO类如下:

@Data
@Alias("Product")
public class ProductPO implements Serializable {
    private static final long serialVersionUID = 4006220787381707992L;
    private Long id;
    private String productName;
    private int stock;
    private double price;
    private int version;
    private String note;

    public ProductPO() {
    }

    public ProductPO(String productName, int stock, double price, int version, String note) {
        this.productName = productName;
        this.stock = stock;
        this.price = price;
        this.version = version;
        this.note = note;
    }
}

 PurchaseRecordPO对象如下:

@Data
@Alias("PurchaseRecord")
public class PurchaseRecordPO implements Serializable {
    private static final long serialVersionUID = -6438217907743195649L;
    private Long id;
    private Long userId;
    private Long productId;
    private Double price;
    private int quantity;
    private Double totalPrice;
    private Timestamp purchaseTime;
    private String note;

    public PurchaseRecordPO() {
    }

    public PurchaseRecordPO(Long userId, Long productId, Double price, int quantity, Double totalPrice,
                            Timestamp purchaseTime, String note) {
        this.userId = userId;
        this.productId = productId;
        this.price = price;
        this.quantity = quantity;
        this.totalPrice = totalPrice;
        this.purchaseTime = purchaseTime;
        this.note = note;
    }

其中设置了MyBatis的别名,以便在MyBatis映射文件中使用。

MyBatis映射接口和映射文件设计与实现

为操作Product和PurchaseRecord的相关数据,需要MyBatis进行持久化的映射,因此Product的映射文件如下:

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org/DTD Mapper 3.0" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="cn.zyt.springbootlearning.dao.ProductMapper">
    <resultMap id="productMap" type="Product">
        <id property="id" column="id"/>
        <result property="productName" column="product_name"/>
        <result property="stock" column="stock"/>
        <result property="price" column="price"/>
        <result property="version" column="version"/>
        <result property="note" column="note"/>
    </resultMap>

    <select id="getProduct" parameterType="long" resultMap="productMap">
        select id, product_name, stock, price, version, note
        from tb_product
        where id=#{id}
    </select>

    <insert id="insertProduct" parameterType="Product" useGeneratedKeys="true" keyProperty="id">
        insert into tb_product (
            product_name, stock, price, version, note
        ) values (
            #{productName}, #{stock}, #{price}, #{version}, #{note}
        )
    </insert>

    <update id="decreaseProduct">
        update tb_product set stock=stock-#{quantity}
        where id=#{id}
    </update>
</mapper>

相应的映射接口文件如下:

@Repository
public interface ProductMapper {
    ProductPO getProduct(@Param("id") Long productId);

    int insertProduct(ProductPO product);

    int decreaseProduct(@Param("id") Long productId, int quantity);

    @Select("select * from tb_product")
    @ResultMap("productMap")
    List<ProductPO> getProductList();
}

PurchaseRecord的映射配置文件如下:

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org/DTD Mapper 3.0" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="cn.zyt.springbootlearning.dao.PurchaseRecordMapper">
    <resultMap id="purchaseRecordMap" type="PurchaseRecord">
        <id property="id" column="id"/>
        <result property="userId" column="user_id"/>
        <result property="productId" column="product_id"/>
        <result property="price" column="price"/>
        <result property="quantity" column="quantity"/>
        <result property="totalPrice" column="total_price"/>
        <result property="purchaseTime" column="purchase_time"/>
        <result property="note" column="note"/>
    </resultMap>


    <insert id="insertPurchaseRecord" parameterType="PurchaseRecord" useGeneratedKeys="true" keyProperty="id">
        insert into tb_purchase_record (
            user_id, product_id, price, quantity, total_price, purchase_time, note
        ) values (
            #{userId}, #{productId}, #{price}, #{quantity}, #{totalPrice}, now(), #{note}
        )
    </insert>

</mapper>

映射接口文件如下:

@Repository
public interface PurchaseRecordMapper {
    int insertPurchaseRecord(PurchaseRecordPO purchaseRecord);

    @Select("select * from tb_purchase_record")
    @ResultMap("purchaseRecordMap")
    List<PurchaseRecordPO> getPurchaseRecordAll();

    @SelectProvider(type = PurchaseRecordProvider.class, method = "searchRecords")
    @ResultMap("purchaseRecordMap")
    List<PurchaseRecordPO> searchRecords(Long userId, Long productId);
}

商品购买业务逻辑的实现

在完成MyBatis映射配置文件与映射接口之后,下面对商品重要的商品购买业务逻辑进行开发,创建业务接口如下:

public interface PurchaseService {

    /**
     * 购买处理业务逻辑,后续对高并发问题优化时会在该接口中添加其他相应的方法
     * @param userId 用户ID
     * @param productId 商品ID
     * @param quantity 购买数量
     * @return 购买是否成功
     */
    boolean purchase(Long userId, Long productId, int quantity);
}

该接口的实现类如下:

@Service
public class PurchaseServiceImpl implements PurchaseService {
    @Autowired
    private ProductMapper productMapper;

    @Autowired
    private PurchaseRecordMapper purchaseRecordMapper;

    /**
     * 购买处理逻辑实现
     */
    @Override
    @Transactional(isolation = Isolation.READ_COMMITTED)
    public boolean purchase(Long userId, Long productId, int quantity) {
        ProductPO product = productMapper.getProduct(productId);
        if (product.getStock() < quantity) {
            return false;
        }

        productMapper.decreaseProduct(productId, quantity);
        PurchaseRecordPO purchaseRecord = initPurchaseRecord(userId, product, quantity);
        purchaseRecordMapper.insertPurchaseRecord(purchaseRecord);
        return true;
    }

    private PurchaseRecordPO initPurchaseRecord(Long userId, ProductPO product, int quantity) {
        PurchaseRecordPO purchaseRecord = new PurchaseRecordPO();
        purchaseRecord.setUserId(userId);
        purchaseRecord.setProductId(product.getId());
        purchaseRecord.setPrice(product.getPrice());
        purchaseRecord.setQuantity(quantity);
        purchaseRecord.setTotalPrice(product.getPrice() * quantity);
        purchaseRecord.setNote("Log time: " + System.currentTimeMillis());
        return purchaseRecord;
    }
}

开发简单的业务控制器如下:

@Controller
@RequestMapping("/purchase")
public class PurchaseController {
    @Autowired
    private PurchaseService purchaseService;

    @GetMapping("/page")
    public String page() {
        return "purchase";
    }

    @PostMapping("/start")
    @ResponseBody
    public CommonResult start(Long userId, Long productId, Integer quantity) {
        boolean success = purchaseService.purchase(userId, productId, quantity);
        String msg = success ? "抢购成功" : "抢购失败";
        return new CommonResult(success, msg);
    }
}

其中/purchase/start为商品购买的请求路径,/purchase/page请求路径下的相应purchase.jsp页面如下:

<%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %>
<%@ page contentType="text/html;charset=UTF-8" language="java" %>
<html>
<head>
    <title>Product Purchase</title>
    <meta name="_csrf" content="${_csrf.token}"/>
    <meta name="_csrf_header" content="${_csrf.headerName}"/>
    <script src="https://code.jquery.com/jquery-3.2.0.js"></script>
    <script type="text/javascript">
        $(document).ready(function () {
            var token = $("meta[name='_csrf']").attr("content");
            var header = $("meta[name='_csrf_header']").attr("content");

            $(document).ajaxSend(function (e, xhr, options) {
                xhr.setRequestHeader(header, token);
            });

            $("#submit").click(function () {
                var userId = $("#userId").val();
                var productId = $("#productId").val();
                var quantity = $("#quantity").val();

                var params = {
                    userId: userId,
                    productId: productId,
                    quantity: quantity
                };

                $.post("./start", params, function(result) {
                    alert(result.msgInfo);
                });
            });
        })
    </script>
</head>
<body>
    <table>
        <tr>
            <td>用户ID:</td>
            <td> <input id="userId" name="userId" type="text"/> </td>
        </tr>
        <tr>
            <td>商品ID:</td>
            <td> <input id="productId" name="productId" type="text"/> </td>
        </tr>
        <tr>
            <td>购买数量:</td>
            <td> <input id="quantity" name="quantity" type="text"/> </td>
        </tr>
        <tr>
            <td> <button id="submit" name="submit">普通购买商品</button> </td>
            <td> 注:可以输入商品熟练进行购买。 </td>
        </tr>
    </table>
    <div><a href="http://localhost:8080/web/index">返回首页</a></div>
</body>
</html>

该页面中目前仅实现了简单的普通购买商品的链接,用于请求/purchase/start,以实现商品购买的业务逻辑。但此时的业务过程在高并发先会产生一定的问题,后面会进行测试和说明,在对高并发问题进行解决时,也会在该文件中添加相应的方法来抢购情况的模拟。

简单购买场景下的测试

上面省去了SpringBoot项目的创建以及与MyBatis的集成相关过程,这部分内容可以在Github的项目中进行查看,该文章中重点对业务的处理过程进行理解,GitHub中的项目地址为:https://github.com/Yitian-Zhang/springboot-learning。下面对该项目进行启动并在简单的购买场景下进行测试。

在商品购买前首先需要在数据库中插入商品的信息,打开商品信息页面:http://localhost:8080/product/list(这部分的实现没有包括在该文章范围内,可以参考上述的GitHub地址中的代码),进行商品信息的插入:

插入完成后,打开商品购买页面:http://localhost:8080/purchase/page

这里在输入框中输入相应的数据(用户ID暂时没有和User用户关联,可以随意输入,但建议和商品id保持一致以方便对购买记录进行查询),然后点击普通购买商品按钮,即可以进行普通商品的购买。购买成功后,会出现如下提示信息:

然后查看商品信息页面,可以看到该商品的库存已经减少:

购买记录页面中也会有插入的购买记录信息,使用userid和商品id的查询功能可以对相应的记录数据进行过滤,对于该商品的三条记录,前两条是我之前测试的记录,最后一条是刚才插入的记录。可以从时间上看出区别。

完成如上的过程,即完成了商品的整体的购买逻辑,该过程在低并放量的情况下可以使用,在库存不足时会提示购买失败,此时库存不会减少 ,也不会生成购买记录。但是该过程在高并发条件下会出现一些问题,下面对该问题进行一定的探索和处理。

处理商品抢购过程中高并发问题

高并发场景下问题测试

下面首先修改purchase.jsp页面中的JavaScript代码,来模拟一下高并发的场景,首先先加入一个新的表:

<table>
        <tr>
            <td>用户ID:</td>
            <td><input id="userId1" name="userId1" type="text"/></td>
        </tr>
        <tr>
            <td>商品ID:</td>
            <td><input id="productId1" name="productId1" type="text"/></td>
        </tr>
        <tr>
            <td><button id="submit1" name="submit1">开始抢购商品(MYSQL行锁方式)</button></td>
            <td>注:默认2000人抢购1000个商品,每人限买1个商品。(根据数据库所在服务器性能进行调整)</td>
        </tr>
    </table>

加入submit1按钮对应的javascript代码,来模拟2000个并发的请求:(该请求数可以视MySQL的性能而定,这里的MySQL性能较弱,所以设置的少了一点)。 

            $("#submit1").click(function () {
                var userId = $("#userId1").val();
                var productId = $("#productId1").val();

                for(var i = 1; i <= 2000; i++) {
                    var params = {
                        userId: userId,
                        productId: productId,
                        quantity: 1
                    };
                    $.post("./start", params, function(result) {
                        // alert(result.msgInfo);
                    });
                }
            });

服务端的代码都没有变化,然后重启该项目,打开商品购买页面,可以看到会有如下的新增输入框:

下面在商品信息页面中加入新的商品信息:

 下面在商品购买页面中点击抢购商品开始模拟,2000个人抢购1000个商品并且每人限购一件的过程,待请求完成后,查看商品信息更新如下:

可以看到在高并发的情况下,商品的库存不是减为0,而是变为-2,也就是本来1000件商品,但卖出去了1002件。这就出现了所谓的“超发”现象。该问题产生的原因可以根据如下表格来分析:

时刻 线程1 线程2 备注
T1 读取库存为1   线程1可以购买商品
T2   读取库存为1 线程2可以购买商品
T3 扣减库存   此时库存为0
T4   扣减库存 此时库存为-1
T5 插入交易记录   线程1正常插入了商品购买记录
T6   插入交易记录 线程2插入商品购买记录,已经出现了异常

为了避免该问题的产生,有如下解决方法:MySQL行锁(悲观锁),CAS(客观锁机制)和借助Redis来处理高并发三种方案。下面进行说明。

使用MySQL悲观锁方式处理高并发

上述高并发情况下出现的问题,主要原因在于共享资源(stock)被多个线程并行修改,从而导致这种问题。如果一个数据库事务读取到该产品的信息后,将该数据锁定,待改事务处理完成后,其他事务才能获取该数据进行操作的话,那么就避免该问题。这就是加锁的解决方案。在MySQL中有行锁的概念,其实际上就是悲观锁直接加锁的模式,实现MySQL中的悲观锁比较简单,修改productMapper.xml映射文件中的getProduct方法的SQL语句如下:

    <!-- 出现超发现象,主要是因为共享资源(这里是stock)被多少个线程修改从而出现并发问题,
         这里使用for update的MySQL悲观锁来解决超发问题 -->
    <select id="getProduct" parameterType="long" resultMap="productMap">
        select id, product_name, stock, price, version, note
        from tb_product
        where id=#{id} for update
    </select>

与之前的代码对比,实际上只是在select语句中使用了for update,即为对查询的数据行进行了加锁,在一个事务对数据进行操作时,会将该数据进行锁定。其他事务之后待该事务处理完成后,才能获取该事务并进行操作。从而避免上述出现的并发问题。

修改完成后,重启项目并使用id=3的新增product 进行测试,完成后得到的结果如下:

可以看到使用MySQL的行锁(悲观锁)可以解决超发的问题,使stock被正常的消费。

注意:在使用for update来lock查询数据时,需要开启事务并设置事务的隔离级别如下:

@Transactional(isolation = Isolation.READ_COMMITTED)

如果没有设置正确的数据库隔离级别,则行锁不会生效(依然会产生超发的问题)。同时由于InnoDB 预设是Row-Level Lock,所以只有「明确」的指定主键,MySQL 才会执行Row lock (只锁住被选取的数据) ,否则MySQL 将会执行Table Lock (将整个数据表单给锁住)。这即针对id(主键)进行的查询,因此开启的为行锁。

上述的悲观锁方式是使用数据库中的锁机制对记录进行加锁,从而使得其他事务等待从而保持数据的一致性。但这种方式会导致事务的排队等待从而影响高并发环境下的处理性能。为了提高处理效率,可以使用乐观锁的方式进行处理。

乐观锁是一种不使用数据库锁并不阻塞线程并发的方案。类似于多线程中的CAS(Campare and Swap)概念,其主要思想如下,以当前的商品购买示例来说,

  1. 首先,在一个线程进行业务逻辑处理之前读取共享资源stock的值(这里成为旧值)并进行保存。
  2. 然后该线程进行业务逻辑处理过程。 
  3. 等该线程需要对共享数据进行修改时,将之前保存的旧值与当前数据库中的值进行比较。如果两个值一致,则进行修改。如果不一致,则什么都不做。

其流程图如下:

以上即是CAS的一般思想,但该过程在多线程的情况下会出现ABA的问题:如果变量V初次读取的时候是A,并且在准备赋值的时候检查到它仍然是A,那能说明它的值没有被其他线程修改过了吗?如果在这段期间它的值曾经被改成了B,然后又改回A,那CAS操作就会误认为它从来没有被修改过。一个使用栈数据结构来解释ABA的例子比较容易理解:https://www.cnblogs.com/qiuhaojie/p/7363967.html

为了避免该问题,引入了版本号的机制(version)。 也就是对共享变量所在的数据加入version字段,version在进行共享变量的操作时会进行累加(无论是否回退,都是会累加),那么在在对共享变量进行修改时,就可以通过version在对旧值与新值的一致性做出判断,从而避免ABA问题中使用错误的数据继续相关的操作。在t_product表中version字段的设计即是为这里准备的。下面对一些代码进行修改来实现商品抢购过程中的乐观锁实现。

为区别之前的操作,在productMapper.xml映射文件中修改decreaseProdcut方法:

    <update id="decreaseProduct">
        update tb_product set stock=stock-#{quantity}, version=version+1
        where id=#{id} and version=#{version}
    </update>

相比较之前的该方法,多了对version字段的自增以及and version=#{version}的更新判断条件,从而实现版本号的使用。

然后根据上述CAS的过程修改PurchaseServiceImpl中的purchase方法:

    @Override
    @Transactional(isolation = Isolation.READ_COMMITTED)
    public boolean purchase(Long userId, Long productId, int quantity) {
        ProductPO product = productMapper.getProduct(productId);
        if (product.getStock() < quantity) {
            return false;
        }
        // 获取当前的版本号
        int version = product.getVersion();
        // 在减少stock时,加入version的对比,从而对数据的一致性做出判断
        int result = productMapper.decreaseProduct(productId, quantity, version);
        if (result == 0) {
            return false;
        }

        PurchaseRecordPO purchaseRecord = initPurchaseRecord(userId, product, quantity);
        purchaseRecordMapper.insertPurchaseRecord(purchaseRecord);
        return true;
    }

完成后,添加id=4的product数据进行测试,运行结果如下:

可以看到id=4的product,stock剩余211,然后通过如下命令查看record记录数为789,也就是成功的交易记录与剩余的stock之和正好为1000,也就是没有在出现超发的问题。

select count(*) from tb_purchase_record where product_id=4;

但这里为什么在2000次请求后还会有剩余的stock呢?原因就在于更新后的purchase方法中,在stock更新的如下代码有可能发生失败的问题:

        // 在减少stock时,加入version的对比,从而对数据的一致性做出判断
        int result = productMapper.decreaseProduct(productId, quantity, version);
        if (result == 0) {
            return false;
        }

为了处理这个问题,乐观锁引入了重入的机制,也就是在一次更新失败之后,不是直接退出,而是在重新尝试一次更新。所以乐观锁也成为可重入锁。但这种可重入的机制不能使重新尝试一致执行下去,这样会造成大量的SQL被执行。因此可以由如下两种方式来限制重入的次数。

  1. 使用时间间隔限制重入
  2. 使用次数数量限制重入 

使用时间间隔限制重入

对于使用时间间隔限制重入的代码,可以对purchase方法进行如下修改:

@Override
    @Transactional(isolation = Isolation.READ_COMMITTED)
    public boolean purchase(Long userId, Long productId, int quantity) {
        long start = System.currentTimeMillis();
        // 循环尝试直至成功
        while (true) {
            long end = System.currentTimeMillis();
            // 如果循环时间大于100ms,终止循环
            if (end - start > 100) {
                return false;
            }
            // 如下是同样的乐观锁过程
            ProductPO product = productMapper.getProduct(productId);
            if (product.getStock() < quantity) {
                return false;
            }

            int version = product.getVersion();
            int result = productMapper.decreaseProduct(productId, quantity, version);
            if (result == 0) {
                // 如果修改stock失败,则在100ms时间间隔内进行循环尝试
                continue;
            }
            PurchaseRecordPO purchaseRecord = initPurchaseRecord(userId, product, quantity);
            purchaseRecordMapper.insertPurchaseRecord(purchaseRecord);
            return true;
        }
    }

上述代码中即使用100ms的时间间隔限制重入,在100ms之内如果尝试不成功,则退出执行。

使用次数数量限制重入 

使用重入数量来限制重入,可以对purchase方法修改如下:

    @Override
    @Transactional(isolation = Isolation.READ_COMMITTED)
    public boolean purchase(Long userId, Long productId, int quantity) {
        for (int i = 0; i < 5; i++) {
            ProductPO product = productMapper.getProduct(productId);
            if (product.getStock() < quantity) {
                return false;
            }

            int version = product.getVersion();
            int result = productMapper.decreaseProduct(productId, quantity, version);
            if (result == 0) {
                continue;
            }
            PurchaseRecordPO purchaseRecord = initPurchaseRecord(userId, product, quantity);
            purchaseRecordMapper.insertPurchaseRecord(purchaseRecord);
            return true;
        }
        return false;
    }

这里将可重入的次数限制为5次(也尝试过3次,出现剩余stock,说明重入尝试的次数不够)。

对于这两个使用重入的乐观锁方法进行测试后发现,product的stock可以全部被消费,并且没有产生超发的问题。分别使用id=5和6的新增prodcut进行测试的结果如下:

借助Redis处理高并发

在高并发环境下,MySQL作为关系型数据库写入数据时比较慢,因此可以通过redis这种基于内存的快速数据库作为缓存来保存交易记录,然后在将交易记录数据同步至MySQL进行存储即可。下面将借助Redis来处理上述过程中的高并发问题。

Spring Boot整合Redis

在Spring Boot中整合redis的过程在此省略,具体可以参考另一篇文章:Spring Boot集成Redis与使用RedisTemplate进行基本数据结构操作示例。下面在部署完成并开启Redis服务,并完成Redis集成之后,在PurchaseServiceImpl类中添加如下代码:

@Service
public class PurchaseServiceImpl implements PurchaseService {

    /**
     * Lua脚本,根据如下调用命令:
     * jedis.evalsha(sha1,
     *                     2,
     *                     PRODUCT_SCHEDULE_SET,
     *                     PURCHASE_PRODUCT_LIST_PREFIX,
     *                     userId + "",
     *                     productId + "",
     *                     quantity + "",
     *                     purchaseTime + "");
     *
     * lua中占位符与参数之间的对应关系如下:
     * KEYS[1] -> PRODUCT_SCHEDULE_SET
     * KEYS[2] -> PURCHASE_PRODUCT_LIST_PREFIX
     * ARGV[1] -> userId
     * ARGV[2] -> productId
     * ARGV[3] -> quantity
     * ARGV[4] -> purchaseTime
     *
     * Redis中数据初始化为:
     * hmset product_1 id 1 stock 30000 price 5.00
     */
    private String PURCHASE_LUA_SCRIPT = "redis.call('sadd', KEYS[1], ARGV[2]) \n"  // sadd product_schedule_set 1
            + "local productPurchaseList = KEYS[2]..ARGV[2] \n"                     // local productPurchaseList = purchase_product_list_1
            + "local userId = ARGV[1] \n"                                           // local userId = 1
            + "local product = 'product_'..ARGV[2] \n"                              // local product = product_1
            + "local quantity = tonumber(ARGV[3]) \n"                               // lcoal quantity = 1
            + "local stock = tonumber(redis.call('hget', product, 'stock')) \n"     // local stock = hget product_1 'stock'
            + "local price = tonumber(redis.call('hget', product, 'price')) \n"     // local price = hget product_1 'price'
            + "local purchase_time = ARGV[4] \n"                                    // local purchase_time purchaseTime
            + "if stock < quantity then return 0 end \n"                            // if stock < quantity then return 0 end
            + "stock = stock - quantity \n"                                         // stock = stock - quantity
            + "redis.call('hset', product, 'stock', tostring(stock)) \n"            // hset product_1 'stock' stock
            + "local total_price = price * quantity \n"                             // local total_price = price * quantity
            + "local purchaseRecord = userId..','..quantity..','..total_price..','..price..','..purchase_time \n"   // local purchaseRecord = userId,quantity,total_price,price,purchase_time
            + "redis.call('rpush', productPurchaseList, purchaseRecord) \n"         // rpush purchase_product_list_1 purchaseRecord
            + "return 1 \n";

    /**
     * 购买记录集合前缀
     */
    private static final String PURCHASE_PRODUCT_LIST_PREFIX = "purchase_list_";

    /**
     * 抢购商品集合
     */
    private static final String PRODUCT_SCHEDULE_SET = "product_schedule_set";

    /**
     * 32位SHA1编码,第一次执行时lua脚本会被缓存到Redis
     */
    private String sha1 = null;

    /**
     * 使用Redis处理高并发
     */
    @Override
    public boolean purchaseRedis(Long userId, Long productId, int quantity) {
        Long purchaseTime = System.currentTimeMillis();
        Jedis jedis = null;

        try {
            // 获取Jedis原始连接
            jedis = (Jedis) stringRedisTemplate.getConnectionFactory().getConnection().getNativeConnection();
            // 如果该脚本没有被加载过,则先将脚本加载到Redis服务器中,让其返回sha1编码
            if (sha1 == null) {
                sha1 = jedis.scriptLoad(PURCHASE_LUA_SCRIPT);
                System.out.println(PURCHASE_LUA_SCRIPT);
            }

            // 执行脚本并返回结果
            Object object = jedis.evalsha(sha1,
                    2,
                    PRODUCT_SCHEDULE_SET,
                    PURCHASE_PRODUCT_LIST_PREFIX,
                    userId + "",
                    productId + "",
                    quantity + "",
                    purchaseTime + "");
            Long result = (Long) object;
            return result == 1;
        } finally {
            // 关闭jedis连接
            if (jedis != null && jedis.isConnected()) {
                jedis.close();
            }
        }
    }

    /**
     * 保存购买记录方法
     * 保存记录启用了数据库事务,并将传播行为设置为REQUEST_NEW,即调用它会将当前事务挂起,开启新的事务
     * 这样在回滚时只会回滚这个方法内部的事务,而不会影响全局事务
     *
     * @param records 购买记录
     * @return true
     */
    @Override
    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public boolean dealRedisPurchase(List<PurchaseRecordPO> records) {
        for (PurchaseRecordPO record : records) {
            purchaseRecordMapper.insertPurchaseRecord(record);
            productMapper.decreaseProduct(record.getProductId(), record.getQuantity());
        }
        return true;
    }
}

这里定义了新的purchaseRedis方法,后续修改controller来调用该方法,以实现使用Redis处理购买逻辑。上述代码使用了lua脚本来实现了redis中的相关操作,Reids lua在redis执行时具有原子性,因此它的执行不会被打断,因此可以使用redis lua脚本执行的方式来代替数据库作为相应用户的数据载体。在代码中定义PURCHASE_SCRIPT脚本具体如下:

redis.call('sadd', KEYS[1], ARGV[2]) 
local productPurchaseList = KEYS[2]..ARGV[2] 
local userId = ARGV[1] 
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_time = ARGV[4] 
if stock < quantity then return 0 end 
stock = stock - quantity 
redis.call('hset', product, 'stock', tostring(stock)) 
local total_price = price * quantity 
local purchaseRecord = userId..','..quantity..','..total_price..','..price..','..purchase_time 
redis.call('rpush', productPurchaseList, purchaseRecord) 
return 1 

该部分的脚本的解释可以具体看代码中的注释。其中在调用jedis.evalsha()方法时,其中的第二个参数值为2,表示了将该参数之后的两个参数以key的形式传递到脚本中,并且在lua脚本中可以使用KEYS[1], KEYS[2]的方式引用这两个参数,而之后的参数以参数的形式引用在lua脚本中,并以ARGV[index]的方式引用。因此该方法参数与脚本中的KEYS,ARGV的对应关系如下:

     *       jedis.evalsha(sha1,
     *                     2,
     *                     PRODUCT_SCHEDULE_SET,
     *                     PURCHASE_PRODUCT_LIST_PREFIX,
     *                     userId + "",
     *                     productId + "",
     *                     quantity + "",
     *                     purchaseTime + "");
     *
     * lua中占位符与参数之间的对应关系如下:
     * KEYS[1] -> PRODUCT_SCHEDULE_SET
     * KEYS[2] -> PURCHASE_PRODUCT_LIST_PREFIX
     * ARGV[1] -> userId
     * ARGV[2] -> productId
     * ARGV[3] -> quantity
     * ARGV[4] -> purchaseTime

需要注意的是,在该新增的方法中,仅使用了redis lua脚本来处理商品购买的逻辑,并将购买后的记录保存在了key为productPurchaseList的列表中。该过程中没有对数据库进行任何操作,所有的处理都是交由redis进行,因此可以利用redis的高性能来处理高并发的情况。

使用Spring定时任务同步数据到MySQL

但在redis中持久化保存交易记录是合适的,因此还需要将redis中的交易记录同步到mysql中进行持久化。这时可以使用Spring的定时任务(scheduling)来进行处理。

首先开启Spring的定时机制,在Spring Boot项目的启动类或者Configuration类中加入如下注解,既可以开始Spring 的定时任务机制:

@Configuration
@EnableScheduling   // 开启Spring定时任务
public class AsyncConfiguration implements AsyncConfigurer {
    // ...
}

然后定义进行交易记录同步的service类:

@Service
public class PurchaseScheduleService {
    private static final String PURCHASE_PRODUCT_LIST_PREFIX = "purchase_list_";
    private static final String PRODUCT_SCHEDULE_SET = "product_schedule_set";
    private static final int BATCH_SIZE = 200;

    @Autowired
    private StringRedisTemplate stringRedisTemplate;

    @Autowired
    private PurchaseService purchaseService;

    // DateTimeFormatter线程安全,SimpleDateFormat线程不安全
    private DateTimeFormatter dateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");

    /**
     * Redis购买记录数据同步至MySQL定时任务
     * // @Scheduled(cron = "0 0 1 * * ?") // 设置每天凌晨1点执行数据同步事务
     */
    @Scheduled(fixedRate = 1000 * 5 * 60) // 设置每隔5分钟进行数据同步
    public void purchaseTask() {
        System.out.println("Redis数据同步至MySQL任务开始,START AT:" + dateTimeFormatter.format(LocalDateTime.now()));
        // 根据PRODUCT_SCHEDULE_SET key,获取redis中进行抢购的商品id set
        Set<String> productIdList = stringRedisTemplate.opsForSet().members(PRODUCT_SCHEDULE_SET);
        List<PurchaseRecordPO> purchaseRecords = new ArrayList<>();
        for (String productIdStr : productIdList) {
            Long productId = Long.valueOf(productIdStr);
            String purchaseKey = PURCHASE_PRODUCT_LIST_PREFIX + productId;
            
            // 对指定id的商品相应的购买记录列表进行绑定操作
            BoundListOperations<String, String> operations = stringRedisTemplate.boundListOps(purchaseKey);
            // 计算每次同步的记录数,避免一次读取的数据量过大导致JVM内存溢出
            Long size = operations.size();
            Long times = size % BATCH_SIZE == 0 ? size / BATCH_SIZE : size / BATCH_SIZE + 1;
            // 分批同步购买记录数据
            for (int i = 0; i < times; i++) {
                List<String> recordList = null;
                if (i == 0) {
                    recordList = operations.range(i * BATCH_SIZE, (i + 1) * BATCH_SIZE);
                } else {
                    recordList = operations.range(i * BATCH_SIZE + 1, (i + 1) * BATCH_SIZE);
                }
                // 将redis中key为purchase_list_id的列表中保存的购买记录字符串取出,将其转换为PurchaseRecordPO对象
                for (String recordStr : recordList) {
                    PurchaseRecordPO record = createPurchaseRecord(productId, recordStr);
                    purchaseRecords.add(record);
                }
                
                // 调用事务执行数据同步,此时事务的传播机制被设置为REQUESTED_NEW,即在分批同步数据的过程中每一次同步的执行都是新建一个事务进行处理
                // 当子事务出现问题时,不会导致全局事务的回滚,仅回滚子事务
                try {
                    purchaseService.dealRedisPurchase(purchaseRecords);
                } catch (Exception e) {
                    e.printStackTrace();
                }
                // 清空列表,等待下一批次数据的同步
                purchaseRecords.clear();
            }
            // 同步完成后,清除Redis中的相关数据:购买商品id集合中的当前id以及购买记录列表
            stringRedisTemplate.delete(purchaseKey);
            stringRedisTemplate.opsForSet().remove(PRODUCT_SCHEDULE_SET, productIdStr);
        }
        System.out.println("同步结束,END AT: " + dateTimeFormatter.format(LocalDateTime.now()));
    }

    /**
     * Redis中存储的record格式为:
     * userId,quantity,total_price,price,purchase_time
     *
     * PurchaseRecordPO(Long userId, Long productId, Double price, int quantity, Double totalPrice,
     *                             Timestamp purchaseTime, String note)
     */
    private PurchaseRecordPO createPurchaseRecord(Long productId, String recordStr) {
        String[] array = recordStr.split(",");
        Long  userId = Long.parseLong(array[0]);
        Integer quantity = Integer.parseInt(array[1]);
        Double totalPrice = Double.valueOf(array[2]);
        Double price = Double.valueOf(array[3]);
        Long time = Long.valueOf(array[4]);
        Timestamp purchaserTime = new Timestamp(time);
        return new PurchaseRecordPO(userId, productId, price, quantity, totalPrice, purchaserTime,
                "Purchase Log: " + purchaserTime.getTime());
    }
}

上面将任务的执行设置为没5分钟执行一次,这里也可以修改为每天凌晨进行执行,以在交易记录产生较小的时间段进行数据同步。数据同步的具体逻辑可以参考代码中的注释。

启动项目并测试 

完成上述的代码修改后,对controller中的代码进行调整,使其调用新增的purchaseRedis方法来处理购买逻辑;

    @RequestMapping("/start")
    @ResponseBody
    public CommonResult start(Long userId, Long productId, Integer quantity) {
        boolean success = purchaseService.purchaseRedis(userId, productId, quantity);
        String msg = success ? "抢购成功" : "抢购失败";
        return new CommonResult(success, msg);
    }

 然后重启该项目,新增id=7的product进行测试。首先在redis插入待购买的数据:

hmset product_7 id 7 stock 1000 price 5.00

在购买页面运行完成后,连接到redis服务器,查看其中写入的数据如下。

127.0.0.1:6379> keys *
1) "product_7"               # 存放初始product信息的set
2) "purchase_list_7"         # 存放该商品交易记录的list
3) "product_schedule_set"    # 存放用于抢购的商品id的set

查看product_schedule_set如下:

127.0.0.1:6379> smembers product_schedule_set
1) "7"

 查看purchase_list_7如下,以userId,quantity,total_price,price,purchase_time格式记录了所有的交易记录,在定时任务中即根据这种记录格式,将其转化为PurchaseRecordPO对象并保存到MySQL中。

127.0.0.1:6379> lrange purchase_list_7 0 -1
   1) "7,1,39.5,39.5,1581822435898"
   2) "7,1,39.5,39.5,1581822435898"
   3) "7,1,39.5,39.5,1581822435898"
   4) "7,1,39.5,39.5,1581822435899"
   5) "7,1,39.5,39.5,1581822435898"
   6) "7,1,39.5,39.5,1581822435898"
   7) "7,1,39.5,39.5,1581822435938"
   8) "7,1,39.5,39.5,1581822435938"
   9) "7,1,39.5,39.5,1581822435938"
  10) "7,1,39.5,39.5,1581822435942"
  11) "7,1,39.5,39.5,1581822435939"
  12) "7,1,39.5,39.5,1581822435941"
    ...
 997) "7,1,39.5,39.5,1581822438033"
 998) "7,1,39.5,39.5,1581822438034"
 999) "7,1,39.5,39.5,1581822438034"
1000) "7,1,39.5,39.5,1581822438034"

然后在完成数据库同步任务之后即完成了使用redis处理高并发的过程。 

三种处理高并发方式的性能比较

在数据库中使用如下SQL语句,来比较上述前两种处理高并发问题的性能:

select min(purchase_time), max(purchase_time) from tb_purchase_record where product_id=#{id};

通过查询得到如下的性能结果:

并发处理方式 并发问题 性能 备注
原始未进行任何并发处理 出现超发现象 22s  
MySQL悲观锁 32s  
CAS无重入机制 存在200多个stock没有被消费 41s  
CAS Time重入限制 39s 重入时间间隔100ms
CAS Count重入限制 27s 重入次数限制count=5
Redis 2137ms  

 注:对于使用Redis来处理高并发的方式中,其数据的的purchase_time根据写入的时间自动生成(now()方法),而不是原始的redis中进行生成的数据,因此在该方式中的性能,需要对redis中的记录进行简单的计算:

Long time = 1581822438034l - 1581822435898l = 2137ms

可见redis在处理高并发情况下的显著优势,因此在这种场景中合理使用redis可以极大的提高并发性能并避免并发问题。

发布了322 篇原创文章 · 获赞 64 · 访问量 4万+

猜你喜欢

转载自blog.csdn.net/yitian_z/article/details/104339952