快捷搜索:

实战高并发秒杀实现(2):防止库存超卖问题(超详细)

一、理论基础

(1)秒杀数据库的设计;

(2)基于数据库乐观锁防止库存超卖;

(3)基于redis实现用户行为频率限制——用户再次抢购时提示“该用户操作频繁,请少稍后重试,一般可设置10秒后才能再次调用秒杀接口”;

(4)基于Token令牌+MQ实现异步修改库存;

(5)使用apache-jmeter做秒杀压力测试(可配置线程数和循环次数(每个线程跑多少个请求数),比如线程数100循环次数100,则模式10000次请求)。

1.2、数据库崩溃问题

问题:

如果秒杀的请求过多,对数据库频繁的IO操作,可能会产生数据库崩溃问题。这时搞分表分库、读写分离、做缓存、限流、熔断都不会起作用。——最有用的是,提前生成令牌,存放在临牌桶中,异步发送到MQ中(token只经过缓存不经过数据库),MQ异步修改库存。

解决方案:

假设库存有100个,但是可能会有10万个并发,要解决数据库频繁IO,可以提前生成好数据库 库存个数个Token,比如这里是100个Token,比如这时有10万个并发,谁能抢到Token,再把Token扔到MQ里面,在MQ里面异步实现修改库存。这时就能做到多少个库存有多少个请求到数据库,而不是10万个请求访问10万次数据库,防止了没抢到的用户无法修改数据库,从而减少了IO操作。

1.3、秒杀骨架图

1.4、总体实现步骤

(1)后台系统在发布秒杀商品的时候,给对应商品添加库存token;

二、实战

2.1、秒杀数据库设计

(1)秒杀 成功明细(记录)表

秒杀抢购的订单和普通下单的订单是完全不一样的

CREATE TABLE `shop_order` (
  `seckill_id` bigint(20) NOT NULL COMMENT 秒杀商品id,
  `user_phone` bigint(20) NOT NULL COMMENT 用户手机号,
  `state` tinyint(4) NOT NULL DEFAULT -1 COMMENT 状态标示:-1:无效 0:成功 1:已付款 2:已发货,
  `create_time` datetime NOT NULL COMMENT 创建时间,
  KEY `idx_create_time` (`create_time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT=秒杀成功明细表;

(2)秒杀 库存表

比如jmeter模拟时发出10000个请求,但是库存只有100,则有9900个用户抢不到。

version字段:代表更新次数,默认是0,库存没更新一次则加1,比如库存1000,变为997,则version值变为3,并且成功明细表有3条数据。

CREATE TABLE `shop_seckill` (
  `seckill_id` bigint(20) NOT NULL COMMENT 商品库存id,
  `name` varchar(120) CHARACTER SET utf8 NOT NULL COMMENT 商品名称,
  `inventory` int(11) NOT NULL COMMENT 库存数量,
  `start_time` datetime NOT NULL COMMENT 秒杀开启时间,
  `end_time` datetime NOT NULL COMMENT 秒杀结束时间,
  `create_time` datetime NOT NULL COMMENT 创建时间,
  `version` bigint(20) NOT NULL DEFAULT 0,
  PRIMARY KEY (`seckill_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT=秒杀库存表;

2.2、后台系统在发布秒杀商品的时候,给对应商品添加库存token

新增对应商品令牌桶:

/**
	 * 新增对应商品库存令牌桶
	 * @seckillId 商品库存id
	 */
	@RequestMapping("/addSpikeToken")
	public BaseResponse<JSONObject> addSpikeToken(Long seckillId, Long tokenQuantity);

@Async
	private void createSeckillToken(Long seckillId, Long tokenQuantity) {
		generateToken.createListToken("seckill_", seckillId + "", tokenQuantity);
	}

2.2、秒杀商品服务接口

(1)SpikeCommodityService:

点击立即抢购(秒杀)按钮,接口传入电话或者用户ID+库存商品ID (秒杀 库存表的库存ID)

/**
 * 秒杀商品服务接口
 */
public interface SpikeCommodityService {

	/**
	 * 用户秒杀接口 phone和userid都可以的
	 * 
	 * @phone 手机号码<br>
	 * @seckillId 库存id
	 * @return
	 */
	@RequestMapping("/spike")
	public BaseResponse<JSONObject> spike(String phone, Long seckillId);

	/**
	 * 新增对应商品库存令牌桶
	 * 
	 * @seckillId 商品库存id
	 */
	@RequestMapping("/addSpikeToken")
	public BaseResponse<JSONObject> addSpikeToken(Long seckillId, Long tokenQuantity);

}

(2)SpikeCommodityServiceImpl:

秒杀接口实现步骤:

    参数验证; 从redis从获取对应的秒杀token——采用redis数据库类型为 list类型, key为 商品库存id ,list存 多个秒杀token,一个库存ID可以装多个秒杀token; 获取到秒杀token之后,异步(@Async异步注解)放入mq中实现修改商品的库存——多线程异步生成token。 方法添加@HystrixCommand(fallbackMethod = "spikeFallback")——实现服务隔离和降级
@RestController
@Slf4j
public class SpikeCommodityServiceImpl extends BaseApiService<JSONObject> implements SpikeCommodityService {
	@Autowired
	private SeckillMapper seckillMapper;
	@Autowired
	private GenerateToken generateToken;
	@Autowired
	private SpikeCommodityProducer spikeCommodityProducer;

	@Override
	@Transactional
    @HystrixCommand(fallbackMethod = "spikeFallback")
	public BaseResponse<JSONObject> spike(String phone, Long seckillId) {
		// 1.参数验证
		if (StringUtils.isEmpty(phone)) {
			return setResultError("手机号码不能为空!");
		}
		if (seckillId == null) {
			return setResultError("商品库存id不能为空!");
		}
		// 2.从redis从获取对应的秒杀token
		String seckillToken = generateToken.getListKeyToken(seckillId + "");
		if (StringUtils.isEmpty(seckillToken)) {
			log.info(">>>seckillId:{}, 亲,该秒杀已经售空,请下次再来!", seckillId);
			return setResultError("亲,该秒杀已经售空,请下次再来!");
		}

		// 3.获取到秒杀token之后,异步放入mq中实现修改商品的库存
		sendSeckillMsg(seckillId, phone);
		return setResultSuccess("正在排队中.......");
	}

	/**
	 * 获取到秒杀token之后,异步放入mq中实现修改商品的库存
	 */
	@Async
	private void sendSeckillMsg(Long seckillId, String phone) {
		JSONObject jsonObject = new JSONObject();
		jsonObject.put("seckillId", seckillId);
		jsonObject.put("phone", phone);
		spikeCommodityProducer.send(jsonObject);
	}

	/**
	 * 使用多线程异步生产令牌
	 * 
	 * @param seckillId
	 * @param tokenQuantity
	 * @return
	 */

	// 采用redis数据库类型为 list类型 key为 商品库存id list 多个秒杀token
	@Override
	public BaseResponse<JSONObject> addSpikeToken(Long seckillId, Long tokenQuantity) {
		// 1.验证参数
		if (seckillId == null) {
			return setResultError("商品库存id不能为空!");
		}
		if (tokenQuantity == null) {
			return setResultError("token数量不能为空!");
		}
		SeckillEntity seckillEntity = seckillMapper.findBySeckillId(seckillId);
		if (seckillEntity == null) {
			return setResultError("商品信息不存在!");
		}
		// 2.使用多线程异步生产令牌
		createSeckillToken(seckillId, tokenQuantity);
		return setResultSuccess("令牌正在生成中.....");
	}

	@Async
	private void createSeckillToken(Long seckillId, Long tokenQuantity) {
		generateToken.createListToken("seckill_", seckillId + "", tokenQuantity);
	}

}

(3)修改库存:SeckillMapper

方案1:行锁机制(悲观锁)——数据库自带

如果不适用乐观锁防止超卖,直接更新数据库时使用一个“and inventory>0方式”——库存大于零才更新就可以了。

然后,mysql中每次在更新数据库时有行锁机制(悲观锁),不存在超卖问题

update shop_seckill set inventory=inventory-1,where  seckill_id=#{seckillId} and inventory>0

方案2:version乐观锁实现(乐观锁是通过version版本号控制的)

version版本号乐观锁机制:

多个线程同时update的时候只有一个能成功,谁成功谁拿到锁。成功的线程修改成功时版本号加1,所以其余的线程就无法更新了(因为版本号变了):

update shop_seckill set inventory=inventory-1, version=version+1 where  seckill_id=#{seckillId} and inventory>0  and version=#{version} ;

以下是悲观锁的实现:

public interface SeckillMapper {

	/**
	 * 使用乐观锁修改库存信息 and inventory>0方式
	 * @param seckillId
	 * @return
	 */
	@Update("update meite_seckill set inventory=inventory-1 where  seckill_id=10001 and inventory>0")
	int optimisticLockSeckill(Long seckillId);

	/**
	 * 基于版本号形式实现乐观锁
	 * @param seckillId
	 * @return
	 */
	@Update("update meite_seckill set inventory=inventory-1 ,version=version+1 where  seckill_id=#{seckillId} and version=#{version} and inventory>0;")
	int optimisticVersionSeckill(@Param("seckillId") Long seckillId, @Param("version") Long version);

	@Update("update meite_seckill set inventory=inventory-1 where  seckill_id=10001;")
	int inventoryDeduction(Long seckillId);

	@Select("SELECT seckill_id AS seckillId,name as name,inventory as inventory,start_time as startTime,end_time as endTime,create_time as createTime,version as version from meite_seckill where seckill_id=#{seckillId}")
	SeckillEntity findBySeckillId(Long seckillId);

}
经验分享 程序员 职场和发展