Redis实战:解锁秒杀新方式

文章标题:

Redis实战:解锁新颖秒杀之法

文章内容:

目录

前言:

实现全局ID生成器,处理秒杀优惠券(运用乐观锁解决超卖状况),以及应对秒杀场景下一人一单的情况(涉及单机与集群环境中的线程安全问题)

1.全局ID生成器

1.1.思考:

鉴于以往一直把数据库中的id设置为自增长字段(每次自增1),以订单id为例,会出现什么问题呢?

  • 每新增一个订单,订单id就自增1,这样id的规律性太过明显,用户能够依据订单id去推测商家的营收状况(获取到商家的数据)
  • 要是订单量很大,数据库中的一张表无法容纳这么多数据,需要分表存储,但由于设置的id自增(每张表都从1开始自增),会导致订单id出现重复,在后续售后处理时,凭借订单id查询订单信息会因为id重复而不便于操作

1.2.订单id的特性:

  • 订单数量大
  • id需唯一

1.3.全局ID生成器的要求:

  • 唯一性:保证id独一无二
  • 高可用性:确保任何时候都能生成正确的id
  • 高性能性:保证生成id的速度足够快
  • 递增性:保证id整体呈逐渐递增态势,有利于数据库创建索引从而加快插入速度
  • 安全性:规律性不能过于显著

1.4.实现方案:

  1. UUID:生成16进制后转换为字符串(无序且不自增)
  2. Redis自增:第1位是符号位,恒为0;接下来31位是时间戳,记录id生成时间;最后的32位是序列号,生成64位二进制后形成long类型数据
  3. snowflake(雪花算法):第1位是符号位,恒为0;接下来41位是时间戳,记录id生成时间;然后10位是工作进程ID,用于区分不同服务器或进程;最后的12位是序列号,用于同一毫秒内生成不同id,生成64位二进制后形成long类型数据
  4. 数据库自增:单独用一张表存储生成的id值,其他需要使用id的表通过查询该表获取

1.5.具体实现(Redis自增方案):

为何可行:

  • 唯一性:由于Redis独立于数据库之外(不管有几张表或几个数据库),Redis仅有一个,所以其自增的id永远唯一
  • 高可用:借助集群、哨兵、主从等方案
  • 高性能:Redis基于内存,数据库基于硬盘,因此性能更优
  • 递增:Redis自带命令可实现自增
  • 安全性:不会直接使用Redis的自增数值(仍存在规律性明显的问题),通过拼接信息来实现

如何实现:采用拼接信息的方式,为提升性能,选用数值类型(long类型),因其占用空间小,便于建立索引

实现步骤:拼接信息,第1位是符号位,恒为0(0表示正,1表示负);接下来31位是时间戳(秒数),记录id生成时间;最后的32位是序列号(Redis自增数),生成64位二进制后形成long类型数据

解释:

时间戳(秒数):利用当前时间减去自行设置的开始时间得到的时间秒数

------------

思考:为何不直接使用当前时间的秒数呢

解释:直接使用当前时间秒数易被猜到规律,规律性明显

序列号:Redis自增数

------------

实现:Redis自增数使用String类型中的increment命令(每次自增1),该命令若Redis中无key会自动创建key并自增(此时值为1),若有key则直接将key中的value自增1,最终返回value值

------------

细节:由于使用Redis命令,最终序列号作为value存入Redis,那么存入Redis的自增数不就是订单数吗?后续若要统计订单数直接查询Redis即可,为方便查询,key需设置有意义的(通过key)

-------------

key的设置:自行设置前缀(因生成id的不只是订单id,需指定对应前缀区分),然后用前缀拼接时间(具体到天),最终形成一个key

------------

思考:加前缀能理解用于区分存入Redis的key,为何还要拼接时间呢?

解释:若序列号都用同一个key,Redis存入有上限,且拼接时间(具体到天)便于统计每天的下单量

实现细节:

思考:最终得到时间戳(秒)long类型,序列号(订单数)long类型,需拼接成全新的long,符号位无需关注(正数为0,负数为1)

<p title=Redis实战:解锁秒杀新方式

" />

步骤:将时间戳左移32位(留出序列号的位置),因左移位时以0填充,再将移位后的时间戳与序列号异或(只要有一个为真就是真,有1就是1),第一位符号位无需关注,时间戳为正数(id一般设为正数),最终形成新的long类型id

解释:这里是二进制计算,二进制只有0/1,有值则为1,无值则为0(异或)

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;
import java.time.LocalDateTime;
import java.time.ZoneOffset;
import java.time.format.DateTimeFormatter;

@Component
public class RedisIdWorker {

    @Autowired
    private final StringRedisTemplate stringRedisTemplate;

    public RedisIdWorker(StringRedisTemplate stringRedisTemplate) {
        this.stringRedisTemplate = stringRedisTemplate;
    }

    //定义开始时间戳
    private static final Long BEGIN_TIME_SECOND = 1740960000L;
    //移动位数
    private static final Long COUNT_BIT = 32L;

    public Long setId(String keyPrefix){
        //1.设置时间戳
        //当前时间戳
        LocalDateTime now = LocalDateTime.now();
        long second = now.toEpochSecond(ZoneOffset.UTC);
        //最终时间戳
        Long time = second - BEGIN_TIME_SECOND;

        //2.获取序列号
        String data = now.format(DateTimeFormatter.ofPattern("yyyy:MM:dd"));
        //Redis返回的序列号
        long increment = stringRedisTemplate.opsForValue().increment("icr" + keyPrefix + data);

        //拼接
        return time << COUNT_BIT | increment;
    }
}

Redis图效果:

<p title=Redis实战:解锁秒杀新方式

" />

2.秒杀优惠券

2.1.秒杀优惠券的基本实现

思索:在进行优惠券下单之前,需判断两点

  • 秒杀是否已开启或已结束
  • 库存数量是否充足

步骤:

前端提交优惠券ID

==》后端接收该ID

==》依据优惠券ID查询数据库,获取优惠券信息

==》判断秒杀是否处于开启或结束状态

==》若秒杀未开启或已结束

==》返回错误提示信息

-------

==》若秒杀正在进行中

==》判断库存数量是否充足

==》若库存不足

==》返回错误提示信息

-------

==》若库存充足

==》扣减库存数量

==》创建订单

==》返回订单ID

<p title=Redis实战:解锁秒杀新方式

" /> <p title=Redis实战:解锁秒杀新方式

" />
@Service
public class VoucherOrderServiceImpl extends ServiceImpl<VoucherOrderMapper, VoucherOrder> implements IVoucherOrderService {

    @Autowired
    private ISeckillVoucherService iSeckillVoucherService;
    @Autowired
    private RedisIdWorker redisIdWorker;
    @Override
    @Transactional
    public Result seckillVoucher(Long voucherId) {
        //1.根据id查询数据库优惠券信息
        SeckillVoucher voucher = iSeckillVoucherService.getById(voucherId);

        //2.获取时间
        LocalDateTime beginTime = voucher.getBeginTime();
        LocalDateTime endTime = voucher.getEndTime();

        //3.判断时间
        if (beginTime.isAfter(LocalDateTime.now())) {
            return Result.fail("秒杀还未开始");
        }
        if (endTime.isBefore(LocalDateTime.now())) {
            return Result.fail("秒杀已经结束");
        }

        //4.获取库存
        Integer stock = voucher.getStock();
        //库存不足
        if(stock < 1){
            return Result.fail("库存不足");
        }

        //库存足
        //5.库存减1
        boolean success = iSeckillVoucherService.update()
                .setSql("stock = stock -1")
                .eq("voucher_id", voucherId)
                .update();
        if (!success){
            return Result.fail("库存不足");
        }
        //6.创建订单
        VoucherOrder voucherOrder = new VoucherOrder();
        //优惠券id
        voucherOrder.setVoucherId(voucherId);
        //订单id
        Long orderId = redisIdWorker.setId("order");
        voucherOrder.setId(orderId);
        //用户id
        Long userId = UserHolder.getUser().getId();
        voucherOrder.setUserId(userId);
        //存入数据库
        save(voucherOrder);
        return Result.ok(orderId);
    }

}

@Component
public class RedisIdWorker {

    @Autowired
    private final StringRedisTemplate stringRedisTemplate;

    public RedisIdWorker(StringRedisTemplate stringRedisTemplate) {
        this.stringRedisTemplate = stringRedisTemplate;
    }

    //定义开始时间戳
    private static final Long BEGIN_TIME_SECOND = 1740960000L;
    //移动位数
    private static final Long COUNT_BIT = 32L;

    public Long setId(String keyPrefix){
        //1.设置时间戳
        //当前时间戳
        LocalDateTime now = LocalDateTime.now();
        long second = now.toEpochSecond(ZoneOffset.UTC);
        //最终时间戳
        Long time = second - BEGIN_TIME_SECOND;

        //2.获取序列号
        String data = now.format(DateTimeFormatter.ofPattern("yyyy:MM:dd"));
        //Redis返回的序列号
        long increment = stringRedisTemplate.opsForValue().increment("icr" + keyPrefix + data);

        //拼接
        return time << COUNT_BIT | increment;
    }
}

解释:重点需留意秒杀的时间和库存数量的判断

2.2.超卖问题

解释:

  • 前提:库存此时为1

示例:线程1先执行查询库存,线程2再执行查询,线程1扣减库存,线程2扣减库存

==》线程1先执行

==》线程1查询库存(1)

==》线程2抢到执行权

==》线程2查询库存(1)

==》线程1再次抢到执行权

==》因库存大于0

==》线程1执行库存扣减操作

==》此时库存(0)

==》线程2执行

==》因之前查询库存结果为1

==》线程2也执行库存扣减操作

==》此时库存(-1)

----------

那么此时 优惠券库存为-1,出现超卖问题

<p title=Redis实战:解锁秒杀新方式

" /> <p title=Redis实战:解锁秒杀新方式

" />

2.3.解决超卖问题的方案

解决方案:

方案一:悲观锁

悲观锁:认为线程安全问题必定会发生,所以在每次操作数据前先获取锁,以保证线程安全,使线程串行执行

  • Synchronized,Lock都属于悲观锁
  • 优点:简单直接
  • 缺点:性能一般

方案二:乐观锁

乐观锁:认为线程安全问题不一定发生,所以不加锁,仅在更新数据时判断有无其他线程修改数据

  • 若未修改则认为安全,自行更新数据
  • 若已被其他线程修改说明出现安全问题,此时可重试或返回异常
  • 优点:性能较好
  • 缺点:存在安全率低的问题

解释:悲观锁直接加锁,因加锁其他线程需等待,性能低;乐观锁不加锁,会出现安全问题(概率低)

思考:

  1. 由于是优惠券库存问题(有数据可判断是否被修改),可直接依据库存判断是否出现数据不一致问题,所以可采用乐观锁
  2. 若不是库存问题,需通过数据整体变化判断,此时采用乐观锁复杂,需判断数据多,所以采用悲观锁
  3. 悲观锁性能一般,如何提高性能:采用分批加锁(分段锁),将数据分成几份(假设分10张表),用户同时抢这10张表,同时10人抢(提高效率),最终思想:每次锁定资源少

总结:若要更新数据可用乐观锁,添加数据用悲观锁

2.4.基于乐观锁来解决超卖问题

版本号法: 设置版本号,每次查询库存时也查询版本号,最后扣减库存时增加判断条件(此时版本号应等于先前查询到的版本号),若不等事务回滚

思想:更新数据前比较版本号是否改变

步骤:

前端提交优惠券ID

==》后端接收该ID

==》依据优惠券ID查询数据库,获取优惠券信息,获取版本号

==》判断秒杀是否处于开启或结束状态

==》若秒杀未开启或已结束

==》返回错误提示信息

-------

==》若秒杀正在进行中

==》判断库存数量是否充足

==》若库存不足

==》返回错误提示信息

-------

==》若库存充足

==》判断版本号是否改变

==》若改变返回错误提示信息

-------

==》若版本号相同

==》扣减库存数量

==》创建订单

==》返回订单ID

<p title=Redis实战:解锁秒杀新方式

" /> <p title=Redis实战:解锁秒杀新方式

" />

CAS法 :直接比较库存,更新数据时增加判断条件(库存是否改变),库存改变不执行更新操作事务回滚

思想:直接利用已有数据判断,根据数据是否改变确定是否更新数据

步骤:

前端提交优惠券ID

==》后端接收该ID

==》依据优惠券ID查询数据库,获取优惠券信息

==》判断秒杀是否处于开启或结束状态

==》若秒杀未开启或已结束

==》返回错误提示信息

-------

==》若秒杀正在进行中

==》判断库存数量是否充足

==》若库存不足

==》返回错误提示信息

-------

==》若库存充足

==》判断库存是否改变

==》若改变返回错误提示信息

-------

==》若库存相同

==》扣减库存数量

==》创建订单

==》返回订单ID

![](https

版权声明:程序员胖胖胖虎阿 发表于 2025年7月4日 下午7:10。
转载请注明:

Redis实战:解锁秒杀新方式

| 胖虎的工具箱-编程导航

相关文章

暂无评论

暂无评论...