Redis从入门到精通(六)Redis实战(三)优惠券秒杀-4.3 优惠券秒杀

每个商户都可以发布优惠券,分为普通券和秒杀券。普通券优惠力度较低,所以可以任意购买;而秒杀券优惠力度较大,需要限时限量抢购。例如:

4.3.1 数据表与实体类

在数据库中,tb_voucher表用于保存优惠券的信息,包括优惠券的基本信息、优惠金额、使用规则等:

tb_seckill_voucher表用于保存秒杀券的扩展信息,包括秒杀券的库存、开始抢购时间、结束抢购时间等:

tb_voucher_order表用于保存优惠券购买订单信息,包括购买用户的ID、优惠券的ID、购买时间等:

根据以上三个数据表在项目中创建三个对应的实体类,如下:

// tb_voucher、tb_seckill_voucher
// com.star.redis.dzdp.pojo.Voucher

/***
 * 优惠券
 * @author hsgx
 * @since 2024/4/5 10:26
 */
@Data
@EqualsAndHashCode(callSuper = false)
@TableName("tb_voucher")
public class Voucher implements Serializable {

    private static final long serialVersionUID = 1L;

    /**
     * 主键
     */
    @TableId(value = "id", type = IdType.AUTO)
    private Long id;

    /**
     * 商铺id
     */
    private Long shopId;

    /**
     * 代金券标题
     */
    private String title;

    /**
     * 副标题
     */
    private String subTitle;

    /**
     * 使用规则
     */
    private String rules;

    /**
     * 支付金额
     */
    private Long payValue;

    /**
     * 抵扣金额
     */
    private Long actualValue;

    /**
     * 优惠券类型
     */
    private Integer type;

    /**
     * 优惠券类型
     */
    private Integer status;

    /**
     * 库存
     */
    @TableField(exist = false)
    private Integer stock;

    /**
     * 生效时间
     */
    @TableField(exist = false)
    private Date beginTime;

    /**
     * 失效时间
     */
    @TableField(exist = false)
    private Date endTime;

    /**
     * 创建时间
     */
    private Date createTime;


    /**
     * 更新时间
     */
    private Date updateTime;
    
}
// tb_seckill_voucher
// com.star.redis.dzdp.pojo.SeckillVoucher

/***
 * 秒杀优惠券表
 * @author hsgx
 * @since 2024/4/5 10:26
 */
@Data
@EqualsAndHashCode(callSuper = false)
@TableName("tb_seckill_voucher")
public class SeckillVoucher implements Serializable {

    private static final long serialVersionUID = 1L;

    /**
     * 关联的优惠券的id
     */
    @TableId(value = "voucher_id", type = IdType.INPUT)
    private Long voucherId;

    /**
     * 库存
     */
    private Integer stock;

    /**
     * 创建时间
     */
    private Date createTime;

    /**
     * 生效时间
     */
    private Date beginTime;

    /**
     * 失效时间
     */
    private Date endTime;

    /**
     * 更新时间
     */
    private Date updateTime;

}
// tb_voucher_order
// com.star.redis.dzdp.pojo.VoucherOrder

/***
 * 优惠券订单
 * @author hsgx
 * @since 2024/4/5 10:30
 */
@Data
@EqualsAndHashCode(callSuper = false)
@TableName("tb_voucher_order")
public class VoucherOrder implements Serializable {

    private static final long serialVersionUID = 1L;

    /**
     * 主键
     */
    @TableId(value = "id", type = IdType.INPUT)
    private Long id;

    /**
     * 下单的用户id
     */
    private Long userId;

    /**
     * 购买的代金券id
     */
    private Long voucherId;

    /**
     * 支付方式 1:余额支付;2:支付宝;3:微信
     */
    private Integer payType;

    /**
     * 订单状态,1:未支付;2:已支付;3:已核销;4:已取消;5:退款中;6:已退款
     */
    private Integer status;

    /**
     * 下单时间
     */
    private Date createTime;

    /**
     * 支付时间
     */
    private Date payTime;

    /**
     * 核销时间
     */
    private Date useTime;

    /**
     * 退款时间
     */
    private Date refundTime;

    /**
     * 更新时间
     */
    private Date updateTime;
    
}

4.3.2 添加优惠券

4.3.2.1 添加普通券代码
  • 1)接口文档
项目 说明
请求方法 POST
请求路径 /voucher/add
请求参数 Voucher
返回值
  • 2)代码实现

首先为普通优惠券业务创建对应的VoucherController类-IVoucherService接口-VoucherServiceImpl实现类-VoucherMapper类;为秒杀优惠券业务创建对应的ISeckillVoucherService接口-SeckillVoucherServiceImpl实现类-SeckillVoucherMapper类。详见测试项目代码。

然后在VoucherController类中编写一个add()方法,用于新增普通优惠券:

// com.star.redis.dzdp.controller.VoucherController

@Resource
private IVoucherService voucherService;

/**
 * 添加普通优惠券
 * @author hsgx
 * @since 2024/4/5 10:43
 * @param voucher
 * @return com.star.redis.dzdp.pojo.BaseResult
 */
@PostMapping("/add")
public BaseResult add(@RequestBody Voucher voucher) {
    log.info("add {}", voucher.toString());
    voucherService.save(voucher);
    log.info("add success. id = {}", voucher.getId());
    return BaseResult.setOk("添加普通优惠券成功");
}
  • 3)功能测试

编写完成后,调用/voucher/add接口,新增一个普通优惠券:

控制台打印信息如下:

add Voucher(id=null, shopId=1, title=500元代金券, subTitle=周一至周日均可使用, rules=全场通用 无需预约 可无限叠加 不兑现、不找零 仅限堂食, payValue=45000, actualValue=50000, type=null, status=null, stock=null, beginTime=null, endTime=null, createTime=null, updateTime=null)
==>  Preparing: INSERT INTO tb_voucher ( shop_id, title, sub_title, rules, pay_value, actual_value ) VALUES ( ?, ?, ?, ?, ?, ? )
==> Parameters: 1(Long), 500元代金券(String), 周一至周日均可使用(String), 全场通用 无需预约 可无限叠加 不兑现、不找零 仅限堂食(String), 45000(Long), 50000(Long)
<==    Updates: 1
add success. id = 10
4.3.2.2 添加秒杀券代码
  • 1)接口文档
项目 说明
请求方法 POST
请求路径 /voucher/seckill/add
请求参数 Voucher
返回值
  • 2)代码实现

在VoucherController类中编写一个addSeckill()方法,调用IVoucherService接口的addSeckillVoucher()方法,用于新增秒杀优惠券:

// com.star.redis.dzdp.controller.VoucherController

/**
 * 添加秒杀优惠券
 * @author hsgx
 * @since 2024/4/5 11:00
 * @param voucher
 * @return com.star.redis.dzdp.pojo.BaseResult
 */
@PostMapping("/seckill/add")
public BaseResult addSeckill(@RequestBody Voucher voucher) {
    return voucherService.addSeckillVoucher(voucher);
}
// com.star.redis.dzdp.service.impl.VoucherServiceImpl

@Resource
private ISeckillVoucherService seckillVoucherService;

@Resource
private StringRedisTemplate stringRedisTemplate;

@Override
public BaseResult addSeckillVoucher(Voucher voucher) {
    log.info("add a seckill voucher, {}", voucher.toString());
    // 1.保存优惠券信息
    save(voucher);
    log.info("add voucher success. id = {}", voucher.getId());
    // 2.保存秒杀信息
    SeckillVoucher seckillVoucher = new SeckillVoucher();
    seckillVoucher.setVoucherId(voucher.getId());
    seckillVoucher.setStock(voucher.getStock());
    seckillVoucher.setBeginTime(voucher.getBeginTime());
    seckillVoucher.setEndTime(voucher.getEndTime());
    seckillVoucherService.save(seckillVoucher);
    // 3.将秒杀优惠券的库存保存到Redis
    String key = "seckill:stock:" + voucher.getId();
    stringRedisTemplate.opsForValue().set(key, voucher.getStock().toString());
    log.info("set to Redis : Key = {}, Value = {}", key, voucher.getStock().toString());
    return BaseResult.setOk("新增秒杀券成功!");
}
  • 3)功能测试

编写完成后,调用/voucher/seckill/add接口,新增一个秒杀优惠券:

控制台打印信息如下:

add a seckill voucher, Voucher(id=null, shopId=1, title=1000元代金券, subTitle=限时秒杀, rules=周一至周日均可使用, payValue=50000, actualValue=100000, type=null, status=null, stock=1000, beginTime=Fri Apr 05 14:00:00 CST 2024, endTime=Fri Apr 05 18:00:00 CST 2024, createTime=null, updateTime=null)
==>  Preparing: INSERT INTO tb_voucher ( shop_id, title, sub_title, rules, pay_value, actual_value ) VALUES ( ?, ?, ?, ?, ?, ? )
==> Parameters: 1(Long), 1000元代金券(String), 限时秒杀(String), 周一至周日均可使用(String), 50000(Long), 100000(Long)
<==    Updates: 1
add voucher success. id = 11
==>  Preparing: INSERT INTO tb_seckill_voucher ( voucher_id, stock, begin_time, end_time ) VALUES ( ?, ?, ?, ? )
==> Parameters: 11(Long), 1000(Integer), 2024-04-05 
<==    Updates: 1
set to Redis : Key = seckill:stock:11, Value = 1000

此时,在数据库的tb_voucher表有2条记录,tb_seckill_voucher有1条记录:

4.3.3 实现秒杀下单

4.3.3.1 秒杀下单逻辑分析

如上图所示,当用户下单时,会提交优惠券的ID,后台根据该ID查询对应的优惠券信息,并判断秒杀是否开始,如果未开始,则直接返回错误信息;如果已经开始,则再次判断库存是否充足,如果不充足,则直接返回错误信息;如果充足,则扣减库存,并创建订单,并返回订单ID。

4.3.3.2 获取秒杀订单ID

每个商户都可以发布订单ID,并且保存到tb_voucher_order表中,如果这个表的ID使用自增ID,则是存在一些问题的:ID的规律太明显,且受到单表数据量的限制。

如果ID具有太明显的规律,用户或者商业对手就很容易猜测出商户的一些敏感信息,比如商户在一天时间内卖出了多少单,这明显不合适。

同时,随着商户规模的扩大,需要存入数据库的数据量也在变大,而MySQL的单表容量不宜超过500W。数据量过大之后,**就要进行拆库拆表,拆分之后,从逻辑上讲它们仍然是同一张表,所以ID是也不能是一样的。

综上,需要保证订单ID的唯一性。本项目使用全局ID生成器来解决这个问题。 全局ID生成器是一种在分布式系统下用来生成全局唯一ID的工具,满足唯一性、高可用、高性能、递增型和安全性等。

为了增加ID的安全性,使用以下编码方式生成全局唯一ID:

  • 符号位:永远是0
  • 时间戳:31bit,以秒为单位,可以使用69年
  • 序列号:32bit,支持每秒产生2^32个不同的ID

下面就创建一个工具类RedisIdWorker,用于生成全局唯一ID:

// com.star.redis.dzdp.utils.RedisIdWorker

public class RedisIdWorker {

    /**
     * 获取全局唯一ID
     * @author hsgx
     * @since 2024/4/5 12:37
     * @param stringRedisTemplate
     * @param keyPrefix
     * @return long
     */
    public static long nextId(StringRedisTemplate stringRedisTemplate, String keyPrefix) {
        // 1.生成时间戳
        long nowSec = System.currentTimeMillis() / 1000;
        // 2.生成序列号
        long count = stringRedisTemplate.opsForValue().increment("icr:" + keyPrefix + ":" + new Date());
        // 3.拼接并返回
        return nowSec << 32 | count;
    }
}
4.3.3.3 获取用户ID

在tb_voucher_order表中,有一个user_id字段,该字段保存了下单用户的ID。下单用户即当前登录的用户,因此要想办法在业务层拿到当前登录用户的ID。

我们在登录拦截器中已经拿到了当前登录用户的信息,因此可以将这里拿到的信息继续向下游传递:

// com.star.redis.dzdp.interceptor.LoginInterceptor#preHandle()

// 5.存在,放行
// 将用户ID向下游传递
request.setAttribute("userId", user.getId());
return true;

如此,后续在Controller方法中,就可以使用request.getAttribute("userId")方法获取用户ID。

4.3.3.4 实现秒杀下单

为优惠券订单业务创建对应的IVoucherOrderService接口-VoucherOrderServiceImpl实现类-VoucherOrderMapper类。详见测试项目代码。

在IVoucherOrderService接口中定义一个seckillVoucher()方法,并在VoucherOrderServiceImpl实现类中具体实现,作用是秒杀下单。

// com.star.redis.dzdp.service.impl.VoucherOrderServiceImpl

@Resource
private ISeckillVoucherService seckillVoucherService;
@Resource
private IVoucherOrderService voucherOrderService;
@Resource
private StringRedisTemplate stringRedisTemplate;

@Override
public BaseResult<Long> seckillVoucher(Long voucherId, Long userId) {
    log.info("开始秒杀下单...voucherId = {}, userId = {}", voucherId, userId);
    // 1.查询秒杀优惠券信息
    SeckillVoucher seckillVoucher = seckillVoucherService.getById(voucherId);
    // 2.判断秒杀活动是否开启或结束
    if(seckillVoucher == null) {
        // 秒杀活动不存在
        return BaseResult.setFail("秒杀活动不存在!");
    } else if(seckillVoucher.getBeginTime().after(new Date())) {
        // 秒杀活动未开始
        log.info("beginTime = {}", seckillVoucher.getBeginTime());
        return BaseResult.setFail("秒杀尚未开始!");
    } else if(seckillVoucher.getEndTime().before(new Date())) {
        // 秒杀活动已结束
        log.info("endTime = {}", seckillVoucher.getEndTime());
        return BaseResult.setFail("秒杀尚已结束!");
    }
    log.info("{}", seckillVoucher.toString());
    // 3.判断库存是否充足
    if(seckillVoucher.getStock() < 1) {
        // 库存不足
        return BaseResult.setFail("库存不足,抢券失败!");
    }
    // 4.扣减库存
    boolean update = seckillVoucherService.update().setSql("stock = stock - 1")
            .eq("voucher_id", voucherId).update();
    log.info("update result = {}", update);
    if(!update) {
        // 扣减库存失败,返回抢券失败
        return BaseResult.setFail("库存不足,抢券失败!");
    }
    // 5.创建订单
    VoucherOrder voucherOrder = new VoucherOrder();
    // 5.1 设置订单ID
    Long orderId = RedisIdWorker.nextId(stringRedisTemplate, "voucher_order");
    log.info("get orderId = {}", orderId);
    voucherOrder.setId(orderId);
    // 5.2 设置用户ID
    voucherOrder.setUserId(userId);
    // 5.3 设置订单其他信息
    voucherOrder.setVoucherId(voucherId);
    voucherOrder.setPayTime(new Date());
    voucherOrderService.save(voucherOrder);
    // 6 返回订单ID
    return BaseResult.setOkWithData(orderId);
}

接着在VoucherOrderMapper类中编写一个seckillOrder()方法,作为秒杀下单的接口。

  • 1)接口文档
项目 说明
请求方法 POST
请求路径 /voucher/seckill/order
请求参数 Voucher
返回值 订单ID
  • 2)代码实现
/**
 * 秒杀下单
 * @author hsgx
 * @since 2024/4/5 12:41
 * @param voucherOrder
 * @return com.star.redis.dzdp.pojo.BaseResult
 */
@PostMapping("/seckill/order")
public BaseResult seckillOrder(@RequestBody VoucherOrder voucherOrder, HttpServletRequest request) {
    return voucherOrderService.seckillVoucher(voucherOrder.getVoucherId(), (Long)request.getAttribute("userId"));
}
  • 3)功能测试

编写完成后,调用/voucher/seckill/order接口,新增一个秒杀订单:

控制台打印信息如下:

开始秒杀下单...voucherId = 11, userId = 1012
==>  Preparing: SELECT voucher_id,stock,create_time,begin_time,end_time,update_time FROM tb_seckill_voucher WHERE voucher_id=?
==> Parameters: 11(Long)
<==      Total: 1
SeckillVoucher(voucherId=11, stock=999, createTime=Fri Apr 05 11:25:00 CST 2024, beginTime=Fri Apr 05 06:00:00 CST 2024, endTime=Fri Apr 05 18:00:00 CST 2024, updateTime=Fri Apr 05 12:56:15 CST 2024)
==>  Preparing: UPDATE tb_seckill_voucher SET stock = stock - 1 WHERE (voucher_id = ?)
==> Parameters: 11(Long)
<==    Updates: 1
update result = true
get orderId = 7354246043942256641
==>  Preparing: INSERT INTO tb_voucher_order ( id, user_id, voucher_id, pay_time ) VALUES ( ?, ?, ?, ? )
==> Parameters: 7354246043942256641(Long), 1012(Long), 11(Long), 2024-04-05 13:10:40.332(Timestamp)
<==    Updates: 1

至此,秒杀下单完成。

本节完,更多内容请查阅分类专栏:Redis从入门到精通

感兴趣的读者还可以查阅我的另外几个专栏:

  • SpringBoot源码解读与原理分析(已完结)
  • MyBatis3源码深度解析(已完结)
  • 再探Java为面试赋能(持续更新中…)
上一篇:Tuxera NTFS for Mac2023绿色免费版 免费的ntfs for mac 免费读写硬盘U盘工具-Tuxera NTFS for Mac2023绿色版安装包(一键安装):https://sourl.cn/mJu6Uf


下一篇:Vue学习笔记-S1