1、搭建

项目使用springboot 2.3.0.RELEASE、mysql 5.7.27 进行构建,压测工具使用的是JMeter,后续会用到redis,开发工具为IDEA

1.1 数据库

在mysql中建立一个red_packet的数据库,有两个表,分别如下。

CREATE TABLE `t_red_packet` (
  `id` int(12) NOT NULL AUTO_INCREMENT,
  `user_id` int(12) DEFAULT NULL,
  `amount` decimal(16,2) DEFAULT NULL,
  `send_date` timestamp NULL DEFAULT NULL,
  `total` int(12) DEFAULT NULL,
  `unit_amount` decimal(12,0) DEFAULT NULL,
  `stock` int(12) DEFAULT NULL,
  `version` int(12) unsigned DEFAULT NULL,
  `note` varchar(256) DEFAULT NULL,
  PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=2 DEFAULT CHARSET=utf8mb4;
CREATE TABLE `t_user_red_packet` (
  `id` int(12) NOT NULL AUTO_INCREMENT,
  `red_packet_id` int(12) DEFAULT NULL,
  `user_id` int(12) DEFAULT NULL,
  `amount` decimal(16,2) DEFAULT NULL,
  `grab_time` timestamp NULL DEFAULT NULL,
  `note` varchar(255) DEFAULT NULL,
  PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

插入数据

INSERT INTO `t_red_packet` VALUES (1, 1, 500.00, '2020-05-22 11:10:29', 50, 10, 50, 50, '');

1.2 创建

使用IDEA创建一个名为grap-pocketSpringBoot项目,导入maven依赖

<dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-freemarker</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-thymeleaf</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
        <dependency>
            <groupId>com.alibaba</groupId>
            <artifactId>druid-spring-boot-starter</artifactId>
            <version>1.1.18</version>
        </dependency>
        <dependency>
            <groupId>org.mybatis.spring.boot</groupId>
            <artifactId>mybatis-spring-boot-starter</artifactId>
            <version>2.1.2</version>
        </dependency>

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-devtools</artifactId>
            <scope>runtime</scope>
            <optional>true</optional>
        </dependency>
        <dependency>
            <groupId>mysql</groupId>
            <artifactId>mysql-connector-java</artifactId>
            <scope>runtime</scope>
        </dependency>
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <optional>true</optional>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
            <exclusions>
                <exclusion>
                    <groupId>org.junit.vintage</groupId>
                    <artifactId>junit-vintage-engine</artifactId>
                </exclusion>
            </exclusions>
        </dependency>
		<dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <optional>true</optional>
        </dependency>

修改application.yml文件

server:
  port: 8080

spring:
  datasource:
    url: jdbc:mysql://localhost:3306/red_pocket?useUnicode=true&characterEncoding=utf-8&serverTimezone=Asia/Shanghai
    username: root
    password: 123456
    druid:
      initial-size: 50
      min-idle: 1
      max-active: 200
      max-wait: 600000
      min-evictable-idle-time-millis: 300000
      test-while-idle: true
      test-on-borrow: false
      test-on-return: false
      max-wait-thread-count: 30000
mybatis:
  mapper-locations:
    - classpath:mapper/*.xml
  type-aliases-package: cn.amoqi.grap.pocket.entity
  configuration:
    map-underscore-to-camel-case: true

配置启动类的Mapper扫描路径

@MapperScan("cn.amoqi.grap.pocket.dao")

创建controller、dao、server、entity文件夹

1.3 Entity

package cn.amoqi.grap.pocket.entity;

import lombok.Data;

import java.io.Serializable;
import java.sql.Timestamp;
@Data
public class RedPacket implements Serializable {
    private Long id;
    private Long userId;
    private Double amount;
    private Timestamp sendDate;
    private Integer total;
    private Double unitAmount;
    private Integer stock;
    private Integer version;
    private String note;
}
package cn.amoqi.grap.pocket.entity;

import lombok.Data;

import java.io.Serializable;
import java.sql.Timestamp;
@Data
public class UserRedPacket implements Serializable {
    private Long id;
    private Long redPacketId;
    private Long userId;
    private Double amount;
    private Timestamp grabTime;
    private String note;
}

1.4 Dao

package cn.amoqi.grap.pocket.dao;

import cn.amoqi.grap.pocket.entity.RedPacket;
import org.apache.ibatis.annotations.Select;
import org.apache.ibatis.annotations.Update;

import java.util.List;

public interface RedPacketDao {
    /**
     * 获取红包信息
     * @param id 红包 id
     * @return 红包具体信息
     */
    @Select("select id, user_id as userId,amount,send_date as sendDate,total,unit_amount as unitAmount,stock,version,note from t_red_packet where id=#{id}")
    public RedPacket getRedPacket(Long id);

    /**
     * 扣减抢红包数
     * @param id 红包id
     * @return 更新记录条数
     */
    @Update("update t_red_packet set stock = stock-1 where id = #{id}")
    public int decreaseRedPacket(Long id);
}
package cn.amoqi.grap.pocket.dao;

import cn.amoqi.grap.pocket.entity.UserRedPacket;
import org.apache.ibatis.annotations.Insert;

public interface UserRedPacketDao {
    @Insert("insert into T_USER_RED_PACKET(red_packet_id,user_id,amount,grab_time,note) values(#{redPacketId},#{userId},#{amount},now(),#{note})")
    public int grapRedPacket(UserRedPacket userRedPacket);
}

1.5 Service

package cn.amoqi.grap.pocket.service;

import cn.amoqi.grap.pocket.entity.RedPacket;

import java.util.List;

public interface RedPacketService {
    List<RedPacket> getRedPacketList();
    public RedPacket getRedPacket(Long id);
    public int decreaseRedPacket(Long id);
}

1.6 Impl

package cn.amoqi.grap.pocket.service;
import cn.amoqi.grap.pocket.dao.RedPacketDao;
import cn.amoqi.grap.pocket.entity.RedPacket;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Isolation;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
@Service
public class RedpacketServiceImpl implements RedPacketService{
    @Autowired
    private RedPacketDao redPacketDao;
    @Override
    public List<RedPacket> getRedPacketList(){
        return redPacketDao.getList();
    }
    @Override
    @Transactional(isolation = Isolation.READ_COMMITTED,propagation = Propagation.REQUIRED)
    public RedPacket getRedPacket(Long id) {
        return redPacketDao.getRedPacket(id);
    }
    @Override
    @Transactional(isolation = Isolation.READ_COMMITTED,propagation = Propagation.REQUIRED)
    public int decreaseRedPacket(Long id) {
        return redPacketDao.decreaseRedPacket(id);
    }
}
package cn.amoqi.grap.pocket.service;

import cn.amoqi.grap.pocket.dao.RedPacketDao;
import cn.amoqi.grap.pocket.dao.UserRedPacketDao;
import cn.amoqi.grap.pocket.entity.RedPacket;
import cn.amoqi.grap.pocket.entity.UserRedPacket;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Isolation;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;

import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;

@Service
public class UserRedPacketServiceImpl implements UserRedPacketService{
    private static final int FAILED = 0;
    @Autowired
    private UserRedPacketDao userRedPacketDao;
    @Autowired
    private RedPacketDao redPacketDao;
    private volatile AtomicInteger atomicInteger = new AtomicInteger(0);
    /**
     * 保存抢红包的信息
     * @param redPacketId 红包编号
     * @param userId 抢红包的用户编号
     * @return 影响记录数目
     */
    @Transactional(isolation = Isolation.READ_COMMITTED,propagation = Propagation.REQUIRED)
    public int grapRedPacket(Long redPacketId, Long userId){
            RedPacket redPacket = redPacketDao.getRedPacket(redPacketId);
            //当前小红包库存大于0
            if(redPacket.getStock() > 0){
                redPacketDao.decreaseRedPacket(redPacketId);
                UserRedPacket userRedPacket = new UserRedPacket();
                userRedPacket.setRedPacketId(redPacketId);
                userRedPacket.setUserId(userId);
                userRedPacket.setAmount(redPacket.getUnitAmount());
                userRedPacket.setNote("抢红包 "+redPacketId);
                int result = userRedPacketDao.grapRedPacket(userRedPacket);
                return result;
            }
        return FAILED;
    }
}

1.7 Controller

package cn.amoqi.grap.pocket.controller;

import cn.amoqi.grap.pocket.service.UserRedPacketService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.ResponseBody;

import java.util.HashMap;
import java.util.Map;

@Controller
@RequestMapping("/userRedPacket")
public class UserRedPacketController {

    @Autowired
    private UserRedPacketService userRedPacketService;

    @RequestMapping(value = "/grapRedPacket")
    @ResponseBody
    public Map<String,Object> grapRedPacket(Long redPacketId, Long userId){
        //抢红包
        int result = userRedPacketService.grapRedPacket(redPacketId,userId);
        Map<String,Object> retMap = new HashMap<>();
        boolean flag = result>0;
        retMap.put("success",flag);
        retMap.put("message",flag?"抢红包":"抢红包失败");
        return retMap;
    }
    @RequestMapping("/1")
    public String test(){
        return "grap";
    }

}

1.8 测试

使用Jmeter创建线程组,线程数为400,并在线程组下面创建HTTP请求,请求地址如下,其他的设置默认即可

http://localhost:8080/userRedPacket/grapRedPacket?redPacketId=1&userId=1

多测试几次(还原t_red_packet字段stock为50,清空t_user_red_packet),会发现 t_user_red_packet表中的stock字段值可能出现负数,出现了超买问题

2、悲观锁 for update

可以使用for update进行锁定表的行

RedPacketDao 查询库存接口getRedPacket加入 for update

@Select("select id, user_id as userId,amount,send_date as sendDate,total," +
            "unit_amount as unitAmount,stock,version,note from t_red_packet where id=#{id} for update")
    public RedPacket getRedPacket(Long id);

使用Jmeter测试后发现库存不会存在超卖的问题

该方法属于悲观锁,加入for update 后会对行进行加锁(因为这里使用主键查询,加锁后可能会引发其他查询的堵塞),意味着高并发情况下当一个事务有了这个更新锁才能往下执行,其他线程如果要更新这条记录就需要等待,这样不会出现超发现象。

3、乐观锁

3.1 使用版本号处理

恢复 RedPacketDao 查询库存接口getRedPacket的方法,不加入for update

RedPacketDao中加入方法

//使用版本号的方式进行
    @Update("update t_red_packet set stock = stock-1, version=version+1 where id = #{id} and version = #{version}")
    public int decreaseRedPacketForVersion(Long id,Integer version);

修改 UserRedPacketServiceImplgrapRedPacket方法

/**
     * 保存抢红包的信息
     * @param redPacketId 红包编号
     * @param userId 抢红包的用户编号
     * @return 影响记录数目
     */
    @Transactional(isolation = Isolation.READ_COMMITTED,propagation = Propagation.REQUIRED)
    public int grapRedPacket(Long redPacketId, Long userId){
            //使用for update时候会在读库存的时候锁住表,只能其他线程提交或者回滚事务释放锁
            RedPacket redPacket = redPacketDao.getRedPacket(redPacketId);
            //当前小红包库存大于0
            if(redPacket.getStock() > 0){
                //如果传入的线程保存version旧值给SQL判断,是否有其他线程修改过数据
                int update = redPacketDao.decreaseRedPacketForVersion(redPacketId, redPacket.getVersion());
                if(update == 0){
                    return FAILED;
                }
                //生成抢红包的信息
                UserRedPacket userRedPacket = new UserRedPacket();
                userRedPacket.setRedPacketId(redPacketId);
                userRedPacket.setUserId(userId);
                userRedPacket.setAmount(redPacket.getUnitAmount());
                userRedPacket.setNote("抢红包 "+redPacketId);
                int result = userRedPacketDao.grapRedPacket(userRedPacket);
                return result;
            }
        return FAILED;
    }

因为一些请求返回失败,会剩余许多库存

3.2 重入机制保证库存消耗

使用时间戳进行重入,修改 UserRedPacketServiceImplgrapRedPacket方法

@Transactional(isolation = Isolation.READ_COMMITTED,propagation = Propagation.REQUIRED)
    public int grapRedPacket(Long redPacketId, Long userId){
        long start = System.currentTimeMillis();
           while (true){
               //获取循环当前时间
               long end = System.currentTimeMillis();
               if(end -start>200){
                   return FAILED;
               }
               //使用for update时候会在读库存的时候锁住表,只能其他线程提交或者回滚事务释放锁
               RedPacket redPacket = redPacketDao.getRedPacket(redPacketId);
               //当前小红包库存大于0
               if(redPacket.getStock() > 0){
                   //如果传入的线程保存version旧值给SQL判断,是否有其他线程修改过数据
                   int update = redPacketDao.decreaseRedPacketForVersion(redPacketId, redPacket.getVersion());
                   if(update == 0){
                       continue;
                   }
                   //生成抢红包的信息
                   UserRedPacket userRedPacket = new UserRedPacket();
                   userRedPacket.setRedPacketId(redPacketId);
                   userRedPacket.setUserId(userId);
                   userRedPacket.setAmount(redPacket.getUnitAmount());
                   userRedPacket.setNote("抢红包 "+redPacketId);
                   int result = userRedPacketDao.grapRedPacket(userRedPacket);
                   return result;
               }else{
                   return FAILED;
               }
           }

    }

从结果上看没有出现超发现象,而且库存会清空。也可以考虑到重试次数

@Transactional(isolation = Isolation.READ_COMMITTED,propagation = Propagation.REQUIRED)
    public int grapRedPacket(Long redPacketId, Long userId){
        long start = System.currentTimeMillis();
           for(int i = 0;i<=3;i++){
               //使用for update时候会在读库存的时候锁住表,只能其他线程提交或者回滚事务释放锁
               RedPacket redPacket = redPacketDao.getRedPacket(redPacketId);
               //当前小红包库存大于0
               if(redPacket.getStock() > 0){
                   //如果传入的线程保存version旧值给SQL判断,是否有其他线程修改过数据
                   int update = redPacketDao.decreaseRedPacketForVersion(redPacketId, redPacket.getVersion());
                   if(update == 0){
                       continue;
                   }
                   //生成抢红包的信息
                   UserRedPacket userRedPacket = new UserRedPacket();
                   userRedPacket.setRedPacketId(redPacketId);
                   userRedPacket.setUserId(userId);
                   userRedPacket.setAmount(redPacket.getUnitAmount());
                   userRedPacket.setNote("抢红包 "+redPacketId);
                   int result = userRedPacketDao.grapRedPacket(userRedPacket);
                   return result;
               }else{
                   return FAILED;
               }
           }
           return FAILED;
    }