当前位置: 首页 > news >正文

秒杀业务如何设计

秒杀业务如何设计


全局唯一ID

在我们的秒杀业务中,不免会生成大量的订单数据,通常这些数据会保存在数据库表中,但是我们在设计数据库表的时候,通常都会把主键设置为自增IDAUTO_INCREMENT,但这是存在问题的:

  1. 自增ID规律容易被猜测,通过ID值可推测出一些敏感信息,或恶意攻击
  2. 若数据量较大需要水平分库分表,两张表各自维护自增值,会使得主键重复

因此需要生成一个全局唯一ID,在分布式系统下也能维护唯一的自增值,主要运用在分布式系统、数据量大,并且数据敏感的数据库表主键的生成,其需要具备以下特征

  • 唯一性:主键不能重复
  • 高可用性:必须要快要稳定
  • 递增性:由于Mysql的B+树索引有序的特性,必须是递增
  • 安全性:规律不容易被揣测

最常见的唯一ID生成的方式有很多种:雪花算法UUID(非递增)Redis自增 这里演示Redis自增的实现方式:

其采用64位长整型数,高位32位为:符号位(1位)+时间戳(31位),低32位是由redis生成的序列号

@Component
public class RedisIdWorker {
    @Autowired
    private StringRedisTemplate redisTemplate;
    private static final long BEGIN_TIMESTAMP = 1640995200L; // 起始时间戳(2022.01.01.00:00:00)
    private static final int COUNT_BITS = 32;  // 左移位数

    /**
     * 生成一个全局唯一Id
     * @return
     */
    public long nextId(String keyPrefix){  // 传入业务对应标识
        LocalDateTime now = LocalDateTime.now();

        // 生成时间戳
        long nowSecond = now.toEpochSecond(ZoneOffset.UTC);
        long timeStamp = nowSecond - BEGIN_TIMESTAMP;

        // 生成序列号
        String date = now.format(DateTimeFormatter.ofPattern("yyyy:MM:dd")); // 当天日期
        long count = redisTemplate.opsForValue().increment("icr:"+keyPrefix+":"+date);

        // 拼接唯一Id
        return timeStamp << COUNT_BITS | count;

    }

}

这里在传入Key的时候拼接了当天日期,将每天的数据做区分,方便统计以及溯源


秒杀下单初步设计

在设计秒杀系统的时候,通常要更具具体业务的不同来分别做一些设计,比如表的设计,购买机制(一人多单/一人一单)、秒杀时段,当然更重要的是并发安全性的考虑,比如说如何安全扣减库存,如何限制下单次数,如何处理卡单不付款,现在我们先完成初步的设计

数据库表

通常情况下,秒杀商品的数据库表与普通商品的表是分离的,订单记录也是分离的,因为秒杀商品与普通商品本质就不属于同一个业务场景,自然就要分离,但是具体数据库的设计还是根据实际情况来设计的,这里提供一些示例:

-- 秒杀商品表(与普通商品分离)
CREATE TABLE `seckill_product` (
  `id` BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
  `activity_id` BIGINT UNSIGNED NOT NULL COMMENT '关联活动',
  `product_id` BIGINT UNSIGNED NOT NULL COMMENT '关联原商品',
  `seckill_price` DECIMAL(10,2) NOT NULL COMMENT '秒杀价',
  `stock` INT UNSIGNED NOT NULL COMMENT '秒杀库存',
  `initial_stock` INT UNSIGNED NOT NULL COMMENT '初始库存',
  `begin_time` DATETIME NOT NULL COMMENT '开始时间',
  `end_time` DATETIME NOT NULL COMMENT '结束时间',
  `status` TINYINT(1) DEFAULT 0 COMMENT '0未开始 1进行中 2已结束',
  `created_at` DATETIME NOT NULL,
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- 秒杀订单表(与普通订单分离)
CREATE TABLE `seckill_order` (
  `id` BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
  `user_id` BIGINT UNSIGNED NOT NULL,
  `product_id` BIGINT UNSIGNED NOT NULL,
  `quantity` INT UNSIGNED NOT NULL DEFAULT 1,
  `seckill_price` DECIMAL(10,2) NOT NULL,
  `status` TINYINT(1) DEFAULT 0 COMMENT '0待支付 1已支付 2超时取消',
  `created_at` DATETIME NOT NULL,
  `pay_time` DATETIME,
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

下单条件判断

在下单时,我们需要校验当前是否可以进行下单,比如说:是否在秒杀时间之内、当前库存是否充足等等一系列检查

开始
是否在秒杀时间呢
库存是否充足
生成订单
返回订单
 	@Autowired
    private SeckillMapper seckillMapper;
    @Autowired
    private RedisIdWorker redisIdWorker;
    /**
     * 秒杀下单
     * @param productId
     * @return
     */
    @Override
    @Transactional
    public Result<Long> seckillProduct(Long productId) {
        //查询商品信息以及秒杀信息
        Product Product = seckillMapper.getProductById(productId); // 原商品信息
        SeckillProduct seckillProduct = seckillMapper.getSeckillProductById(productId); // 秒杀信息
        // 判断是否开始
        LocalDateTime beginTime = seckillProduct.getBeginTime();
        if (beginTime.isAfter(LocalDateTime.now())){
            return Result.error("活动尚未开始");
        }
        // 判断是否结束
        LocalDateTime endTime = seckillProduct.getEndTime();
        if (endTime.isBefore(LocalDateTime.now())) {
            return Result.error("活动已结束");
        }
        // 判断库存是否充足
        if (seckillProduct.getStock() < 1) {
            return Result.error("库存不足");
        }
        // 扣减库存
        boolean isSuccess = seckillMapper.deductionStock(productId);
        if (!isSuccess)
            return Result.error("操作失败");
        // 创建订单
        SeckillOrder seckillOrder = new SeckillOrder();
            // 生成订单id
        long orderId = redisIdWorker.nextId("seckill");
        seckillOrder.setId(orderId);
            // 用户Id
        Long userId = BaseContext.getCurrentId();
        seckillOrder.setUserId(userId);
            // 商品Id
        seckillOrder.setProductId(productId);
        seckillOrder.setCreatedAt(LocalDateTime.now()); // 创建时间
        seckillOrder.setSeckillPrice(seckillProduct.getSeckillPrice()); // 价格
        // 保存订单
        seckillMapper.save(seckillOrder);
        // 返回订单Id
        return Result.success(orderId);
    }

这样我们就完成了一个最简单的秒杀业务,通过测试这段代码是没有问题的,但是在真实的业务场景下,是大量的用户同时点击下单按钮向后端发起请求,在这样高并发的场景下我们的代码显然是不够用的,因为我们没有做任何并发安全保障

假如此时我们的库存只剩下最后一个了,用户A和用户B同时向后端发起请求,但是由于用户A更快一点,先从数据库获取了秒杀信息,之后检查库存发现,库存还有,可以下单,但是在扣减库存之前,用户B的请求也拿到了数据库中的秒杀信息,由于用户A线程还未扣减库存,于是剩余库存还是1,用户B线程也判断认为可以下单,于是就出现了库存超买的问题


库存超卖问题

超卖问题是典型的多线程安全问题,针对这一问题的常见解决方案就是加锁

  • 悲观锁:

    认为线程安全问题一定会发生,因此在操作数据之前先获取锁,确保线程串行执行例如Synchronized、Lock都属于悲观锁

  • 乐观锁:

    认为线程安全问题不一定会发生,因此不加锁,只是在更新数据时去判断有没有其它线程对数据做了修改,如果没有修改则认为是安全的,自己才更新数据,如果已经被其它线程修改说明发生了安全问题,此时可以重试或异常

悲观锁

悲观锁线程安全问题一定会发生,那么我们就要为每一个线程进入到关键方法的时候去加一个锁,保证线程的串行执行,对于如何改造上面的代码我们就只需要在方法上加一个**synchronized**关键字即可

public synchronized Result<Long> seckillProduct(Long productId)

但是这样真的可以解决超卖问题吗?

还记得我们在这个方法上加上了@Transactional开启事务吗,而同步锁**synchronized锁住了整个方法,当方法结束的时候,会先释放锁,之后才会提交事务,如果在释放锁之后事务提交之前**,有其他线程趁此机会有拿到了锁去检查库存,但是由于事务还未提交,库存并未真正扣减,所以数据库事务隔离级别不是串行化的情况下,其他线程会认为依然有库存,造成库存超买的问题

除此之外还有其他问题:

  1. 单机锁无法解决分布式问题
    synchronized 是 Java 中的 ​单机锁,只能保证同一个 JVM 实例内的线程安全,如果你的服务是分布式部署(多个实例),synchronized 无法跨实例控制并发,仍然可能导致超卖
  2. 锁粒度问题
    synchronized 锁的粒度是整个方法,所有请求都会串行执行,即使它们操作的是不同的商品(productId),这会导致性能瓶颈,尤其是高并发场景下

乐观锁

乐观锁最经典的方式就是CAS(compareAndSet),不进行任何加锁处理,在更改时先判断其是否被更改过了(比较并更改)

在秒杀业务中,我们可以在扣减数据库库存时,先和之前获取的值进行对比,如果一样,说明没有被其他线程修改,这样做利用的数据库的行级锁来保证并发时数据的一致性:

这是我们之前的扣减库存的业务:

.....
		// 扣减库存
        boolean isSuccess = seckillMapper.deductionStock(productId);
        if (!isSuccess)
            return Result.error("操作失败");
......

那么对于其对应的deductionStock方法就不仅仅只是扣减库存,还要检查是否被修改过,但是这里并不需要一定要求其与之前获取的库存相等:

UPDATE seckill_product SET stock = stock - 1 WHERE id = ? AND stock = ?;

因为库存的特殊性,我们只需要让其保持大于0就可以正常扣减库存,如果强行要求前后值一样,则会导致大量请求失败,影响用户体验,因此只需:

UPDATE seckill_product SET stock = stock - 1 WHERE id = ? AND stock > 0

接下来就可以编写我们的Mapper层的代码:

public interface SeckillMapper {
    /**
     * 扣减库存
     * @param productId 商品ID
     * @return 是否成功(true:成功,false:失败)
     */
    boolean deductionStock(Long productId);
}
<!-- 扣减库存 -->
    <update id="deductionStock">
        UPDATE seckill_product
        SET stock = stock - 1
        WHERE id = #{productId}
        AND stock > 0
    </update>

一人一单业务

通常秒杀业务中,同一个商品只允许一个人下单一次,也就是一人一单,那么我们如何保证一人一单呢?

实现思路很简单,同一个人同个商品只允许下单一次,那么只需要在下单时去订单表中查询:

select count(*) from seckill_order where user_id = ? and product_id = ?;

那么我们只需要在扣减库存之前去做一人一单的检查即可:

        // 判断库存是否充足....

        // 判断是否已经下过单了
        int count = seckillMapper.check(BaseContext.getCurrentId());
        if (count > 0)
            return Result.error("禁止重复下单");

        // 扣减库存....

你以为这样就可以了吗???

这样同样会有并发安全问题,虽然前端页面只允许你一次下一单,但是这个只防君子不防小人,小人会选择绕过前端之间向后端发送大量请求,两个请求先后到达check方法,由于事务都未提交,所以在数据库中查到的count值都是0,此时再先后完成扣减库存,不就一个人下了多单了吗

那怎么办么?依然加一个乐观锁吗,但是这里并不是修改操作,而是检查是否存在,也就无法使用CAS操作,因此这里只能加悲观锁

我们需要把检查一人一单的check方法到返回订单id之前,都加上悲观锁,而不是只在check方法上加锁,因为事务到返回订单id之后才会提交,这期间不允许再次下单,因此可以把这一部分提取出来变成一个新的方法:

/**
     * 秒杀下单
     * @param productId
     * @return
     */
    @Override
    public Result<Long> seckillProduct(Long productId) {
        //查询商品信息以及秒杀信息
        Product Product = seckillMapper.getProductById(productId); // 原商品信息
        SeckillProduct seckillProduct = seckillMapper.getSeckillProductById(productId); // 秒杀信息
        // 判断是否开始
        LocalDateTime beginTime = seckillProduct.getBeginTime();
        if (beginTime.isAfter(LocalDateTime.now())){
            return Result.error("活动尚未开始");
        }
        // 判断是否结束
        LocalDateTime endTime = seckillProduct.getEndTime();
        if (endTime.isBefore(LocalDateTime.now())) {
            return Result.error("活动已结束");
        }
        // 判断库存是否充足
        if (seckillProduct.getStock() < 1) {
            return Result.error("库存不足");
        }
        return creatProductOrder(productId, seckillProduct);
    }
    // 提取方法
    @Transactional
    public Result<Long> creatProductOrder(Long productId, SeckillProduct seckillProduct) {
        // 判断是否已经下过单了
        int count = seckillMapper.check(BaseContext.getCurrentId());
        if (count > 0)
            return Result.error("禁止重复下单");


        // 扣减库存
        boolean isSuccess = seckillMapper.deductionStock(productId);
        if (!isSuccess)
            return Result.error("操作失败");

        // 创建订单
        SeckillOrder seckillOrder = new SeckillOrder();
        // 生成订单id
        long orderId = redisIdWorker.nextId("seckill");
        seckillOrder.setId(orderId);
        // 用户Id
        Long userId = BaseContext.getCurrentId();
        seckillOrder.setUserId(userId);
        // 商品Id
        seckillOrder.setProductId(productId);
        seckillOrder.setCreatedAt(LocalDateTime.now()); // 创建时间
        seckillOrder.setSeckillPrice(seckillProduct.getSeckillPrice()); // 价格
        // 保存订单
        seckillMapper.save(seckillOrder);
        // 返回订单Id
        return Result.success(orderId);
    }

由于所有的修改操作都在新提取的方法中,于是我们就需要把事务注解@Transactional改到第二个方法上

接下来就是加锁操作了,此时有一个问题,锁是加在creatProductOrder方法上还是方法内?

1. 加在方法上

如果把synchronized加在方法上,那么加锁的对象就是当前类的class文件:

public synchronized Result<Long> creatProductOrder(Long productId, SeckillProduct seckillProduct)

也就是说,任意一个用户来做秒杀下单,到检查一人一单时都要进行加锁操作,但是不同的用户之间并没有一人多单的风险,之所以要进行检查就是怕同一个用户多次下单,因此让所有用户的请求都阻塞的锁粒度会不会又点大呢?

2. 加在方法内

如果加在方法内,就只需要把同一个用户的多次请求给阻塞住,因此加锁的对象可以说用户的id,细化锁粒度:

@Transactional
public Result<Long> creatProductOrder(Long productId, SeckillProduct seckillProduct) {
    // 用户Id
    Long userId = BaseContext.getCurrentId();
    synchronized(userId.toString().intern()){
        // 检查一人一单
        
        // 扣减库存
        
        // 生成订单...
    }
        
}

这样锁的锁定范围变小了,性能也得到了提升,但是,还记得上面实现超卖问题的解决时,为什么不使用悲观锁呢,方法上加了@Transactional开启事务,而同步锁**synchronized**结束之后事务并没有提交,还是可能会导致并发安全问题的,因此锁不能加在方法内,而是用锁来锁住整个方法,让事务先提交而后释放锁

也就是在creatProductOrder()方法调用时,在seckillProduct()中的调用处加上锁:

@Override
public Result<Long> seckillProduct(Long productId) {
    //查询商品信息以及秒杀信息...
    
    // 判断秒杀是否开始或结束...
    
    // 判断库存是否充足...
    
    Long userId = BaseContext.getCurrentId();
	synchronized (userId.toString().intern()){ // 锁住用户的Id
		return creatProductOrder(productId, seckillProduct);
	}
    
}

**这样就可以了吗?**并不是

这里还涉及了事务失效的问题,众所周知,@Transactional注解开启事务的原理是为我们的方法生成一个动态代理对象,在代理对象中的方法前后为我们开启事务和提交事务,我们只在creatProductOrder()方法上加上了事务注解,而这里调用creatProductOrder()方法实际上是调用了对象本身的方法,也就是this.creatProductOrder(),并没有使用Spring为了事务而生成的代理对象,从而导致事务失效

因此我们在这里也要拿到事务的代理对象,利用AopContext接口的**currentProxy()**方法就能获取动态代理对象

Long userId = BaseContext.getCurrentId();
synchronized (userId.toString().intern()){
    // 获取动态代理对象
	SeckillServiceImpl proxy = (SeckillServiceImpl) AopContext.currentProxy();
    // 用代理对象调用
	return proxy.creatProductOrder(productId, seckillProduct);
}

除此之外,这样实现代理对象调用还需要完成三件事:

  1. SeckillServiceImpl类的接口SeckillService中也要写上**creatProductOrder()**方法的方法签名,这样Spring才能正确生成代理对象中的方法

  2. 引入aspectj动态代理模式的依赖

    <dependency>
    	<groupId>org.aspectj</groupId>
    	<artifactId>aspectjweaver</artifactId>
    </dependency>
    
  3. 在启动类上加上@EnableAspectJAutoProxy注解,将exposeProxy设为true,暴露代理对象

    @EnableAspectJAutoProxy(exposeProxy = true)
    @SpringBootApplication
    public class JourneyLinkAppApplication {
        public static void main(String[] args) {
            SpringApplication.run(JourneyLinkAppApplication.class, args);
        }
    }
    

这样就完成了


分布式系统、集群模式

以上我们实现的秒杀系统只能在单体项目中使用,当今的项目多为分布式项目,并且服务的部署也会采用集群模式,会将不同的业务模块拆分为不同的微服务模块,同一个服务也会部署在多台服务器上,服务模块之间通过网络传输数据调用方法,而我们所使用的悲观锁synchronized属于JVM本地锁,由本地的锁监视器来控制加锁与释放锁操作,如果部署在多台服务器上,就会有多个JVM,因此本地锁就无法保证分布式的并发安全,而在方法内也会调用其他服务器上其他模块的业务方法,这就超出了@Transactional注解的事务范围,造成事务失效,因此在分布式系统、集群模式的项目中,我们的秒杀系统应该使用分布式锁+分布式事务

相关文章:

  • 【实战】deepseek数据分类用户评论数据
  • 如何编写一个Spring Boot Starter
  • 语法: result=fmod(val1, val2)
  • python3最新版下载及python 3.13.1安装教程(附安装包)
  • DeepSeek自学手册:《从理论(模型训练)到实践(模型应用)》|73页|附PPT下载方法
  • δ函数相关的定义和性质
  • 免费下载 | 2025低空经济产业发展报告
  • 什么是嵌入式处理器
  • 玄机-第四章 windows实战-wordpress的测试报告
  • Windows系统提权
  • 《Git:基本命令使用》
  • 【python】12. File
  • QT多线程实战经验
  • 深入C++:operator new与operator delete重载探秘
  • 常用数据库远程连接工具全解析:从入门到高效管理
  • MySQL Router被HTTP流量击穿
  • 读《浪潮之巅》:探寻科技产业的兴衰密码
  • 为AI聊天工具添加一个知识系统 之147 设计重审 之12 聚合AI
  • Vue.js 模板语法全解析:从基础到实战应用
  • 机场上云-无人机状态上报流程
  • 5月1日全国铁路发送旅客2311.9万人次,创历史新高
  • 陈颖已任上海黄浦区委常委、统战部部长
  • 深观察丨从“不建议将导师挂名为第一作者”说开去
  • 广东省副省长刘红兵跨省调任湖南省委常委、宣传部长
  • 牛市早报|今年第二批810亿元超长期特别国债资金下达,支持消费品以旧换新
  • 外交部官方公众号发布视频:不跪!