秒杀功能实现


秒杀功能实现

在电商项目中我们经常会看到商品秒杀功能;就是商家给出一定数量的商品,用户对这些商品进行抢购。看似简单的秒杀功能那么是如何实现的呐?

我们这就来解开秒杀功能实现的逻辑及代码演示。

1.秒杀功能实现

1.1 建表:

用户表:

CREATE TABLE `user` (
  `id` int(11) NOT NULL AUTO_INCREMENT COMMENT '主键',
  `name` varchar(10) DEFAULT NULL COMMENT '名字',
  `age` int(11) DEFAULT NULL COMMENT '年龄',
  `phone` char(11) DEFAULT NULL COMMENT '号码',
  `user_name` varchar(255) DEFAULT NULL COMMENT '用户名',
  `password` varchar(255) DEFAULT NULL COMMENT '密码',
  `status` tinyint(1) DEFAULT '0' COMMENT '登录状态(0:未登录,1:已登录)',
  `lock_accont` tinyint(1) DEFAULT '0' COMMENT '账号锁(0:未锁,1:锁)',
  `last_dt` datetime DEFAULT NULL COMMENT '最后一次登录时间',
  `ip` varchar(15) DEFAULT NULL COMMENT '客户端ip',
  `disable` tinyint(1) DEFAULT '0' COMMENT '账号是否被禁用(0:否,1:是)',
  `disable_dt` datetime DEFAULT NULL COMMENT '封禁日期',
  `enable_dt` datetime DEFAULT NULL COMMENT '解封时间',
  `create_dt` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
  `update_dt` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '修改时间',
  PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=1019 DEFAULT CHARSET=utf8mb4 COMMENT='用户表';

优惠券表:

CREATE TABLE `ticket` (
  `id` int(11) NOT NULL AUTO_INCREMENT COMMENT '主键',
  `name` varchar(255) DEFAULT NULL COMMENT '卷名',
  `ticket_count` int(11) DEFAULT NULL COMMENT '票数',
  `create_dt` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
  `update_dt` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '修改时间',
  PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=3 DEFAULT CHARSET=utf8mb4 COMMENT='优惠卷表';

用户优惠券关联表:

CREATE TABLE `user_ticket` (
  `user_id` int(11) NOT NULL COMMENT '用户id',
  `ticket_id` int(11) NOT NULL COMMENT '票id'
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='抢票关联表';

1.2 功能实现说明:

我们先初步的实现秒杀功能,看看到底是怎样的一个流程吧:

1.3 代码实现:

代码技术选型是springboot、mybatisplus。

Controller:

/**
 * 优惠卷表(Ticket)表控制层
 *
 */
@Slf4j
@RestController
@RequestMapping("ticket")
public class TicketController {
    /**
     * 服务对象
     */
    @Resource
    private TicketService ticketService;

	 //秒杀
    @PostMapping("flashSale")
    public Result<Boolean> flashSale(UserTicket ut) {
        return Result.succeed(ticketService.flashSale(ut));
    }

}

Entity:

/**
 * 优惠卷表(Ticket)表实体类
 *
 * @author xlw
 * @since 2024-01-22 21:12:01
 */
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
@TableName("ticket")
public class Ticket {
    
    //主键    
    @TableId(value = "id", type = IdType.AUTO)
    private Integer id;
    
    //卷名    
    @TableField(value = "name")
    private String name;
    
    //票数    
    @TableField(value = "ticket_count")
    private Integer ticketCount;
    
    //创建时间    
    @TableField(value = "create_dt")
    private Date createDt;
    
    //修改时间    
    @TableField(value = "update_dt")
    private Date updateDt;

}
/**
 * 用户表(User)表实体类
 *
 * @author xlw
 * @since 2023-12-23 19:43:50
 */
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
@TableName("user")
public class User {
    
    //主键    
    @TableId(value = "id", type = IdType.AUTO)
    private Integer id;
    
    //名字    
    @TableField(value = "name")
    private String name;
    
    //年龄    
    @TableField(value = "age")
    private Integer age;
    
    //号码    
    @TableField(value = "phone")
    private String phone;
    
    //用户名    
    @TableField(value = "user_name")
    private String userName;
    
    //密码    
    @TableField(value = "password")
    private String password;
    
    //创建时间
//    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
//    @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
    @TableField(value = "create_dt")
    private LocalDateTime createDt;
    
    //修改时间    
    @TableField(value = "update_dt")
    private LocalDateTime updateDt;

}
/**
 * 抢票关联表(UserTicket)表实体类
 *
 * @author xlw
 * @since 2024-01-22 21:12:26
 */
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
@TableName("user_ticket")
public class UserTicket {
    
    //用户id    
    @TableField(value = "user_id")
    private Integer userId;
    
    //票id    
    @TableField(value = "ticket_id")
    private Integer ticketId;
}

Service:

/**
 * 优惠卷表(Ticket)表服务接口
 *
 * @author xlw
 * @since 2024-01-22 21:12:01
 */
public interface TicketService extends IService<Ticket> {
    

    /**
     * 秒杀
     *
     * @return boolean
     */
    String flashSale(UserTicket ut);

}
/**
 * 优惠卷表(Ticket)表服务实现类
 *
 * @author xlw
 * @since 2024-01-22 21:12:01
 */
@Service("ticketService")
public class TicketServiceImpl extends ServiceImpl<TicketMapper, Ticket> implements TicketService {

    @Resource
    private UserTicketService userTicketService;

    /**
     * 锁池
     */
    private final Interner<String> pool = Interners.newWeakInterner();

    @Override
    public String flashSale(UserTicket ut) {
        //加锁的意义是控制一人一单,缺点线程压积
        synchronized (pool.intern("user_" + ut.getUserId())) {
            //1.查询优惠卷库存
            Ticket ticket = getById(ut.getTicketId());
            if (ticket.getTicketCount() <= 0) {
                return "库存不足";
            }
            //2.一人一单
            QueryWrapper<UserTicket> queryWrapper = new QueryWrapper<>();
            queryWrapper.eq("user_id", ut.getUserId());
            boolean exists = userTicketService.exists(queryWrapper);
            if (exists) {
                return "已经抢过票了";
            }

            //注意:this.submitOrder()会导致事务失效,原因是自引用不走代理
            TicketServiceImpl ts = (TicketServiceImpl) AopContext.currentProxy();
            return ts.submitOrder(ut) ? "抢购成功" : "抢购失败";
        }
    }

    @Transactional(rollbackFor = Exception.class)
    public boolean submitOrder(UserTicket ut) {
        //3.扣减库存,乐观锁控制库存的扣减
        boolean update = update().setSql("ticket_count = ticket_count - 1").eq("id", ut.getTicketId()).gt("ticket_count", 0).update();
        //4.添加用户与优惠卷的关联关系
        if (!update) {
            return false;
        }
        return userTicketService.save(ut);
    }

}

我们在flashSale(UserTicket ut)使用了synchronized的关键字为了就是防止同一个用户对同一个商品进行大量的秒杀请求操作时,如果用户优惠券表还没有该用户记录,很大的可能会操作该用户多次秒杀同一商品;我们synchronized锁对象并没有直接使用"user_" + ut.getUserId(),而且使用的guava提供的Interners工具类,这个可以避免我们在String常量池中大量的创建字符串。

1.4 秒杀功能的优化:

以上我们简单的实现了单体环境下的秒杀功能,但是以上代码还有很多的不足之处,例如并发量很大的话大量请求会直接打在数据库上,很容易把数据库弄宕机;在分布式环境下会造成超发。因此我们需要对以上的实现逻辑和代码进行改造和优化;我们引入reids来实现分布式锁和缓存,rabbitMQ来实现最终一致性和削峰。

Service代码优化:

/**
 * 优惠卷表(Ticket)表服务实现类
 *
 * @author xlw
 * @since 2024-01-22 21:12:01
 */
@Service("ticketService")
public class TicketServiceImpl extends ServiceImpl<TicketMapper, Ticket> implements TicketService {

    @Resource
    private UserTicketService userTicketService;

    @Resource
    private RedisUtil redisUtil;

    @Resource
    private RabbitMQUtil rabbitMQUtil;

    @Resource
    private RedissonClient redissonClient;

    /**
     * 票前缀
     */
    private final String TICKET_PREFIX = "ticket:";

    /**
     * 用户票前缀
     */
    private final String USER_TICKET_PREFIX = "user_ticket:";

    private final String LUA_PATH = "lua/FlashSale.lua";

    @Override
    public String flashSale2(UserTicket ut) {
        RLock lock = redissonClient.getLock("lock:" + ut.getUserId());
        boolean b = false;
        try {
            b = lock.tryLock();
            if (!b) {
                return "不允许重复操作";
            }
            //1.调用秒杀lua脚本
            Long result = redisUtil.execute(Long.class, LUA_PATH, CollectionUtil.toList(TICKET_PREFIX + ut.getTicketId(), USER_TICKET_PREFIX + ut.getTicketId()), ut.getTicketId(), ut.getUserId());
            if (result.intValue() != 0) {
                //下单失败
                return result.intValue() == 1 ? "库存不足" : "用户已下单";
            }
            //下单成功,通过MQ完成下单和库存扣减
            rabbitMQUtil.safeSend(ut);
            return "下单成功";
        } finally {
            if (b) {
                lock.unlock();
            }
        }
    }
}

秒杀LUA脚本:

--秒杀就是使用redis中的set类型判断用户是否下单,string类型保存票的库存
--秒杀主要依托于redis的线程安全,利用LUA脚本的原子性保证库存和下单之间的数据一致性,

--票库存key
local ticketKey = KEYS[1]
--票id
local ticketId = ARGV[1]
--用户下单key
local userKey = KEYS[2]
--用户id
local userId = ARGV[2]

--1.判断票的库存是否足够
if (tonumber(redis.call("get", ticketKey)) < 1) then
    --库存不足
    return 1
end
--2.判断用户是否下单
if (redis.call("sismember", userKey, userId) == 1) then
    --用户已下单
    return 2
end
--3.用户未下单,可下单,添加下单记录
redis.call("sadd", userKey, userId)
--4.扣减库存
redis.call("decr", ticketKey)
--抢票成功
return 0

我们把优惠券的库存和优惠券秒杀用户数据存到了redis中,这样就避免了热点数据的访问流量被直接打到数据库上;我们通过lua脚本操作redis命令可以保证原则性操作;RLock分布式锁可以防止分布式环境下超卖的问题。

优惠券订单消费端:

/**
 * @description: 消息队列消费者
 * @Title: MQConsumer
 * @Author xlw
 * @Package com.xlw.test.rabbitmq_demo.config
 * @Date 2024/1/29 19:44
 */
@Slf4j
@Component
public class MQConsumer {

    @Resource
    private TicketService ticketService;

    private ThreadPoolTaskExecutor threadPool;

    @PostConstruct
    public void init() {
        log.info("初始化线程池");
        threadPool = new ThreadPoolTaskExecutor();
        threadPool.initialize();
        threadPool.setCorePoolSize(4);
        threadPool.setMaxPoolSize(8);
        threadPool.setThreadGroup(new ThreadGroup("order"));
        threadPool.setThreadNamePrefix("o_");
        threadPool.setQueueCapacity(100);
    }

    @RabbitListener(queues = "${mq.queue-name}")
    public void orderConsume(String msg, Channel channel, Message message) {
        threadPool.execute(() -> {
            log.info("消费者消费:{}", msg);
            UserTicket userTicket = JSONObject.parseObject(msg, UserTicket.class);
            try {
                boolean b = ticketService.createOrder(userTicket);
                if (b) {
                    //手动签收
                    channel.basicAck(message.getMessageProperties().getDeliveryTag(), false);
                    return;
                }
            } catch (Exception e) {
                try {
                    //拒绝消息,进入死信队列
                    channel.basicReject(message.getMessageProperties().getDeliveryTag(), false);
                } catch (IOException ex) {
                    throw new RuntimeException(ex);
                }
                throw new RuntimeException(e);
            }
            try {
                //拒绝消息,进入死信队列
                channel.basicReject(message.getMessageProperties().getDeliveryTag(), false);
            } catch (IOException ex) {
                throw new RuntimeException(ex);
            }
        });
    }
}

优惠券订单消费端使用了线程池提高消费端的消费速度。

2.总结:

秒杀功能要点就是:

  • 解决高并发环境下大量的请求直接访问数据库的问题; (使用缓存)
  • 分布式环境下如何解决订单超发的问题; (使用分布式锁)
  • 提高秒杀功能的性能问题。 (使用异步处理,多线程处理)

文章作者: 威@猫
版权声明: 本博客所有文章除特別声明外,均采用 CC BY 4.0 许可协议。转载请注明来源 威@猫 !
评论
  目录