(以下內容全部來自上述課程)
優惠券秒殺
1. 全局唯一ID
每個店鋪都可以發布優惠券:
當用戶搶購時,就會生成訂單并保存到tb voucher order這張表中,而訂單表如果使用數據庫自增ID就存在一些問題:
- id的規律性太明顯
- 受單表數據量的限制
全局ID生成器
全局ID生成器,是一種在分布式系統下用來生成全局唯一ID的工具,一般要滿足下列特性:
為了增加ID的安全性,我們可以不直接使用Redis自增的數值,而是拼接一些其他信息:
ID的組成部分:
- 符號位:1bit,永遠為0
- 時間戳:31bit,以秒為單位,可以使用69年
- 序列號:32bit,秒內的計數器,支持每秒產生2^32個不同ID
2. Redis實現全局唯一ID
RedisIdWorker.java:
package com.hmdp.utils;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 {/*** 開始時間戳*/private static final long BEGIN_TIMESTAMP = 1640995200L;/*** 序列號的位數*/private static final int COUNT_BITS = 32;private StringRedisTemplate stringRedisTemplate;public RedisIdWorker(StringRedisTemplate stringRedisTemplate) {this.stringRedisTemplate = stringRedisTemplate;}public long nextId(String keyPrefix) {// 1.生成時間戳LocalDateTime now = LocalDateTime.now();long nowSecond = now.toEpochSecond(ZoneOffset.UTC);long timestamp = nowSecond - BEGIN_TIMESTAMP;// 2.生成序列號// 2.1.獲取當前日期,精確到天String date = now.format(DateTimeFormatter.ofPattern("yyyy:MM:dd"));// 2.2.自增長long count = stringRedisTemplate.opsForValue().increment("icr:" + keyPrefix + ":" + date);// 3.拼接并返回return timestamp << COUNT_BITS | count;}
}
3. 添加優惠券
每個店鋪都可以發布優惠券,分為平價券和特價券。平價券可以任意購買,而特價券需要秒殺搶購:
表關系如下:
- tb_voucher:優惠券的基本信息,優惠金額、使用規則等
- tb_seckill_voucher:優惠券的庫存、開始搶購時間,結束搶購時間。特價優惠券才需要填寫這些信息
在VoucherController中提供了一個接口,可以添加秒殺優惠券:
package com.hmdp.controller;import com.hmdp.dto.Result;
import com.hmdp.entity.Voucher;
import com.hmdp.service.IVoucherService;
import org.springframework.web.bind.annotation.*;import javax.annotation.Resource;/*** <p>* 前端控制器* </p>** @author 虎哥* @since 2021-12-22*/
@RestController
@RequestMapping("/voucher")
public class VoucherController {@Resourceprivate IVoucherService voucherService;/*** 新增普通券* @param voucher 優惠券信息* @return 優惠券id*/@PostMappingpublic Result addVoucher(@RequestBody Voucher voucher) {voucherService.save(voucher);return Result.ok(voucher.getId());}/*** 新增秒殺券* @param voucher 優惠券信息,包含秒殺信息* @return 優惠券id*/@PostMapping("seckill")public Result addSeckillVoucher(@RequestBody Voucher voucher) {voucherService.addSeckillVoucher(voucher);return Result.ok(voucher.getId());}/*** 查詢店鋪的優惠券列表* @param shopId 店鋪id* @return 優惠券列表*/@GetMapping("/list/{shopId}")public Result queryVoucherOfShop(@PathVariable("shopId") Long shopId) {return voucherService.queryVoucherOfShop(shopId);}
}
4. 實現秒殺
下單時需要判斷兩點:
- 秒殺是否開始或結束,如果尚未開始或已經結束則無法下單
- 庫存是否充足,不足則無法下單
VoucherOrderController.java:
package com.hmdp.controller;import com.hmdp.dto.Result;
import com.hmdp.service.IVoucherOrderService;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;import javax.annotation.Resource;/*** <p>* 前端控制器* </p>** @author 虎哥* @since 2021-12-22*/
@RestController
@RequestMapping("/voucher-order")
public class VoucherOrderController {@Resourceprivate IVoucherOrderService voucherOrderService;@PostMapping("seckill/{id}")public Result seckillVoucher(@PathVariable("id") Long voucherId) {return voucherOrderService.seckillVoucher(voucherId);}
}
IVoucherOrderService.java:
package com.hmdp.service;import com.baomidou.mybatisplus.extension.service.IService;
import com.hmdp.dto.Result;
import com.hmdp.entity.VoucherOrder;public interface IVoucherOrderService extends IService<VoucherOrder> {Result seckillVoucher(Long voucherId);
}
VoucherOrderServiceImpl.java:
package com.hmdp.service.impl;import com.baomidou.mybatisplus.extension.service.IService;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.hmdp.dto.Result;
import com.hmdp.entity.SeckillVoucher;
import com.hmdp.entity.VoucherOrder;
import com.hmdp.mapper.VoucherOrderMapper;
import com.hmdp.service.ISeckillVoucherService;
import com.hmdp.service.IVoucherOrderService;
import com.hmdp.utils.RedisIdWorker;
import com.hmdp.utils.UserHolder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;import javax.annotation.Resource;
import java.time.LocalDateTime;@Service
public class VoucherOrderServiceImpl extends ServiceImpl<VoucherOrderMapper, VoucherOrder> implements IVoucherOrderService {@Resourceprivate ISeckillVoucherService seckillVoucherService;@Resourceprivate RedisIdWorker redisIdWorker;@Override@Transactionalpublic Result seckillVoucher(Long voucherId) {//1. 查詢優惠券SeckillVoucher voucher = seckillVoucherService.getById(voucherId);//2. 判斷秒殺是否開始if (voucher.getBeginTime().isAfter(LocalDateTime.now())){//尚未開始return Result.fail("秒殺尚未開始");}//3. 判斷秒殺是否結束if (voucher.getEndTime().isBefore(LocalDateTime.now())){//已經結束return Result.fail("秒殺已經結束");}//4. 判斷庫存是否充足if (voucher.getStock() < 1){//庫存不足return Result.fail("庫存不足");}//5. 扣減庫存boolean success = seckillVoucherService.update().setSql("stock = stock - 1").eq("voucher_id",voucherId).update();if (!success){//扣減失敗return Result.fail("庫存不足");}//6. 創建訂單VoucherOrder voucherOrder = new VoucherOrder();//6.1 訂單idlong orderId = redisIdWorker.nextId("order");voucherOrder.setId(orderId);//6.2 用戶idLong userId = UserHolder.getUser().getId();voucherOrder.setUserId(userId);//6.3 代金券idvoucherOrder.setVoucherId(voucherId);save(voucherOrder);//7. 返回訂單idreturn Result.ok(orderId);}}
5. 庫存超賣問題分析
超賣問題是典型的多線程安全問題,針對這一問題的常見解決方案就是加鎖:
悲觀鎖:添加同步鎖,讓線程串行執行
- 優點:簡單粗暴
- 缺點:性能一般
樂觀鎖:不加鎖,在更新時判斷是否有其他線程在修改
- 優點:性能好
- 缺點:存在成功率低的問題
樂觀鎖
樂觀鎖的關鍵是判斷之前查詢得到的數據是否有被修改過,常見的方式有兩種:
- 版本號法
- CAS法(用庫存代替版本)
6. 樂觀鎖解決超賣問題
//5. 扣減庫存boolean success = seckillVoucherService.update().setSql("stock = stock - 1") //set stock = stock - 1 .eq("voucher_id",voucherId).gt("stock",0) //where id = ? and stock > 0 .update();if (!success){//扣減失敗return Result.fail("庫存不足");}
7. 實現一人一單
需求:修改秒殺業務,要求同一個優惠券,一個用戶只能下一單
目前完整代碼:VoucherOrderServiceImpl.java:
package com.hmdp.service.impl;import com.baomidou.mybatisplus.extension.service.IService;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.hmdp.dto.Result;
import com.hmdp.entity.SeckillVoucher;
import com.hmdp.entity.VoucherOrder;
import com.hmdp.mapper.VoucherOrderMapper;
import com.hmdp.service.ISeckillVoucherService;
import com.hmdp.service.IVoucherOrderService;
import com.hmdp.utils.RedisIdWorker;
import com.hmdp.utils.UserHolder;
import lombok.val;
import org.springframework.aop.framework.AopContext;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;import javax.annotation.Resource;
import java.time.LocalDateTime;@Service
public class VoucherOrderServiceImpl extends ServiceImpl<VoucherOrderMapper, VoucherOrder> implements IVoucherOrderService {@Resourceprivate ISeckillVoucherService seckillVoucherService;@Resourceprivate RedisIdWorker redisIdWorker;@Overridepublic Result seckillVoucher(Long voucherId) {//1. 查詢優惠券SeckillVoucher voucher = seckillVoucherService.getById(voucherId);//2. 判斷秒殺是否開始if (voucher.getBeginTime().isAfter(LocalDateTime.now())){//尚未開始return Result.fail("秒殺尚未開始");}//3. 判斷秒殺是否結束if (voucher.getEndTime().isBefore(LocalDateTime.now())){//已經結束return Result.fail("秒殺已經結束");}//4. 判斷庫存是否充足if (voucher.getStock() < 1){//庫存不足return Result.fail("庫存不足");}Long userId = UserHolder.getUser().getId();synchronized (userId.toString().intern()) {//獲取代理對象(事務)IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();return proxy.createVoucherOrder(voucherId);}}@Transactionalpublic synchronized Result createVoucherOrder(Long voucherId) {//5. 一人一單Long userId = UserHolder.getUser().getId();//5.1 查詢訂單int count = query().eq("user_id", userId).eq("voucher_id", voucherId).count();//5.2 判斷是否存在if (count > 0) {//用戶已經購買過了return Result.fail("用戶已經購買過一次!");}//6. 扣減庫存boolean success = seckillVoucherService.update().setSql("stock = stock - 1") //set stock = stock - 1.eq("voucher_id", voucherId).gt("stock", 0) //where id = ? and stock > 0.update();if (!success) {//扣減失敗return Result.fail("庫存不足");}//7. 創建訂單VoucherOrder voucherOrder = new VoucherOrder();//7.1 訂單idlong orderId = redisIdWorker.nextId("order");voucherOrder.setId(orderId);//7.2 用戶idvoucherOrder.setUserId(userId);//7.3 代金券idvoucherOrder.setVoucherId(voucherId);save(voucherOrder);//8. 返回訂單idreturn Result.ok(orderId);}
}
8. 集群下的線程并發安全問題
一人一單的并發安全問題
通過加鎖可以解決在單機情況下的一人一單安全問題,但是在集群模式下就不行了。
- 我們將服務啟動兩份,端口分別為8081和8082:
- 然后修改nqinx的conf目錄下的nginx.conf文件,配置反向代理和負載均衡:
現在,用戶請求會在這兩個節點上負載均衡,再次測試下是否存在線程安全問題。