秒杀处理超卖问题,第一种,利用悲观锁处理,(处理的书数据库)
没处理之前(大概有四百个线程抢到)(总共10000个线程)(总共有10个产品数量)
这个直观,在上面加一个成员变量,
private static int count=0;
在查询语句里加一个for update
但是还是会超卖,因为一个业务层操作了几个不同的dao层方法,所以要让他们在一个事务里,保持原子性
悲观锁虽然能控制住,但是效率低,性能不高
缺点:会有很多的无效请求,我们只有10个有效请求,剩下的9000多个无用请求也会去访问数据库,所以可以需要拦截掉,以此来保护数据库
乐观锁来防止超卖的问题
用预扣存的方式来解决超卖
定时任务从数据库查询即将开始的秒杀活动,然后讲秒杀活动的信息发一份到Redis,redis里面需要怎么存数据,关键存的是秒杀商品的数量,key-----》miaosha:product:1(1是活动ID) value---->是活动的产品数量 存数量先要判断get数量是否大于0,然后还要递减,redis操作一个操作时原子性,两个操作就不是原子性了,所以可以写一个lua脚本,自己写一个命令,将两个操作组合成一个,这样的话直接存数量不太好!此外,可以换一种方式存,redis有个数据结构是List结构,list结构可以弹数据
即value—》list(1001,1002),存产品的ID,抢一个弹一个
另外里面还要存用户的信息:key—》miaosha:user:1(活动ID) value—》set(101,102)存用户的标识,比如用户的ID, 用什么数据结构,一般一个活动只能允许用户抢到一个商品,既不能重复抢购,,所以用redis的set结构来存用户的标识
客户端抢购商品,redis里面开始减数量,并且记录抢购成功的用户信息,通过mq向订单发送消息,并且同步到数据库
第一步:写定时任务
/**
-
写一个任务管理器,为了将即将开始的活动保存到redis里
-
首先判断时间,当前的时间
-
查询可以进行秒杀的活动还有一个方法写清理redis
*/
@Component
public class MiaoshaTask {
@Resource(name = “myStringRedisTemplate”)
private RedisTemplate<String,Object> redisTemplate;
@Resource(name = “myIntegerRedisTemplate”)
private RedisTemplate<String,Integer> IntegerRedisTemplate;
@Autowired
private MiaoshaMapper miaoshaMapper;@Scheduled(cron = “0/10 * * * * ?”)
public void queryCanStartKillProduct(){
//查询即将开始的秒杀活动
//select * from miaosha where flag=1 and miaosha_check=1 andstatus
=0 and now() between start_time and end_time
List list=miaoshaMapper.queryStartActive();
if (list!=null && !list.isEmpty()){
for (Miaosha miaosha : list) {
//构建一个秒杀活动信息的key
String key = new StringBuilder(“miaosha:product:”).append(miaosha.getId()).toString();
//得到秒杀商品数量
// for (Integer i = 0; i < miaosha.getCount(); i++) {
// //数量有多少个,就保存多少个商品ID在List中
// redisTemplate.opsForList().leftPush(key,miaosha.getProductId());
// }
//一个一个添加需要多次访问数据库,所以批量操作,只访问一次数据库,有两种方式,一种是redis的流水线,也就是管道,还有一种是集合
//管道略,看笔记,只写第二种
Collection ids=new ArrayList<>(miaosha.getCount());
for (Integer i = 0; i < miaosha.getCount(); i++) {
ids.add(miaosha.getProductId());
}
IntegerRedisTemplate.opsForList().leftPushAll(key,ids);
//把未开启秒杀状态改为开启状态 0—》1
miaosha.setStatus(“1”);
miaoshaMapper.updateByPrimaryKeySelective(miaosha);
//存秒杀活动本身;
String activeKey=new StringBuilder(“miaosha:active:”).append(miaosha.getId()).toString();
redisTemplate.opsForValue().set(activeKey,miaosha);
}
System.out.println(“redis初始化信息成功”);
}}
/**-
查询结束的秒杀活动,并将redis的数据进行清理
-
判断时间
-
当前时间大于结束时间就把状态改为2
*/
@Scheduled(cron = “0/10 * * * * ?”)
public void queryEndKillProduct() {
List list= miaoshaMapper.queryEndKillProduct();
if (list!=null && !list.isEmpty()){
for (Miaosha miaosha : list) {
//构建一个秒杀活动信息的key
String key = new StringBuilder(“miaosha:product:”).append(miaosha.getId()).toString();
//清理商品数量信息
IntegerRedisTemplate.delete(key);
//清理秒杀活动本身;
String activeKey=new StringBuilder(“miaosha:active:”).append(miaosha.getId()).toString();
redisTemplate.delete(activeKey);
//把开启秒杀状态改为已结束状态 1—》2
miaosha.setStatus(“2”);
miaoshaMapper.updateByPrimaryKeySelective(miaosha);} System.out.println("清理成功");
}
}
}
-
redis的工具类,改成集合的方式,value 不能是对象
package com.yw.miaosha.config;
import com.fasterxml.jackson.annotation.JsonAutoDetect;
import com.fasterxml.jackson.annotation.PropertyAccessor;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.serializer.Jackson2JsonRedisSerializer;
import org.springframework.data.redis.serializer.StringRedisSerializer;
@Configuration
public class RedisConfig {
@Bean(name="myStringRedisTemplate")
public RedisTemplate<String,Object> getTemplate(RedisConnectionFactory connectionFactory){
RedisTemplate<String,Object> redisTemplate=new RedisTemplate<>();
redisTemplate.setConnectionFactory(connectionFactory);
//key的序列化
redisTemplate.setKeySerializer(new StringRedisSerializer());
//我们的 value 使用什么方式来进行序列化,因为 value是任意的对象,所以我们使用 json
Jackson2JsonRedisSerializer jackson2JsonRedisSerializer = new Jackson2JsonRedisSerializer(Object.class);//创建一个解析为 json 的序列化对象
ObjectMapper objectMapper = new ObjectMapper();//因为 jackson 中是使用ObjectMapper来进行序列化的,所以我们需要设置给ObjectMapper
objectMapper.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY);
objectMapper.enableDefaultTyping(ObjectMapper.DefaultTyping.NON_FINAL);//设置所有非 final 修饰的变量都可以被序列化
jackson2JsonRedisSerializer.setObjectMapper(objectMapper);//指定我们的 objectmapper
//设置 value 的序列化方式
redisTemplate.setValueSerializer(jackson2JsonRedisSerializer);//设置值的序列化方式为 json
redisTemplate.setHashValueSerializer(jackson2JsonRedisSerializer);
redisTemplate.afterPropertiesSet();
return redisTemplate;
}
//方法名不能重复啊白痴啊白痴
@Bean(name="myIntegerRedisTemplate")
public RedisTemplate<String,Integer> getIntegerTemplate(RedisConnectionFactory connectionFactory){
RedisTemplate<String,Integer> redisTemplate=new RedisTemplate<>();
redisTemplate.setConnectionFactory(connectionFactory);
//key的序列化
redisTemplate.setKeySerializer(new StringRedisSerializer());
return redisTemplate;
}
}
第二步:业务层的处理
/**
- 判断活动状态
- 1未开启
- 2已经结束
- 3用户抢购过了,不能在抢购
- 4商品被抢购完了
- 5能进行抢购
- reids版本(预扣库存方案)实现秒杀处理逻辑
- @param userId
- @param id
- @return
*/
@Override
public ResultBean kill(Integer userId, Integer id) {
//通过活动Id获取秒杀活动的信息
// Miaosha miaosha = miaoshaMapper.selectByPrimaryKey(id);
String activeKey=new StringBuilder(“miaosha:active:”).append(id).toString();
Miaosha miaosha = (Miaosha) redisTemplate.opsForValue().get(activeKey);
if (“0”.equals(miaosha.getStatus())){
throw new MiaoshaException(“秒杀活动还未开始,请等待。。。”);
}
if (“2”.equals(miaosha.getStatus())){
throw new MiaoshaException(“秒杀活动已经结束,请下次吧”);
}
//通过redis查找用户是抢购过,构建key
String key=new StringBuilder(“miaosha:user:”).append(miaosha.getId()).toString();
//用户的信息保存在set数据结构中
Boolean member = redisTemplate.opsForSet().isMember(key, userId);
if (member){
//已经抢购过了,不能够重复抢购
throw new MiaoshaException(“已经抢购过了,不能够重复抢购”);
}
//商品是否抢购完了
String productKey=new StringBuilder(“miaosha:product:”).append(miaosha.getId()).toString();
//id为空,说明抢购完了
Integer productId = (Integer) redisTemplate.opsForList().leftPop(productKey);
if (productId==null){
//已经没有数量了
throw new MiaoshaException(“商品抢购完了,下次吧”);
}
//有数量,能进行抢购,抢购成功
//存用户Id
redisTemplate.opsForSet().add(key,userId);
return new ResultBean(“200”,“抢购成功”);
}
第三步:将成功抢购成功的用户信息发送给订单
导依赖
自动注入模板
@Autowired
private RabbitTemplate rabbitTemplate;
首先:创建订单表,订单表里需要用户信息,商品名称,订单编号,也可以直接创建一个Map集合,将数据放进去
@Override
public ResultBean kill(Integer userId, Integer id) {
//通过活动Id获取秒杀活动的信息
// Miaosha miaosha = miaoshaMapper.selectByPrimaryKey(id);
String activeKey=new StringBuilder(“miaosha:active:”).append(id).toString();
Miaosha miaosha = (Miaosha) redisTemplate.opsForValue().get(activeKey);
if (“0”.equals(miaosha.getStatus())){
throw new MiaoshaException(“秒杀活动还未开始,请等待。。。”);
}
if (“2”.equals(miaosha.getStatus())){
throw new MiaoshaException(“秒杀活动已经结束,请下次吧”);
}
//通过redis查找用户是抢购过,构建key
String key=new StringBuilder(“miaosha:user:”).append(miaosha.getId()).toString();
//将用户的信息保存在set数据结构中,如果能保存,代表没有抢购过,会返回1,有这个用户的信息,会返回0
long result = redisTemplate.opsForSet().add(key,userId);
System.err.println(result);
if (result0){
//已经抢购过了,不能够重复抢购
throw new MiaoshaException(“已经抢购过了,不能够重复抢购”);
}
//商品是否抢购完了
String productKey=new StringBuilder(“miaosha:product:”).append(miaosha.getId()).toString();
//id为空,说明抢购完了
Integer productId = (Integer) IntegerRedisTemplate.opsForList().leftPop(productKey);
//System.err.println(productId);
if (productIdnull){
//已经没有数量了,不知道为什么,所有代码要写在抛出异常之前
//当商品Id为空的时候,加一个用户删除一个用户,程序是向下执行的,一定要记得,不会debug一下
redisTemplate.opsForSet().remove(key,userId);
//将状态设置为2,已结束活动
miaosha.setStatus(“2”);
miaoshaMapper.updateByPrimaryKeySelective(miaosha);
throw new MiaoshaException("商品抢购完了,下次吧");
}
//5,有数量,能进行抢购,抢购成功
//存用户Id
// redisTemplate.opsForSet().add(key,userId);
//SSSS指毫秒
SimpleDateFormat simpleDateFormat=new SimpleDateFormat(“yyyyMMddhhmmssSSSS”);
String orderNo = simpleDateFormat.format(System.currentTimeMillis()).toString();
//给订单系统发送消息,然后订单系统在往数据库表中添加数据
//关键的数据 user_id product—id count orderNo product_price
Map<String,Object> paramMap=new HashMap<>();
paramMap.put(“user_id”, userId);
paramMap.put(“product_id”, miaosha.getProductId());
paramMap.put(“count”, 1);
paramMap.put(“product_price”, miaosha.getProductPrice());
//订单编号需要唯一
//有四种方式:UUID,时间戳,Redis的自增,雪花算法
//这里使用时间戳,UUId可读性不好
paramMap.put(“orderNo”, orderNo+userId);
rabbitTemplate.convertAndSend("order-exchange", "order.create",paramMap);
return new ResultBean("200","抢购成功");
}
}
订单系统接收消息
package com.yw.shoporder.listener;
import org.springframework.amqp.rabbit.annotation.RabbitListener;
import org.springframework.stereotype.Component;
import java.util.Map;
/**
- 监听器,秒杀系统,将抢购到的用户的信息发送订单系统
- 发送的内容在map集合里
- reids用的set数据类型,key:miaoshao:user:id value:userid
*/
@Component
public class OrderListener {
@RabbitListener(queues = “order-queue”)
public void receive(Map<String,Object> map){
System.out.println(map);
System.out.println(map.get(“orderNo”));
}
}
秒杀系统和订单系统交互
解决问题:秒杀订单量暴增的问题
办法:1:订单系统加集群
2、限流: mq中的QOS+手动ACK
application.yml文件里的配置
listener:
simple:
prefetch: 1 #表示每次rabbitmq给我发送几个消息
acknowledge-mode: manual #代表手动ack
订单系统的监听器的代码,实现接收秒杀系统抢购成功用户的消息并限流
package com.yw.shoporder.listener;
import com.rabbitmq.client.Channel;
import org.springframework.amqp.core.Message;
import org.springframework.amqp.rabbit.annotation.RabbitListener;
import org.springframework.stereotype.Component;
import java.io.IOException;
import java.util.Map;
/**
- 监听器,秒杀系统,将抢购到的用户的信息发送订单系统
- 发送的内容在map集合里
- reids用的set数据类型,key:miaoshao:user:id value:userid
*/
@Component
public class OrderListener {
@RabbitListener(queues = “order-queue”)
public void receive(Map<String,Object> map, Channel channel, Message message){
System.out.println(map);
System.out.println(map.get(“orderNo”));
try {
System.out.println(“模拟订单系统生成订单表和订单明细信息。。。。。。”);
Thread.sleep(3000);
} catch (InterruptedException e) {
e.printStackTrace();
}
//手动ack
long deliveryTag = message.getMessageProperties().getDeliveryTag();
try {
channel.basicAck(deliveryTag,false);
} catch (IOException e) {
e.printStackTrace();
}
}
}
安全加固
隐藏秒杀地址:
即使是内部员工,也无法提前知道秒杀接口的地址,因为最终的秒杀地址是根据用户信息来随机生成的。
而对于攻击者来说,秒杀地址是随机生成的且一分钟内有效(甚至可以设置一次性的),意味着后续无法持续使用,每次都需要重新获取新的秒杀地址,从而加大其攻击的成本。
秒杀开始之前,先去请求接口获取秒杀地址,如果未到时,则无法获取到秒杀地址。
如何配置动态秒杀地址、
1、点击秒杀获取请求地址,然后Controller通过业务层生成UUid的随机地址在redis保存并返回,用户带着生成的path地址访问秒杀页面,传过来的path如果和redis里的一致,即可以访问
业务层代码:
@Override
public ResultBean getPath(Integer userId, Integer id) {
//先判断活动开没开始
String activeKey=new StringBuilder(“miaosha:active:”).append(id).toString();
Miaosha miaosha = (Miaosha) redisTemplate.opsForValue().get(activeKey);
if (“0”.equals(miaosha.getStatus())){
throw new MiaoshaException(“秒杀活动还未开始,请等待。。。”);
}
if (“2”.equals(miaosha.getStatus())){
throw new MiaoshaException(“秒杀活动已经结束,请下次吧”);
}
//动态生成随机的path
String path= UUID.randomUUID().toString();
//存到Redis,需要拼一个key
String pathKey = new StringBuilder(“miaosha:”).append(userId).append("😊.append(id).toString();
//存到redis
redisTemplate.opsForValue().set(pathKey,path);
//设置生存时间,1分钟
redisTemplate.expire(pathKey,1, TimeUnit.MINUTES);
return new ResultBean(“200”,path);
}
在kill的方法里在添加一个变量
@Override
public ResultBean kill(Integer userId, Integer id, String path) {
//判断path是否合法
//从redis里查一下
String pathKey = new StringBuilder(“miaosha:”).append(userId).append("😊.append(id).toString();
Object o = redisTemplate.opsForValue().get(pathKey);
if (o==null){
throw new MiaoshaException(“秒杀地址不合法”);
}
//一次性有效
redisTemplate.delete(pathKey);
//通过活动Id获取秒杀活动的信息
// Miaosha miaosha = miaoshaMapper.selectByPrimaryKey(id);
String activeKey=new StringBuilder(“miaosha:active:”).append(id).toString();
Miaosha miaosha = (Miaosha) redisTemplate.opsForValue().get(activeKey);
if (“0”.equals(miaosha.getStatus())){
throw new MiaoshaException(“秒杀活动还未开始,请等待。。。”);
}
Controller处理
/**
-
安全,为了防止内部员工知道接口访问,所以生成动态的
-
首先,客户端发送请求到生成pat接口。接口返回动态生成的path(前提:到了秒杀时间+用户已经登录)
-
key—>miaosha:userId,secKillId value—>path
-
生存时间一分钟,也可以一次性,用完就丢
-
拿回返回的path,在构建一个新的请求地址,请求秒杀接口
-
判断是否合法
-
只要判断redis中是否存着path,如果没有就是不合法的请求
*/
@RequestMapping("/getPath")
@ResponseBody
public ResultBean getPath(Integer userId,Integer id){try {
return miaoshaService.getPath(userId,id);
} catch (MiaoshaException e) {
return new ResultBean(“400”,e.getMessage());
}
}
kill方法里加Path变量
/**- 后台处理,
- 第一,需要知道是哪个用户在抢购
- 第二,秒杀的并发的处理
*/
@RequestMapping("/kill/{path}")
@ResponseBody
public ResultBean kill(@PathVariable String path, Integer userId, Integer id){
// 判断Path的合法性
try {
//System.err.println(userId);
return miaoshaService.kill(userId,id,path);
} catch (MiaoshaException e) {
//e.getMessage()是业务层抛出的信息
return new ResultBean(“400”,e.getMessage());
}
}
10.2,添加验证码
点击秒杀之前,必须填写验证码
严格来说,没有无法破解的验证码,添加验证码,也是为了加大攻击的成本
1,添加依赖
com.github.penggle
kaptcha
2.3.2
2,配置生成验证码的Bean
@Bean
public DefaultKaptcha getDefaultKaptche(){
DefaultKaptcha defaultKaptcha = new DefaultKaptcha();
Properties properties = new Properties();
// 图片边框
properties.setProperty(“kaptcha.border”, “yes”);
// 边框颜色
properties.setProperty(“kaptcha.border.color”, “106,180,90”);
// 字体颜色
properties.setProperty(“kaptcha.textproducer.font.color”, “red”);
// 图片宽
properties.setProperty(“kaptcha.image.width”, “110”);
// 图片高
properties.setProperty(“kaptcha.image.height”, “40”);
// 字体大小
properties.setProperty(“kaptcha.textproducer.font.size”, “30”);
// session key
properties.setProperty(“kaptcha.session.key”, “code”);
// 验证码长度
properties.setProperty(“kaptcha.textproducer.char.length”, “4”);
// 字体
properties.setProperty(“kaptcha.textproducer.font.names”, “宋体,楷体,微软雅黑”);
defaultKaptcha.setConfig(new Config(properties));
return defaultKaptcha;
}
3,提供一个获取验证码的接口
@Autowired
private DefaultKaptcha defaultKaptcha;
…
@RequestMapping("/getCode")
@ResponseBody
public void getCode(HttpServletResponse response){
//生成验证码 - 保存到服务器
String text = defaultKaptcha.createText();
//根据验证码生成图片
BufferedImage image = defaultKaptcha.createImage(text);
//将验证码图片写回客户端
try {
ImageIO.write(image, “jpg”, response.getOutputStream());
} catch (IOException e) {
e.printStackTrace();
}
}
输入验证码,点击立即抢购,把验证码带到后端,redis里面现存一份,controller生成验证码,存到redis里,哪个用户对应哪儿个验证码,能够抢购,说明已经登录。
11,限流防刷
防止用户在短期内发送大量的请求,将恶意请求拦截在上层
mq的限流是系统和 系统之间的,现在是客户端和系统之间的限流
12,数据的一致性考虑
分布式事务,采用MQ实现柔性事务,实现最终的数据一致性