目录
1.业务流程
一个秒杀商品的简单实现,主要包括添加普通商品至秒杀商品表——>进行秒杀——>对用户请求进行限流——>对用户购买行为进行限制——>判断库存——>创建订单等一系列流程。
一般一个秒杀系统的设计思路如下:
- 首先,在网关处对用户的请求进行限流,使用令牌桶算法等相关限流算法,根据秒杀商品的数量对请求数进行控制;同时,对单一用户的没秒请求数进行控制,避免用户的频繁请求导致服务器超载;
- 然后启用redis等工具对商品进行预减库存,判断库存是否足够,避免数据库的频繁IO操作;
- 接着,使用消息队列等方式异步进行订单创建、支付订单等异步操作,简短每一个用户的订单处理时间;
- 最后,为避免超买、超卖等现象,对数据表创建联合唯一索引、对代码段加锁以及分布式锁的方式,应当大量的并发请求。
根据以上思路,本次demo为建议实现,代码逻辑如下:
由于只是简单demo,没有实现网关限流和订单支付等操作,只是从用户下单到创建订单,减库存的一次简单实现。
2.技术选用
本次demo主要基于springboot进行实现,数据库选用mysql,数据库框架使用mybatis plus。
限流基于redis下使用计数器算法实现;预减库存使用redis进行实现;分布式锁则是基于redission下进行实现。
消息队列则是基于rabbitmq下的直连交换机和队列以路由模式进行实现。
3.数据库设计
本次数据库主要包括三个表:
(1)商品表:
(2)秒杀商品表:
(由于只是一个demo,所以开始时间和结束时间等字段没有进行具体设置)
(3)订单表:
一些相关数据到表中,以便后续模拟业务展开:
4.具体代码
下面是一些关键代码的实现,由于基于mybatis plus下进行实现,此处不放出mapper层、service层、Entities层相关代码,可使用mybatisX Generator插件在IDE连接数据库自动生成。
所需依赖:
<dependencies>
<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>
<!--mysql-druid-->
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<version>5.1.37</version>
</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>
</dependency>
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-lang3</artifactId>
<version>3.6</version>
</dependency>
<!-- mybatis-plus启动器-->
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
<version>3.5.1</version>
</dependency>
<!--rabbitmq相关依赖-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-amqp</artifactId>
</dependency>
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>fastjson</artifactId>
<version>1.2.47</version>
</dependency>
<!-- redis 依赖 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<!--redission相关依赖-->
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson</artifactId>
<version>3.16.0</version>
</dependency>
</dependencies>
application.yml:
server:
port: 8080
spring:
thymeleaf:
cache: false
datasource:
driver-class-name: com.mysql.jdbc.Driver
url: jdbc:mysql://localhost:3306/seckill?useUnicode=true&characterEncoding=utf-8&allowMultiQueries=true
username: root
password: root
rabbitmq:
host: localhost
username: guest
password: guest
port: 5672
publisher-returns: true #回退消息,当找不到routing key对应的队列时,是否回退信息
listener:
simple:
concurrency: 10 #消费者最少数量
max-concurrency: 10 #消费者最大数量
prefetch: 1 #消费者每次处理一条消息
auto-startup: true
default-requeue-rejected: false #消息被拒绝是否重新进入队列
redis:
database: 0
host: 127.0.0.1
#redis默认端口
port: 6379
password:
jedis:
pool:
max-active: 8
# 连接池最大阻塞等待时间(使用负值表示没有限制)
max-wait: -1ms
# 连接池中的最大空闲连接
max-idle: 8
# 连接池中的最小空闲连接
min-idle: 0
# 连接超时时间(毫秒)
timeout: 5000ms
mybatis-plus:
mapper-locations: classpath*:/mapper/*Mapper.xml
# Mybatis SQL打印
logging:
level:
com.seven.seckill.mapper: debug
entities层VO类:
@NoArgsConstructor
@AllArgsConstructor
@Data
public class GoodsOrderVo {
private Long userId;
private Long goodsId;
private String goodsName;
private BigDecimal goodsPrice;
}
(各大PO对象皆和mybatis plus生成模板一致,请参考数据库设计)
serviceImpl代码:
@Service
@Slf4j
public class SeckillGoodsServiceImpl extends ServiceImpl<SeckillGoodsMapper, SeckillGoods>
implements SeckillGoodsService, InitializingBean{
@Resource
private RedisTemplate<String,Object> redisTemplate;
@Resource
private SeckillGoodsMapper seckillGoodsMapper;
//初始化Bean时,将秒杀商品存入redis缓存
@Override
public void afterPropertiesSet() throws Exception {
if (seckillGoodsMapper.selectList(null)!=null){
List<SeckillGoods> list = seckillGoodsMapper.selectList(null);
for (SeckillGoods goods:list){
redisTemplate.opsForValue().set("seckillGoods:"+goods.getGoodsId(),goods.getStockCount());
}
}
}
}
(其他代码皆和mybatis plus service层代码一致)
controller代码,包含添加秒杀商品和进行秒杀下单两个接口:
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.seven.seckill.entities.pojo.Goods;
import com.seven.seckill.entities.pojo.Order;
import com.seven.seckill.entities.pojo.SeckillGoods;
import com.seven.seckill.entities.vo.GoodsOrderVo;
import com.seven.seckill.entities.vo.ResponseEnum;
import com.seven.seckill.entities.vo.ResponseResult;
import com.seven.seckill.exception.GlobalException;
import com.seven.seckill.service.GoodsService;
import com.seven.seckill.service.SeckillGoodsService;
import lombok.extern.slf4j.Slf4j;
import org.redisson.api.RLock;
import org.redisson.api.RedissonClient;
import org.springframework.amqp.rabbit.connection.CorrelationData;
import org.springframework.amqp.rabbit.core.RabbitTemplate;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;
import javax.annotation.Resource;
import javax.servlet.http.HttpServletRequest;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
import static com.seven.seckill.config.RabbitMqConfig.EXCHANGE_ORDER;
@RestController
@Slf4j
public class SeckillController{
@Resource
private GoodsService goodsService;
@Resource
private SeckillGoodsService seckillGoodsService;
@Resource
private RabbitTemplate rabbitTemplate;
@Resource
private RedisTemplate<String,Object> redisTemplate;
@Resource
private RedissonClient redisson;
//秒杀下单
@PostMapping("/order")
public ResponseResult<?> secondKillOrder(@RequestBody GoodsOrderVo goodsOrderVo, HttpServletRequest request){
// log.info("查看数据库库存");
// SeckillGoods seckillGoods = seckillGoodsService.getById(goodsOrderVo.getGoodsId());
// if (seckillGoods.getStockCount()<1){
// //throw new GlobalException(ResponseEnum.NULL_ERROR);
// return new ResponseResult<>(ResponseEnum.NULL_ERROR);
// }
// //查看用户是否已有订单,进行限量购买
// Order order = orderService.
// getOne(new QueryWrapper<Order>().eq("user_id", goodsOrderVo.getUserId())
// .eq("goods_id", goodsOrderVo.getGoodsId()));
// if (order!=null){
// //throw new GlobalException(ResponseEnum.REPEAT_ERROR);
// return new ResponseResult<>(ResponseEnum.REPEAT_ERROR);
// }
// 为避免频繁的数据库访问,已弃用
//限流:计数器算法,基于redis实现
//下方代码对单个用户进行限流,单个用户每s仅可访问5次
String url = String.valueOf(request.getRequestURL());
Integer flag = (Integer) redisTemplate.opsForValue().get(url+":"+goodsOrderVo.getUserId());
if (flag==null){
//设置计数器,5秒过期
redisTemplate.opsForValue().set(url+":"+goodsOrderVo.getUserId(),1, 1, TimeUnit.SECONDS);
}else if (flag < 10){
//请求数+1
redisTemplate.opsForValue().increment(url+":"+goodsOrderVo.getUserId());
}else {
//限流
log.warn("请求过于频繁");
throw new GlobalException(ResponseEnum.SYSTEM_ERROR);
}
//从redis取缓存,限制用户购买量
Order order = (Order) redisTemplate.opsForValue().
get("SeckillOrder:"+goodsOrderVo.getGoodsId()+"-"+goodsOrderVo.getUserId());
if (order!=null){
throw new GlobalException(ResponseEnum.REPEAT_ERROR);
}
//获取redission分布式锁
String lockKey = UUID.randomUUID().toString();
RLock lock = redisson.getLock(lockKey);
try {
//开启锁
lock.lock();
synchronized(this){
//redis预减库存
if(redisTemplate.opsForValue().get(("seckillGoods:"+ goodsOrderVo.getGoodsId()))==null){
return new ResponseResult<>(ResponseEnum.NULL_ERROR);
}else {
int count = (int)redisTemplate.opsForValue().get(("seckillGoods:"+ goodsOrderVo.getGoodsId()));
if (count<1){
log.info("库存不足");
throw new GlobalException(ResponseEnum.NULL_ERROR);
}
//使用decrement原子操作进行递减库存
redisTemplate.opsForValue().decrement("seckillGoods:"+ goodsOrderVo.getGoodsId());
//count = count-1
//redisTemplate.opsForValue().set("seckillGoods:"+ goodsOrderVo.getGoodsId(),count);
}
//rabbitmq异步处理:创建订单
String messageId = "goods:"+goodsOrderVo.getGoodsId()+"-user:"+goodsOrderVo.getUserId();
rabbitTemplate.convertAndSend(EXCHANGE_ORDER,"order_route", goodsOrderVo, new CorrelationData(messageId));
//注意:此处数据库减库存不可用rabbitmq异步处理,会由于多条请求同时对同一行数据操作使得库存不正确
log.info("扣减数据库库存");
SeckillGoods seckillGoods = seckillGoodsService.
getOne(new QueryWrapper<SeckillGoods>().eq("goods_id", goodsOrderVo.getGoodsId()));
if (seckillGoods.getStockCount()>0) {
seckillGoods.setStockCount(seckillGoods.getStockCount() - 1);
seckillGoodsService.update(seckillGoods,
new QueryWrapper<SeckillGoods>().eq("goods_id", goodsOrderVo.getGoodsId())
//当库存大于0才可更新
.gt("stock_count", 0));
}
}
}catch (Exception e){
log.warn("系统错误,稍后重试");
}
finally {
//关闭锁
lock.unlock();
}
return ResponseResult.success();
}
//添加秒杀商品
@PostMapping("/add")
public ResponseResult<?> addGoods(@RequestBody SeckillGoods seckillGoods){
if (seckillGoods.getStockCount() > goodsService.getOne(new QueryWrapper<Goods>()
.eq("id",seckillGoods.getGoodsId())).getGoodsStock()){
return new ResponseResult<>(ResponseEnum.NULL_ERROR.getCode(),"库存不足");
}
boolean result = seckillGoodsService.save(seckillGoods);
if (result){
//添加库存至redis
redisTemplate.opsForValue().set("seckillGoods:"+seckillGoods.getGoodsId(),seckillGoods.getStockCount());
return ResponseResult.success();
}
return ResponseResult.fail();
}
}
秒杀业务接口执行逻辑如下:
- 首先基于redis进行计数器限流,限制同一用户的每秒请求数。
- 通过请求限制后,通过redis缓存判断该用户是否购买过该秒杀商品,若购买过,则拒绝请求。
- 接着获取分布式锁,进入秒杀商品逻辑:根据redis中缓存好的库存值判断库存是否足够,足够则预减缓存库存足够,通过rabbitMq发送消息,异步创建订单;同时对数据表进行处理,减少库存;不足则拒绝下单。
- 上述操作完成后关闭分布式锁,返回下单结果。
rabbitMq相关代码如下:
(1)声明队列及交换机,并进行绑定,使用路由直连模式:
import org.springframework.amqp.core.*;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class RabbitMqConfig {
public static final String QUEUE_ORDER = "order_queue";
public static final String EXCHANGE_ORDER = "order_exchange";
@Bean("queue_order")
public Queue orderQueue(){
return QueueBuilder.durable(QUEUE_ORDER).build();
}
@Bean("exchange_order")
public DirectExchange orderExchange() {
return new DirectExchange(EXCHANGE_ORDER);
}
//声明队列绑定交换机
//直接交换机,路由匹配模式
@Bean
public Binding orderQueueBinding(@Qualifier("queue_order") Queue queue_order,
@Qualifier("exchange_order") DirectExchange exchange_order) {
return BindingBuilder.bind(queue_order).to(exchange_order).with("order_route");
}
}
(2)配置消费者,进行异步创建订单:
import com.alibaba.fastjson.JSON;
import com.seven.seckill.entities.pojo.Order;
import com.seven.seckill.entities.vo.GoodsOrderVo;
import com.seven.seckill.service.OrderService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.amqp.core.Message;
import org.springframework.amqp.rabbit.annotation.RabbitListener;
import org.springframework.beans.BeanUtils;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Component;
import javax.annotation.Resource;
import static com.seven.seckill.config.RabbitMqConfig.QUEUE_ORDER;
@Slf4j
@Component
public class OrderListener {
@Resource
private OrderService orderService;
@Resource
private RedisTemplate<String,Object> redisTemplate;
@RabbitListener(queues = QUEUE_ORDER)
public void receiveOrderMsg(Message message){
log.info("订单队列接收到消息:"+new String(message.getBody()));
GoodsOrderVo goodsOrderVo = JSON.parseObject(new String(message.getBody()), GoodsOrderVo.class);
//创建商品订单
Order order = new Order();
BeanUtils.copyProperties(goodsOrderVo,order);
order.setGoodsCount(1);
order.setTotalPrice(goodsOrderVo.getGoodsPrice());
boolean result = orderService.save(order);
if (result){
//将订单缓存
redisTemplate.opsForValue().
set("SeckillOrder:"+goodsOrderVo.getGoodsId()+"-"+goodsOrderVo.getUserId(),order);
}
}
}
代码编写完成,进行测试,使用Jmeter并发发送100个请求同时秒杀商品3:Apple watch(秒杀数量20件,参考上文数据库设计),其中20个请求使用相同的用户id。
查看数据库,我们可以看到秒杀库存没有负数:
订单数也只有20单:
没有出现超买超卖问题。
5.相关说明
上述即为基于springboot下的秒杀商品接口的简易demo实现.
本次项目使用了分布式锁和本地锁来保证商品的库存正常扣减及订单的有序创建,应对高并发问题。但这是建立在秒杀商品数量不过多的情况下启用的。若是秒杀商品达到上万件,启用redission的分布式锁会十分影响性能,还需再进行进一步优化。