022:基于Redis实现秒杀抢购
1 微服务秒杀抢购实现方案
今日课程任务
- 大型电商秒杀抢购有哪些技术实现方案
- 秒杀抢购如何防止库存超卖的问题
- 秒杀抢购如何保证接口的安全性
- 基于Redis令牌桶方式实现秒杀抢购
- 基于lua脚本与java方式实现秒杀抢购区别
- 基于kafka异步实现扣库存,减少数据库IO操作
- 前端如何定时获取秒杀抢购消费结果
2 秒杀抢购如何实现前端优化
秒杀接口的实现;实际上就是一个互联网高并发解决问题+亿万级别商品详情页面设计。
前端:
- 实现动静分离 将静态资源(js、img、css、html)和动态资源(ftl、接口)分开部署;
- 将静态资源部署到第三方cdn中存放,减少带宽传输距离,提高网页加载速度;
- 将一些静态资源实现压缩(img/css/js等)生成.min文件,减少带宽的传输;
- 前端需要将抢购按钮置灰,防止用户重复触发秒杀接口;
- 生成一个图像验证码防止机器模拟刷秒杀接口;
- 基于openresty+lua实现亿万级别商品详情页面;
作用:目的就是能够将秒杀的商品详情页面快速展示给用户。
3 基于mysql行锁机制防止库存超卖
后端:
- 使用网关对秒杀接口实现限流、服务降级、熔断,有效的保护秒杀接口,使用黑名单和白名单限制部分刷接口用户(sentinel/redis/guava);
- 先扣库存,库存扣成功后下订单,跳转到聚合支付;
秒杀成功->扣库存->下订单 生成一个支付令牌跳转到聚合支付令牌
注意:扣库存是秒杀成功之后扣而不是用户支付成功后扣
- 秒杀成功就是不支付如何实现库存回滚?
30分钟订单超时的问题 解决:基于redis过期key或者MQ延迟队列 - 假设当前库存为1的情况下,多个用户同时抢购最后一个库存,怎么防止库存超卖的问题?
解决:库存>0或者乐观锁、数据库自带行锁机制实现
update meite_seckill set inventory=inventory-1,version=version+1 where seckill_id=#{seckillId} and inventory>0
mysql行锁 多个线程同时操作同一行数据的时候,最终只会有一个线程进行操作,目的是为了保证线程安全性问题,防止数据脏读。
redis多线程同时操作同一个key做set操作,没有行锁机制。
4 基于乐观锁方式防止库存超卖
update meite_seckill set inventory=inventory-1,version=version+1 where seckill_id=#{seckillId} and inventory>0 and version=#{version}
乐观锁与悲观锁区别:
乐观锁:自旋形式,程序不会阻塞 类似java CAS无锁机制
悲观锁:会导致程序阻塞,效率较低 synchronized/lock
使用乐观锁机制防止库存马上被抢完,采用自旋的形式靠运气秒杀抢购。
5 基于Redis生成令牌桶方式实现秒杀
如果有10万人抢购商品,会对数据库做10万次的io操作,对数据库性能压力非常大。
库存如果有100个的情况下,只需要做100次减库存。
采用令牌桶方式实现。如果人为修改库存,令牌桶的令牌数量也需要修改。
- 基于Redis生成库存令牌桶,当前库存有100个就生成100个令牌,只要能够抢到令牌就可以去减库存,可以有效减少数据库的io操作。
Key=秒杀的商品id value=list(token)
6 提供生成令牌桶接口
秒杀成功明细表
DROP TABLE IF EXISTS `meite_order`;
CREATE TABLE `meite_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='秒杀成功明细表';
INSERT INTO `meite_order` VALUES ('10000', '15528415441', '1', '2020-04-28 22:18:01');
秒杀库存表
DROP TABLE IF EXISTS `meite_seckill`;
CREATE TABLE `meite_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='秒杀库存表';
INSERT INTO `meite_seckill` VALUES ('10000', '蚂蚁课堂第七期微服务架构', '99', '2020-04-28 16:47:44', '2020-04-30 16:47:47', '2020-04-28 16:47:50', '2');
新建项目mt-shop-service-api-seckill、mt-shop-service-seckill
public interface OrderTokenService {
/**
* 新增对应商品库存令牌桶
*
* @seckillId 商品库存id
*/
@GetMapping("/addSpikeToken")
public BaseResponse<JSONObject> addSpikeToken(Long seckillId, Long tokenQuantity);
}
@RestController
public class OrderTokenServiceImpl extends BaseApiService implements OrderTokenService {
@Autowired
private SeckillMapper seckillMapper;
@Autowired
private TokenUtil tokenUtil;
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("令牌正在生成中.....");
}
private void createSeckillToken(Long seckillId, Long tokenQuantity) {
tokenUtil.createListToken("seckill_", seckillId + "", tokenQuantity);
}
}
TokenUtil
public void createListToken(String keyPrefix, String redisKey, Long tokenQuantity) {
List<String> listToken = getListToken(keyPrefix, tokenQuantity);
redisUtil.setList(redisKey, listToken);
}
public List<String> getListToken(String keyPrefix, Long tokenQuantity) {
List<String> listToken = new ArrayList<>();
for (int i = 0; i < tokenQuantity; i++) {
String token = keyPrefix + UUID.randomUUID().toString().replace("-", "");
listToken.add(token);
}
return listToken;
}
测试效果:
7 MQ异步消费如何获取消费结果
- 采用多线程或者MQ异步根据令牌修改库存,先返回“正在秒杀中”结果给客户端。
问题:秒杀接口不能立即响应秒杀结果,所以前端ajax采用根据用户userId或者手机号码主动定时查询秒杀结果。
案例:12306提示正在出票中。。过段时间提示出票成功或者出票失败
8 MQ消费者如何保证幂等性问题
@RestController
public class SpikeCommodityServiceImpl extends BaseApiService implements SpikeCommodityService {
@Autowired
private TokenUtil tokenUtil;
@Autowired
private RedisUtil redisUtil;
@Autowired
private SpikeManager spikeManager;
@Override
public BaseResponse<JSONObject> spike(String userPhone, Long seckillId) {
// 1.验证参数
if (userPhone == null) {
return setResultError("userPhone不能为空");
}
if (seckillId == null) {
return setResultError("seckillId不能为空");
}
// 2.对用户的频率实现限制 10s setnx key存在返回1 key如果不存在的情况0
Boolean spikeNx = redisUtil.setNx(userPhone, seckillId + "", 10l);
if (!spikeNx) {
return setResultError("您当前在10s内访问频率过多,请稍后重试!");
}
// 3. 从Redis中获取令牌,
String spikeToken = tokenUtil.getListKeyToken(seckillId + "");
if (StringUtils.isEmpty(spikeToken)) {
return setResultError("很抱歉,当前商品已经售空,请下次再来");
}
// 4.修改库存、下订单 肯定不同步 一定要是异步实现 MQ异步实现 // 投递消息到MQ中
spikeManager.sendSpikeMsg(userPhone, seckillId, spikeToken);
return setResultError("正在抢购中...");
}
}
@Component
@EnableAsync
public class SpikeManager {
@Autowired
private KafkaTemplate<String, String> kafkaTemplate;
public void sendOrderMsg(JSONObject data){
kafkaTemplate.send("mayikt-topic-spike", null, data.toJSONString());
}
@Async
public void sendSpikeMsg(String userPhone,Long seckillId,String spikeToken){
JSONObject data = new JSONObject();
data.put("userPhone",userPhone);
data.put("seckillId",seckillId);
data.put("spikeToken",spikeToken);
// 投递消息到MQ中
sendOrderMsg(data);
}
}
@Component
public class InventoryConsumer {
@Autowired
private SeckillMapper seckillMapper;
@Autowired
private OrderMapper orderMapper;
@KafkaListener(topics = "mayikt-topic-spike")
public void receive(ConsumerRecord<?, ?> consumer) throws Exception {
String json = (String) consumer.value();
if (StringUtils.isEmpty(json)) {
return;
}
JSONObject jsonObject = JSONObject.parseObject(json);
String userPhone = jsonObject.getString("userPhone");
Long seckillId = jsonObject.getLong("seckillId");
// 根据userid+seckillId+msg自带的消息全局id 解决幂等性问题
// 3.根据该id查询该商品是否存在
SeckillEntity seckillEntity = seckillMapper.findBySeckillId(seckillId);
if (seckillEntity == null) {
return;
}
// 4.对库存实现减去1
Long version = seckillEntity.getVersion();
int resultSeckill = seckillMapper.inventoryDeduction(seckillId, version);
if (resultSeckill <= 0) {
return;
}
// 5.插入订单记录
OrderEntity orderEntity = new OrderEntity(seckillId, userPhone);
int resultOrder = orderMapper.insertOrder(orderEntity);
if (resultOrder <= 0) {
return;
}
}
}
效果测试: