概要
商城项目中的秒杀功能是指在特定时间段内,以较低的价格或特殊的优惠条件销售一定数量的商品。这个功能通常被用于吸引用户、提升销量和增加用户粘性。
整体架构流程
主要使用spring boot+spring cloud+redis+RabbitMQ。
商品中的秒杀功能一般包括以下几个方面
1.秒杀商品设置,商城项目一般可以在特定时间选择一些商品作为秒杀商品,并设置秒杀的特定时间、结束时间和秒杀数量等参数
2.秒杀活动页面。商城项目会在秒杀活动开始前提前编写秒杀活动页面,包括秒杀活动列表,秒杀商品详情,包括商品的图片、名称、原价、秒杀价等,并显示秒杀的倒计时。
3.秒杀订单处理,用户在秒杀期间,商城项目需要判断用户购买资格,判断库存是否充足,生成订单、减少库存等
4.秒杀限制,商城项目一般会对秒杀功能进行一些限制,例如每个用户只能参与一次秒杀活动等。
5.秒杀结果显示,秒杀成功后,会显示秒杀的结果。
具体实现
1.秒杀活动当天导入秒杀商品
//利用定时任务导入秒杀商品 每天凌晨1点导入商品,这时用户量比较小
@Scheduled(cron = "0 0 1 * * ?")
public void task1(){
System.out.println("触发的时间为"+new Date());
//给秒杀模块发送消息导入商品
rabbitService.sendMessage(MqConst.EXCHANGE_DIRECT_TASK,MqConst.ROUTING_TASK_1,"");
}
//根据定时任务导入商品
@RabbitListener(bindings = @QueueBinding(
exchange = @Exchange(value = MqConst.EXCHANGE_DIRECT_TASK),
value = @Queue(value = MqConst.QUEUE_TASK_1),
key = {MqConst.ROUTING_TASK_1}
))
@SneakyThrows
public void importTpRedis(Message message, Channel channel){
QueryWrapper<SeckillGoods> queryWrapper=new QueryWrapper<>();
//查询符合条件的秒杀商品集合 是当天时间的、库存大于0的、状态为等于1的
queryWrapper.eq("DATE_FORMAT(start_time,'%Y-%m-%d')", DateUtil.formatDate(new Date()))
.gt("stock_count",0).eq("status",1);
List<SeckillGoods> seckillGoodsList = seckillGoodsService.list(queryWrapper);
//遍历符合条件的商品
for (SeckillGoods seckillGoods : seckillGoodsList) {
//如果缓存中已经有了这个商品就直接跳过此次循环
if (redisTemplate.boundHashOps(RedisConst.SECKILL_GOODS).hasKey(seckillGoods.getSkuId().toString())){
continue;
}
//如果没有,将其放入缓存
redisTemplate.boundHashOps(RedisConst.SECKILL_GOODS).put(seckillGoods.getSkuId().toString(),seckillGoods);
//将库存放入集合队列中实现原子性防止超卖 根据库存数量
for (Integer i = 0; i < seckillGoods.getStockCount(); i++) {
redisTemplate.boundListOps(RedisConst.SECKILL_STOCK_PREFIX+seckillGoods.getSkuId().toString()).leftPush(seckillGoods.getSkuId().toString());
}
//更新状态位通过Redis发布订阅
redisTemplate.convertAndSend("seckillpush",seckillGoods.getSkuId()+":1");
}
channel.basicAck(message.getMessageProperties().getDeliveryTag(),false);
}
需要注意的是redis的发布订阅模式:
用这个方式是由于,由于在内存中取数据要比在redis中取数据更快,所以可以把商品的状态位放入内存中,但是由于我们的项目是分布式的、每一个微服务可能都会有多个服务器,这时由于每个服务器都有不同的内存,就会导致状态位无法共享,因此我们可以采用Redis的发布订阅模式,对同一个应用的多个节点进行共享。
@Configuration
public class RedisChannelConfig {
/**
* 配置监听器
*/
@Bean
public RedisMessageListenerContainer redisMessageListenerContainer(MessageListenerAdapter messageListenerAdapter, RedisConnectionFactory redisConnectionFactory){
RedisMessageListenerContainer messageListenerContainer=new RedisMessageListenerContainer();
//订阅主题
messageListenerContainer.addMessageListener(messageListenerAdapter,new PatternTopic("seckillpush"));
messageListenerContainer.setConnectionFactory(redisConnectionFactory);
//这个container 可以添加多个 messageListener
return messageListenerContainer;
}
/**
* 配置适配器
*/
@Bean
public MessageListenerAdapter messageListenerAdapter(MessageReceive messageReceive){
//这个地方 是给 messageListenerAdapter 传入一个消息接受的处理器,利用反射的方法调用“receiveMessage”
//也有好几个重载方法,这边默认调用处理器的方法 叫handleMessage 可以自己到源码里面看
return new MessageListenerAdapter(messageReceive,"receiveMessage");
}
/**
* 配置redisTemplate
*/
@Bean
StringRedisTemplate template(RedisConnectionFactory redisConnectionFactory){
//因为StringRedisTemplate里面有redisConnectionFactory
return new StringRedisTemplate(redisConnectionFactory);
}
}
@Component
public class MessageReceive {
/**
* 接收消息的方法
*/
public void receiveMessage(String message){
message= message.replaceAll("\"","");
String[] split = message.split(":");
if (split!=null && split.length==2){
CacheHelper.put(split[0],split[1]);
}
}
}
//状态位信息都放入这里面
public class CacheHelper {
//ConcurrentHashMap是线程安全的
public static final Map<String,Object> cacheMap=new ConcurrentHashMap<>();
/**
* 放入缓存
* @param key
* @param value
*/
public static void put(String key,Object value){
cacheMap.put(key,value);
}
/**
* 得到缓存
* @param key
* @return
*/
public static Object get(String key){
return cacheMap.get(key);
}
/**
* 清除缓存
*/
public static void removeAll(){
cacheMap.clear();
}
public static void remove(String key){
cacheMap.remove(key);
}
}
2.完成秒杀商品列表和详情
//秒杀商品列表
@GetMapping("/findAll")
public List<SeckillGoods> seckillGoodsList() {
return redisTemplate.boundHashOps(RedisConst.SECKILL_GOODS).values();
}
//详情
@GetMapping("/getSeckillGoods/{skuId}")
public SeckillGoods getSeckillGoods(@PathVariable Long skuId) {
return (SeckillGoods) redisTemplate.boundHashOps(RedisConst.SECKILL_GOODS).get(skuId.toString());
}
//themeleaf完成请求转发功能
@GetMapping("/seckill.html")
public String seckill(Model model){
//通过openfeign接口调用
List<SeckillGoods> seckillGoodsList = activityFeignClient.seckillGoodsList();
model.addAttribute("list",seckillGoodsList);
return "seckill/index";
}
@GetMapping("/seckill/{skuId}.html")
public String detailSeckill(@PathVariable Long skuId,Model model){
//通过openfeign接口调用
SeckillGoods seckillGoods = activityFeignClient.getSeckillGoods(skuId);
List<BaseAttrInfo> skuAttrList = productFeignClient.getAttrList(skuId);
model.addAttribute("skuAttrList",skuAttrList);
model.addAttribute("item",seckillGoods);
return "seckill/item";
}
秒杀列表展示:
点击商品进入商品详情
3.获取抢单码、秒杀抢单和检查抢单状态
@GetMapping("/auth/getSeckillSkuIdStr/{skuId}")
public Result getSeckillSkuIdStr(@PathVariable Long skuId, HttpServletRequest request) {
//获取下单的用户id
String userId = AuthContextHolder.getUserId(request);
//获取商品详情
SeckillGoods seckillGoods = this.getSeckillGoods(skuId);
//判断商品时间是否允许
Date curDate = new Date();
if (DateUtil.dateCompare(seckillGoods.getStartTime(), curDate) && DateUtil.dateCompare(curDate, seckillGoods.getEndTime())) {
//如果允许,就返回
String seckillStr = MD5.encrypt(userId);
return Result.ok(seckillStr);
}
return Result.fail().message("商品抢购时间已过期或还未开始");
获取抢单码是为了防止用户恶意访问页面,在后续下单的时候会进行判断抢单码是否相等,防止用户通过输入网址进入下单页面
获取抢单码之后,跳转到新的页面,将抢单码作为参数传出去,接着就可以进入秒杀抢单接口,在这里会进行
判断抢单码是否正确,判断状态位是否允许抢单,如果都可以的话,就可以发送消息到MQ队列,排着队等待后续处理
@PostMapping("/auth/seckillOrder/{skuId}")
public Result seckillOrder(@PathVariable Long skuId, HttpServletRequest request) {
//获取传过来的下单码
String skuIdStr = request.getParameter("skuIdStr");
//获取用户id
String userId = AuthContextHolder.getUserId(request);
//验证下单码是否正确
if (!MD5.encrypt(userId).equals(skuIdStr)) {
return Result.build(null, ResultCodeEnum.SECKILL_ILLEGAL);
}
//验证状态位是否正确
String status = (String) CacheHelper.get(skuId.toString());
//如果为空,表示不合法
if (StringUtils.isEmpty(status)) {
return Result.build(null, ResultCodeEnum.SECKILL_ILLEGAL);
}
//如果为0说明已售罄
if (status.equals("0")) {
return Result.build(null, ResultCodeEnum.SECKILL_FINISH);
}
//如果为1说明还有商品
if (status.equals("1")) {
UserRecode userRecode = new UserRecode();
userRecode.setUserId(userId);
userRecode.setSkuId(skuId);
rabbitService.sendMessage(MqConst.EXCHANGE_DIRECT_SECKILL_USER, MqConst.ROUTING_SECKILL_USER, userRecode);
}
return Result.ok();
}
在消费者收到秒杀消息后,还需要再次判断状态位,防止商品已经售罄,如果售罄,不再往下执行,之后需要判断用户是否重复下单,可以利用Redis中的setnx进行判断,如果用户已经下过单,那么就表示setnx已经设置过,由于setnx只能设置一次,再次设置就会返回false,这样就能控制用户重复下单,如果用户重复下单,就不再往下执行,之后再次判断商品是否售罄,如果售罄,更新状态位不再往下执行,如果上述都没有问题,那么就可以生成临时订单,更新库存。
@SneakyThrows
@RabbitListener(bindings = @QueueBinding(
value = @Queue(value = MqConst.QUEUE_SECKILL_USER),
exchange = @Exchange(value = MqConst.EXCHANGE_DIRECT_SECKILL_USER),
key = {MqConst.ROUTING_SECKILL_USER}
))
public void seckill(Message message, Channel channel, UserRecode userRecode){
try {
if (userRecode!=null){
seckillGoodsService.seckillOrder(userRecode);
}
} catch (Exception e) {
//出现问题记录日志
e.printStackTrace();
}
channel.basicAck(message.getMessageProperties().getDeliveryTag(),false);
}
//service方法
@Override
public void seckillOrder(UserRecode userRecode) {
String userId = userRecode.getUserId();
Long skuId = userRecode.getSkuId();
//判断状态位
if (CacheHelper.get(skuId.toString()).equals("0")){
return;
}
//判断用户是否重复下单
Boolean ifAbsent = redisTemplate.opsForValue().setIfAbsent(RedisConst.SECKILL_USER + userId, skuId.toString(),RedisConst.SECKILL__TIMEOUT, TimeUnit.SECONDS);
if (!ifAbsent){
return;
}
//取出商品
String goodsId = (String) redisTemplate.boundListOps(RedisConst.SECKILL_STOCK_PREFIX+skuId).rightPop();
if (StringUtils.isEmpty(goodsId)){
//如果为空说明商品售罄,更新状态位
redisTemplate.convertAndSend("seckillpush",skuId+":0");
return;
}
//生成临时订单,存入redis
OrderRecode orderRecode=new OrderRecode();
orderRecode.setOrderStr(MD5.encrypt(userId+skuId));
orderRecode.setUserId(userId);
orderRecode.setNum(1);
orderRecode.setSeckillGoods(this.getSeckllGoods(skuId));
redisTemplate.boundHashOps(RedisConst.SECKILL_ORDERS).put(orderRecode.getUserId(),orderRecode);
//更新库存
this.updateStock(skuId);
}
private void updateStock(Long skuId) {
ReentrantLock reentrantLock=new ReentrantLock();
reentrantLock.lock();
try {
//获取库存剩余数
Long size = redisTemplate.boundListOps(RedisConst.SECKILL_STOCK_PREFIX+skuId).size();
//更新数据库
SeckillGoods seckillGoods = this.getSeckllGoods(skuId);
seckillGoods.setStockCount(size.intValue());
seckillGoodsMapper.updateById(seckillGoods);
//更新缓存
redisTemplate.boundHashOps(RedisConst.SECKILL_GOODS).put(seckillGoods.getSkuId().toString(),seckillGoods);
} finally {
reentrantLock.unlock();
}
}
private SeckillGoods getSeckllGoods(Long skuId) {
QueryWrapper<SeckillGoods> queryWrapper=new QueryWrapper<>();
queryWrapper.eq("sku_id",skuId);
return this.getOne(queryWrapper);
}
在这个页面秒杀下单之后会有一个方法定时请求后端接口检查下单状态,检查是否下单成功,是否抢单成功,是否商品已经售罄,是否正在排队,是否重复下单
@GetMapping("/auth/checkOrder/{skuId}")
public Result checkOrder(@PathVariable Long skuId, HttpServletRequest request) {
String userId = AuthContextHolder.getUserId(request);
//根据缓存判断用户是否存在得知是否在排队
Boolean isExist = redisTemplate.hasKey(RedisConst.SECKILL_USER + userId);
if (isExist) {
//判断是否下单成功
if (redisTemplate.boundHashOps(RedisConst.SECKILL_ORDERS_USERS).hasKey(userId.toString())){
//表示已经下单成功
return Result.build(null,ResultCodeEnum.SECKILL_ORDER_SUCCESS);
}
//判断是否抢单成功
if (redisTemplate.boundHashOps(RedisConst.SECKILL_ORDERS).hasKey(userId.toString())) {
//表示已经抢单成功
return Result.build(null, ResultCodeEnum.SECKILL_SUCCESS);
}
//判断商品是否售罄
String status = (String) CacheHelper.get(skuId.toString());
if (!StringUtils.isEmpty(status)){
if (status.equals("0")){
return Result.build(null,ResultCodeEnum.SECKILL_FINISH);
}
}else{
//用户可能重复下单或者商品状态出现问题
return Result.build(null,ResultCodeEnum.ILLEGAL_REQUEST);
}
}
//如果不存在说明正在排队
return Result.build(null, ResultCodeEnum.SECKILL_RUN);
}
抢单成功页面:
4.抢单成功后生成订单
首先进入交易页面,通过theme leaf页面转发,获取用户地址数据,订单详情数据,总价格,总数量
@GetMapping("/seckill/trade.html")
public String trade(Model model){
//通过openfeign远程调用
Result<Map> trade = activityFeignClient.trade();
if (trade.isOk()){
model.addAllAttributes(trade.getData());
return "/seckill/trade";
}else{
model.addAttribute("message",trade.getMessage());
return "seckill/fail";
}
}
@GetMapping("/auth/trade")
public Result trade(HttpServletRequest request){
Map<String,Object> map=new HashMap<>();
String userId = AuthContextHolder.getUserId(request);
//获取用户地址 通过openfeign远程调用
List<UserAddress> userAddressList = userFeignClient.findUserAddressListByUserId(Long.parseLong(userId));
//获取商品详情
OrderRecode orderRecode= (OrderRecode) redisTemplate.boundHashOps(RedisConst.SECKILL_ORDERS).get(userId.toString());
SeckillGoods seckillGoods = orderRecode.getSeckillGoods();
OrderDetail orderDetail=new OrderDetail();
orderDetail.setImgUrl(seckillGoods.getSkuDefaultImg());
orderDetail.setSkuName(seckillGoods.getSkuName());
orderDetail.setSkuNum(orderRecode.getNum());
orderDetail.setOrderPrice(seckillGoods.getPrice());
orderDetail.setSkuId(seckillGoods.getSkuId());
List<OrderDetail> detailArrayList=new ArrayList<>();
detailArrayList.add(orderDetail);
//获取总价格和总数
OrderInfo orderInfo=new OrderInfo();
orderInfo.setOrderDetailList(detailArrayList);
orderInfo.sumTotalAmount();
BigDecimal totalAmount = orderInfo.getTotalAmount();
Integer totalNum = orderRecode.getNum();
map.put("totalAmount",totalAmount);
map.put("totalNum",totalNum);
map.put("detailArrayList",detailArrayList);
map.put("userAddressList",userAddressList);
return Result.ok(map);
}
交易页面:
点击提交订单开始生成订单
@PostMapping("/auth/submitOrder")
public Result submitOrder(@RequestBody OrderInfo orderInfo,HttpServletRequest request){
String userId = AuthContextHolder.getUserId(request);
orderInfo.setUserId(Long.parseLong(userId));
//利用open feign调用订单模块生成订单
Long orderId = orderFeignClient.submitOrder(orderInfo);
if(orderId==null){
return Result.fail().message("下单失败");
}
//删除抢单成功之后生成的临时订单
redisTemplate.boundHashOps(RedisConst.SECKILL_ORDERS).delete(userId.toString());
//保存下单成功的信息
redisTemplate.boundHashOps(RedisConst.SECKILL_ORDERS_USERS).put(userId.toString(),orderId.toString());
return Result.ok(orderId);
}
下单成功页面
小结
商城项目中的秒杀功能需要考虑并发访问量大、库存管理、订单处理、交易安全等方面的问题,因此在实现上需要注意性能优化、并发控制、数据一致性等方面的考虑。同时,商城项目还需要注意防止刷单、防止秒杀脚本等恶意行为,保障秒杀活动的公平性和用户体验。