简易的秒杀系统
-
防止超卖问题
-
令牌桶限流
-
整合Redis缓存
控制器
@RestController
@RequestMapping("stock")
public class StockController {
@Autowired
private OrderService orderService;
//秒杀方法
@GetMapping("kill")
public String kill(Integer id){
//根据秒杀商品的id去执行秒杀业务
try {
int orderId = orderService.kill(id);
return "秒杀成功,秒杀ID = "+String.valueOf(orderId);
}catch (Exception e){
e.printStackTrace();
return e.getMessage();
}
}
}
service
public interface OrderService {
//根据id去秒杀商品
int kill(Integer id);
}
service的实现类
@Service
@Transactional
public class OrderServiceImpl implements OrderService {
@Autowired
private StockDao stockDao;
@Autowired
private OrderDao orderDao;
@Override
public int kill(Integer id) {
Order order = new Order();
//根据商品的id去校验库存
Stock stock = stockDao.checkStock(id);
//如果已售的数量和库存数量相等的话,就说明秒杀完毕
if (stock.getCount().equals(stock.getSale())){
throw new RuntimeException("该商品已经卖完了");
}else {
//校验完毕就扣除库存 给已售+1
stock.setSale(stock.getSale()+1);
stockDao.updateSale(stock);
//创建订单操作
order.setCreate_time(new Date());
order.setName(stock.getName());
order.setSid(stock.getId());
orderDao.createOrder(order);
return order.getId();
}
}
}
dao层
@Mapper
@Repository
public interface OrderDao {
//创建订单
void createOrder(Order order);
}
@Mapper
@Repository
public interface StockDao {
//根据商品的id查询库存
Stock checkStock(Integer id);
//根据商品id扣除库存
void updateSale(Stock stock);
}
实体类
@Data
@AllArgsConstructor
@NoArgsConstructor
@ToString
@Accessors(chain = true)
//订单
public class Order {
private Integer id;
private Integer sid;
private String name;
private Date create_time;
}
@Data
@AllArgsConstructor
@NoArgsConstructor
@ToString
@Accessors(chain = true)
public class Stock {
private Integer id;
private String name;
//初始数量
private Integer count;
//卖的数量
private Integer sale;
//版本号 用于乐观锁
private Integer version;
}
mapper.xml文件
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd" >
<mapper namespace="com.ityang.mstest.dao.OrderDao">
<!--创建订单-->
<insert id="createOrder" parameterType="com.ityang.mstest.entity.Order" useGeneratedKeys="true" keyProperty="id">
insert into stock_order values (#{id},#{sid},#{name},#{create_time})
</insert>
</mapper>
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd" >
<mapper namespace="com.ityang.mstest.dao.StockDao">
<!--根据商品id扣除库存-->
<update id="updateSale" parameterType="com.ityang.mstest.entity.Stock">
update stock set sale = #{sale} where id = #{id}
</update>
<!--根据秒杀商品的id来查询库存-->
<select id="checkStock" parameterType="integer" resultType="com.ityang.mstest.entity.Stock">
select id,name,count,sale,version from stock
where id = #{id}
</select>
</mapper>
此时没有加任何锁,在单线程下没有问题,在多线程下就会出现超卖的问题
使用jmeter进行压力测试
此时数据库库存是100,有110个线程同时访问 ,数据库会有超卖现象,只有100个商品,却生成了更多的订单
悲观锁解决超卖现象
此时可以给controller中的方法加一个悲观锁,不能在service的实现类加
@RestController
@RequestMapping("stock")
public class StockController {
@Autowired
private OrderService orderService;
//秒杀方法
@GetMapping("kill")
public String kill(Integer id){
//根据秒杀商品的id去执行秒杀业务
try {
//这里加上悲观锁
synchronized (this){
int orderId = orderService.kill(id);
return "秒杀成功,秒杀ID = "+String.valueOf(orderId);
}
}catch (Exception e){
e.printStackTrace();
return e.getMessage();
}
}
}
此时数据变得正常,不会发生超卖现象,但是效率很低
乐观锁解决超卖现象
//改造了service实现类的代码
@Service
@Transactional
public class OrderServiceImpl implements OrderService {
@Autowired
private StockDao stockDao;
@Autowired
private OrderDao orderDao;
@Override
public int kill(Integer id) {
//根据商品的id去校验库存
Stock stock = checkStock(id);
//如果已售的数量和库存数量相等的话,就说明秒杀完毕
// 校验完毕就扣除库存 给已售+1
updateSale(stock);
//创建订单操作
Integer orderId = creatOrder(stock);
return orderId;
}
//校验库存
private Stock checkStock(Integer id){
Stock stock = stockDao.checkStock(id);
//如果已售的数量和库存数量相等的话,就说明秒杀完毕
if (stock.getCount().equals(stock.getSale())){
throw new RuntimeException("该商品已经卖完了");
}else {
return stock;
}
}
//扣除库存
private void updateSale(Stock stock){
stock.setSale(stock.getSale()+1);
stockDao.updateSale(stock);
}
//创建订单
private Integer creatOrder(Stock stock){
Order order = new Order();
order.setName(stock.getName());
order.setSid(stock.getId());
order.setCreate_time(new Date());
orderDao.createOrder(order);
return order.getId();
}
}
首先要给数据库加一个字段名 version
使用乐观锁的方式,实际就是将防止超卖的问题交给数据库来完成,利用数据库定义的version
字段以及数据库的事务实现在并发下解决商品超卖的问题
service实现类中更改扣除库存的方法,其他的不变
//扣除库存
private void updateSale(Stock stock){
int updateSale = stockDao.updateSale(stock);
if (updateSale==0){
//如果等于0 说明其他线程都没有拿到版本号
throw new RuntimeException("抢购失败,请重试");
}
}
更改xml文件中的扣除库存的sql语句,其他的不变
<!--根据商品id扣除库存-->
<update id="updateSale" parameterType="com.ityang.mstest.entity.Stock">
update stock
set sale = sale+1,
version = version+1
where id = #{id}
and version = #{version}
</update>
接口的限流
使用令牌桶算法实现乐观锁和限流
-
项目中引入依赖
<!-- https://mvnrepository.com/artifact/com.google.guava/guava --> <dependency> <groupId>com.google.guava</groupId> <artifactId>guava</artifactId> <version>29.0-jre</version> </dependency> <!--google的开源工具类RateLimter,对令牌桶的实现-->
-
controller要引入RateLimiter 然后设置发放的令牌数 参数是发放的令牌数量
//创建令牌桶的实例 参数是每秒放行多少个请求
private RateLimiter rateLimiter = RateLimiter.create(20);
-
秒杀接口的改变
//秒杀方法,使用乐观锁和令牌桶限流 @GetMapping("kill2") public String kill2(Integer id){ //加入令牌桶的限流 2秒内抢不到令牌的请求就放弃 if (rateLimiter.tryAcquire(2,TimeUnit.SECONDS)){ //就加了这一句的判断,判断请求2秒内是否拿到令牌,拿到了就处理接下来的业务,没有拿到就返回抢购失败 try { int kill2 = orderService.kill(id); return "秒杀成功,秒杀ID = "+ String.valueOf(kill2); }catch (Exception e){ e.printStackTrace(); return e.getMessage(); } }else { return "抢购失败"; } }
-
限时抢购
<!-- 整合redis --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-redis</artifactId> <version>2.3.3.RELEASE</version> </dependency>
spring.redis.host=127.0.0.1 spring.redis.port=6379 spring.redis.database=0
@Service @Transactional public class OrderServiceImpl implements OrderService { @Autowired private StockDao stockDao; @Autowired private OrderDao orderDao; @Autowired private StringRedisTemplate stringRedisTemplate; @Override public int kill(Integer id) { //整合Redis,设置限时抢购的时间 //校验redis中秒杀商品是否超时 Boolean hasKey = stringRedisTemplate.hasKey("kill" + id); if (hasKey){ //根据商品的id去校验库存 Stock stock = checkStock(id); //如果已售的数量和库存数量相等的话,就说明秒杀完毕 // 校验完毕就扣除库存 给已售+1 updateSale(stock); //创建订单操作 Integer orderId = creatOrder(stock); return orderId; }else { throw new RuntimeException("当前商品抢购活动已经结束"); } } //校验库存 private Stock checkStock(Integer id){ //在sql层面完成销量的+1和version的+1,并且根据商品的id和版本号同时查询更新的商品 Stock stock = stockDao.checkStock(id); //如果已售的数量和库存数量相等的话,就说明秒杀完毕 if (stock.getCount().equals(stock.getSale())){ throw new RuntimeException("该商品已经卖完了"); }else { return stock; } } //扣除库存 private void updateSale(Stock stock){ int updateSale = stockDao.updateSale(stock); if (updateSale==0){ //如果等于0 说明其他线程都没有拿到版本号 throw new RuntimeException("抢购失败,请重试"); } } //创建订单 private Integer creatOrder(Stock stock){ Order order = new Order(); order.setName(stock.getName()); order.setSid(stock.getId()); order.setCreate_time(new Date()); orderDao.createOrder(order); return order.getId(); } }
-
隐藏接口 使用md5加密的方式
Controller创建秒杀商品的接口
//创建需要秒杀的商品和限购的时间 @GetMapping("create") public String createkill(Integer id,Integer killtime){ Calendar calendar = Calendar.getInstance(); //获取当前时间 calendar.setTime(new Date()); //创建秒杀任务 Stock stock = orderService.createkill(id,killtime); //获取当前时间 + 设置的时间 之后的时间 设置的时间的单位是 秒 calendar.add(Calendar.SECOND,killtime); String s = "创建" + stock.getName() + "商品的秒杀成功," + "有效时间为:" + new SimpleDateFormat("yyyy-MM-dd HH-mm-ss").format(new Date()) + " 到 " + new SimpleDateFormat("yyyy-MM-dd HH-mm-ss").format(calendar.getTime()); return s; }
Service中的方法
@Override public Stock createkill(Integer id, Integer killtime) { Stock stock = stockDao.checkStock(id); stringRedisTemplate.opsForValue().set("kill_"+id,stock.getName(),60,TimeUnit.SECONDS); stock.setCreatekill(killtime); //这里我在stock的实体类中增加了一个属性 //商品的秒杀时间 private Integer createkill; return stock; }
Controller获取md5加密生成的字符串
//进行md5加密 @GetMapping("md5") public String getMd5(Integer id,Integer userid){ String md5; try { md5=orderService.getMd5(id,userid); }catch (Exception e){ e.printStackTrace(); return "获取md5失败:"+e.getMessage(); } return md5; }
Service的生成md5的方法
@Override public String getMd5(Integer id, Integer userId) { //验证传过来的userid是否存在 User user = userDao.findById(userId); if (user==null){ throw new RuntimeException("用户名不存在"); } //验证传过来的商品的id,判断该商品是否存在 Stock stock = stockDao.checkStock(id); if (stock==null){ throw new RuntimeException("商品不存在"); } //两者都存在,生成md5签名,放入redis服务 String hashkey = "KEY_" +userId +"_" + id; //!Q*jS#是一个盐,随机生成的 String key = DigestUtils.md5DigestAsHex((userId+id+"!Q*jS#").getBytes()); //将hashkey作为key 生成的md5加密的 key作为value存入redis中 设置的有效时间是 60s stringRedisTemplate.opsForValue().set(hashkey, key, 60, TimeUnit.SECONDS); log.info("redis写入了:[{}] [{}]",hashkey,key); return "加密的数据是:"+key; }
改造Controller接口 需要前台传来商品id,用户id和获取的md5字符串
//秒杀方法,使用乐观锁和令牌桶限流 加上了md5加密 @GetMapping("kill3") public String kill3(Integer id,Integer userid,String md5){ //加入令牌桶的限流 2秒内抢不到令牌的请求就放弃 if (rateLimiter.tryAcquire(2,TimeUnit.SECONDS)){ try { int kill3 = orderService.killmd5(id,userid,md5); return "秒杀成功,秒杀ID = "+ String.valueOf(kill3); }catch (Exception e){ e.printStackTrace(); return e.getMessage(); } }else { return "抢购失败"; } }
Service中的方法
@Autowired private StringRedisTemplate stringRedisTemplate; //需要整合redis @Override public int killmd5(Integer id, Integer userid, String md5) { //验证是否秒杀商品是否超时 返回的是Boolean值 Boolean hasKey = stringRedisTemplate.hasKey("kill_" + id); //没有超时 if (hasKey){ //验证签名 String hashkey="KEY_" +userid +"_" + id; String s = stringRedisTemplate.opsForValue().get(hashkey); if (s==null){ throw new RuntimeException("没有携带验证体,当前请求不合法"); } if (s.equals(md5)){ //校验库存 Stock stock = checkStock(id); //更新库存 updateSale(stock); //生成订单 creatOrder(stock); return id; }else { throw new RuntimeException("非法访问"); } }else { throw new RuntimeException("商品秒杀活动已经结束了"); } } //校验库存 private Stock checkStock(Integer id){ //在sql层面完成销量的+1和version的+1,并且根据商品的id和版本号同时查询更新的商品 Stock stock = stockDao.checkStock(id); //如果已售的数量和库存数量相等的话,就说明秒杀完毕 if (stock.getCount().equals(stock.getSale())){ throw new RuntimeException("该商品已经卖完了"); }else { return stock; } } //扣除库存 private void updateSale(Stock stock){ int updateSale = stockDao.updateSale(stock); if (updateSale==0){ //如果等于0 说明其他线程都没有拿到版本号 throw new RuntimeException("抢购失败,请重试"); } } //创建订单 private Integer creatOrder(Stock stock){ Order order = new Order(); order.setName(stock.getName()); order.setSid(stock.getId()); order.setCreate_time(new Date()); orderDao.createOrder(order); return order.getId(); }
-
单用户限制频率
其他的都没变,只是在要进行业务处理之前增加了一个验证。
改造Controller中的接口
//秒杀方法,使用乐观锁和令牌桶限流 加上了md5加密 再加上单用户限流 @GetMapping("kill4") public String kill4(Integer id,Integer userid,String md5){ //加入令牌桶的限流 2秒内抢不到令牌的请求就放弃 if (rateLimiter.tryAcquire(2,TimeUnit.SECONDS)){ try { //查询用户访问该接口的次数 int userCount = userService.saveUserCount(userid); log.info(userid+"--用户访问的次数是:[{}]",userCount); //进行调用次数的判断 boolean isBanned = userService.getUserCount(userid); if (isBanned){ log.info("请求失败,超过了频率限制"); return "请求失败,超过了频率限制"; } int kill3 = orderService.killmd5(id,userid,md5); return "秒杀成功,秒杀ID = "+ String.valueOf(kill3); }catch (Exception e){ e.printStackTrace(); return e.getMessage(); } }else { return "抢购失败"; } }
创建一个UserService接口
public interface UserService { //向redis中写入用户访问的次数 int saveUserCount(Integer userId); //判断单位时间调用的次数 boolean getUserCount(Integer userId); }
UserService实现类
@Service @Slf4j public class UserServiceImpl implements UserService { @Autowired private StringRedisTemplate stringRedisTemplate; @Override public int saveUserCount(Integer userId) { String userCountKey = "USER_"+userId; int limit = 0 ; //首先查询redis中有没有这个用户的记录 String s = stringRedisTemplate.opsForValue().get(userCountKey); if (s == null){ //查询结果是null 说明没有,就创建一个新的key 并且设置访问次数初始是 0 有效时间是 1 小时 stringRedisTemplate.opsForValue().set(userCountKey,"0",3600,TimeUnit.SECONDS); //返回调用次数 0 return limit; }else { //如果查询到了 就获取这个用户的访问次数,并且给这个用户的访问次数 +1 String userCount = stringRedisTemplate.opsForValue().get(userCountKey); limit = Integer.parseInt(userCount)+1; stringRedisTemplate.opsForValue().set(userCountKey,String.valueOf(limit),3600,TimeUnit.SECONDS); //返回调用次数 return limit; } } @Override public boolean getUserCount(Integer userId) { String userCountKey = "USER_"+userId; String userCount = stringRedisTemplate.opsForValue().get(userCountKey); if (userCount==null){ //如果为空,说明该key出现异常 log.error("该用户没有申请验证记录值,访问异常"); return true; } return Integer.parseInt(userCount)>10; //false说明没有超过 true说明超过了 } }