Redis優(yōu)惠券秒殺企業(yè)實戰(zhàn)
一、全局唯一ID
1. 全局ID生成器
每個店鋪都可以發(fā)布優(yōu)惠券:

當用戶搶購時,就會生成訂單并保存到tb_voucher_order這張表中,而訂單表如果使用數(shù)據(jù)庫自增ID就存在一些問題:
- id的規(guī)律性太明顯
- 受單表數(shù)據(jù)量的限制
所以tb_voucher_order表的主鍵不能用自增ID:
create table tb_voucher_order
(
id bigint not null comment '主鍵'
primary key,
user_id bigint unsigned not null comment '下單的用戶id',
voucher_id bigint unsigned not null comment '購買的代金券id',
pay_type tinyint(1) unsigned default 1 not null comment '支付方式 1:余額支付;2:支付寶;3:微信',
status tinyint(1) unsigned default 1 not null comment '訂單狀態(tài),1:未支付;2:已支付;3:已核銷;4:已取消;5:退款中;6:已退款',
create_time timestamp default CURRENT_TIMESTAMP not null comment '下單時間',
pay_time timestamp null comment '支付時間',
use_time timestamp null comment '核銷時間',
refund_time timestamp null comment '退款時間',
update_time timestamp default CURRENT_TIMESTAMP not null on update CURRENT_TIMESTAMP comment '更新時間'
);
全局ID生成器,是一種在分布式系統(tǒng)下用來生成全局唯一ID的工具,一般要滿足下列特性:

為了增加ID的安全性,我們可以不直接使用Redis自增的數(shù)值,而是拼接一些其它信息:

D的組成部分:
- 符號位:1bit,永遠為0,表示正數(shù)
- 時間戳:31bit,以秒為單位,可以使用69年
- 序列號:32bit,秒內(nèi)的計數(shù)器,支持每秒產(chǎn)生2^32個不同ID
編寫全局ID生成器代碼:
@Component
public class RedisIdWorker {
/**
* 開始時間戳,以2022.1.1為基準計算時間差
*/
private static final long BEGIN_TIMESTAMP = 1640995200L;
/**
* 序列號的位數(shù)
*/
private static final int COUNT_BITS = 32;
private StringRedisTemplate stringRedisTemplate;
public RedisIdWorker(StringRedisTemplate stringRedisTemplate) {
this.stringRedisTemplate = stringRedisTemplate;
}
/**
* 生成帶有業(yè)務(wù)前綴的redis自增id
* @param keyPrefix
* @return
*/
public long nextId(String keyPrefix) {
// 1.生成時間戳
LocalDateTime now = LocalDateTime.now();
long nowSecond = now.toEpochSecond(ZoneOffset.UTC);
long timestamp = nowSecond - BEGIN_TIMESTAMP;
// 2.生成序列號
// 2.1.獲取當前日期,精確到天
// 加上日期前綴,可以讓存更多同一業(yè)務(wù)類型的數(shù)據(jù),并且還能通過日期獲取當天的業(yè)務(wù)數(shù)量,一舉兩得
String date = now.format(DateTimeFormatter.ofPattern("yyyy:MM:dd"));
// 2.2.自增長
long count = stringRedisTemplate.opsForValue().increment("icr:" + keyPrefix + ":" + date);
// 3.拼接并返回
// 用于是數(shù)字類型的拼接,所以不能像拼接字符串那樣處理,而是通過位運算將高32位存 符號位+時間戳,低32位存 序列號
return timestamp << COUNT_BITS | count;
}
public static void main(String[] args) {
LocalDateTime time = LocalDateTime.of(2022, 1, 1, 0, 0, 0);
long second = time.toEpochSecond(ZoneOffset.UTC);
System.out.println(second);// 1640995200
}
}
測試全局ID生成器:
@SpringBootTest
class HmDianPingApplicationTests {
@Resource
private RedisIdWorker redisIdWorker;
private ExecutorService executorService = Executors.newFixedThreadPool(500);
@Test
void testIdWorker() throws InterruptedException {
CountDownLatch latch = new CountDownLatch(300);
// 每個線程生成100個id
Runnable task = () -> {
for (int i = 0; i < 100; i++) {
long id = redisIdWorker.nextId("order");
System.out.println("id = " + id);
}
latch.countDown();
};
// 300個線程
long begin = System.currentTimeMillis();
for (int i = 0; i < 300; i++) {
executorService.submit(task);
}
latch.await();
long end = System.currentTimeMillis();
System.out.println("time = " + (end - begin));
}
}
測試結(jié)果:


2. 全局唯一ID生成策略
- UUID(不是遞增的)
- Redis自增
- 雪花算法(snowflake)
- 數(shù)據(jù)庫自增(單獨建一張表存自增id,分配到分庫分表后的表中)
3. Redis自增ID策略
- 以日期作為前綴的key,方便統(tǒng)計訂單量
- 自增ID的結(jié)構(gòu):時間戳 + 計數(shù)器
二、實現(xiàn)優(yōu)惠券秒殺下單
1. 添加優(yōu)惠券
每個店鋪都可以發(fā)布優(yōu)惠券,分為平價券和特價券。平價券可以任意購買,而特價券需要秒殺搶購:

優(yōu)惠券表信息:
- tb_voucher:優(yōu)惠券的基本信息,優(yōu)惠金額、使用規(guī)則等(tb_voucher表的type字段區(qū)分是普通券還是秒殺券)
- tb_seckill_voucher:優(yōu)惠券的庫存、開始搶購時間,結(jié)束搶購時間(秒殺券才需要填寫這些信息),同時秒殺券擁有普通券的基本信息(秒殺券表tb_seckill_voucher的主鍵id綁定的是普通券表tb_voucher的id)
create table tb_voucher
(
id bigint unsigned auto_increment comment '主鍵'
primary key,
shop_id bigint unsigned null comment '商鋪id',
title varchar(255) not null comment '代金券標題',
sub_title varchar(255) null comment '副標題',
rules varchar(1024) null comment '使用規(guī)則',
pay_value bigint(10) unsigned not null comment '支付金額,單位是分。例如200代表2元',
actual_value bigint(10) not null comment '抵扣金額,單位是分。例如200代表2元',
type tinyint(1) unsigned default 0 not null comment '0,普通券;1,秒殺券',
status tinyint(1) unsigned default 1 not null comment '1,上架; 2,下架; 3,過期',
create_time timestamp default CURRENT_TIMESTAMP not null comment '創(chuàng)建時間',
update_time timestamp default CURRENT_TIMESTAMP not null on update CURRENT_TIMESTAMP comment '更新時間'
);
create table tb_seckill_voucher
(
voucher_id bigint unsigned not null comment '關(guān)聯(lián)的優(yōu)惠券的id'
primary key,
stock int(8) not null comment '庫存',
create_time timestamp default CURRENT_TIMESTAMP not null comment '創(chuàng)建時間',
begin_time timestamp default CURRENT_TIMESTAMP not null comment '生效時間',
end_time timestamp default CURRENT_TIMESTAMP not null comment '失效時間',
update_time timestamp default CURRENT_TIMESTAMP not null on update CURRENT_TIMESTAMP comment '更新時間'
)
comment '秒殺優(yōu)惠券表,與優(yōu)惠券是一對一關(guān)系';
2. 編寫添加秒殺券的接口
主要代碼:
@RestController
@RequestMapping("/voucher")
public class VoucherController {
@Resource
private IVoucherService voucherService;
/**
* 新增秒殺券
* @param voucher 優(yōu)惠券信息,包含秒殺信息
* @return 優(yōu)惠券id
*/
@PostMapping("seckill")
public Result addSeckillVoucher(@RequestBody Voucher voucher) {
voucherService.addSeckillVoucher(voucher);
return Result.ok(voucher.getId());
}
}
@Service
public class VoucherServiceImpl extends ServiceImpl<VoucherMapper, Voucher> implements IVoucherService {
@Resource
private ISeckillVoucherService seckillVoucherService;
@Override
@Transactional
public void addSeckillVoucher(Voucher voucher) {
// 保存優(yōu)惠券
save(voucher);
// 保存秒殺信息
SeckillVoucher seckillVoucher = new SeckillVoucher();
seckillVoucher.setVoucherId(voucher.getId());
seckillVoucher.setStock(voucher.getStock());
seckillVoucher.setBeginTime(voucher.getBeginTime());
seckillVoucher.setEndTime(voucher.getEndTime());
seckillVoucherService.save(seckillVoucher);
}
}
測試添加:

測試結(jié)果:

三、實現(xiàn)秒殺下單

下單時需要判斷兩點:
- 秒殺是否開始或結(jié)束,如果尚未開始或已經(jīng)結(jié)束則無法下單
- 庫存是否充足,不足則無法下單

主要代碼:
@RestController
@RequestMapping("/voucher-order")
public class VoucherOrderController {
@Resource
private IVoucherOrderService voucherOrderService;
@PostMapping("seckill/{id}")
public Result seckillVoucher(@PathVariable("id") Long voucherId) {
return voucherOrderService.seckillVoucher(voucherId);
}
}
@Service
public class VoucherOrderServiceImpl extends ServiceImpl<VoucherOrderMapper, VoucherOrder> implements IVoucherOrderService {
@Resource
private ISeckillVoucherService seckillVoucherService;
@Resource
private RedisIdWorker redisIdWorker;
@Override
public Result seckillVoucher(Long voucherId) {
// 1.查詢優(yōu)惠券
SeckillVoucher voucher = seckillVoucherService.getById(voucherId);
// 2.判斷秒殺是否開始
if (voucher.getBeginTime().isAfter(LocalDateTime.now())) {
// 尚未開始
return Result.fail("秒殺尚未開始!");
}
// 3.判斷秒殺是否已經(jīng)結(jié)束
if (voucher.getEndTime().isBefore(LocalDateTime.now())) {
// 尚未開始
return Result.fail("秒殺已經(jīng)結(jié)束!");
}
// 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.創(chuàng)建訂單
VoucherOrder voucherOrder = new VoucherOrder();
// 6.1.訂單id
long orderId = redisIdWorker.nextId("order");
voucherOrder.setId(orderId);
// 6.2.用戶id
Long userId = UserHolder.getUser().getId();
voucherOrder.setUserId(userId);
// 6.3.代金券id
voucherOrder.setVoucherId(voucherId);
save(voucherOrder);
return Result.ok(orderId);
}
}
簡單測試秒殺成功:

扣減庫存成功:

四、超賣問題
當有大量請求同時訪問時,就會出現(xiàn)超賣問題


超賣問題是典型的多線程安全問題,針對這一問題的常見解決方案就是加鎖:

1. 加鎖方式 - 樂觀鎖
樂觀鎖的關(guān)鍵是判斷之前查詢得到的數(shù)據(jù)是否有被修改過,常見的方式有兩種:
(1)版本號法

(2)CAS法
- 用庫存代替了版本號,可以少加一個字段
- 扣庫存時,與查詢時的庫存比較,沒被修改則可以扣減庫存

2. 樂觀鎖解決超賣問題
樂觀鎖方式,通過CAS判斷前后庫存是否一致,解決超賣問題:
// 之前的代碼
boolean success = seckillVoucherService.update()
.setSql("stock= stock -1")
.eq("voucher_id", voucherId).update();
// 樂觀鎖方式,通過CAS判斷前后庫存是否一致,解決超賣問題
boolean success = seckillVoucherService.update()
.setSql("stock= stock -1") // set stock = stock -1
.eq("voucher_id", voucherId).eq("stock",voucher.getStock()).update(); // where id = ? and stock = ?
又出現(xiàn)新的問題:
- 假設(shè)100個線程同時請求,但通過CAS判斷后,只有一個線程能扣減庫存成功,其余99個線程全部失敗
- 此時,庫存剩余99,但是實際業(yè)務(wù)可以滿足其余99個線程扣減庫存
- 雖然能解決超賣問題,但是設(shè)計不合理
所以為了解決失敗率高的問題,需要進一步改進:
通過CAS 不再 判斷前后庫存是否一致,而是判斷庫存是否大于0
boolean success = seckillVoucherService.update()
.setSql("stock= stock -1")
.eq("voucher_id", voucherId).gt("stock",0).update(); // where id = ? and stock > 0
3. 小結(jié)
超賣這樣的線程安全問題,解決方案有哪些?
(1)悲觀鎖:添加同步鎖,讓線程串行執(zhí)行
- 優(yōu)點:簡單粗暴
- 缺點:性能一般
(2)樂觀鎖:不加鎖,在更新時判斷是否有其它線程在修改
- 優(yōu)點:性能相對悲觀鎖好(但是仍然需要同時查數(shù)據(jù)庫,影響性能)
- 缺點:存在成功率低的問題(可以采用分段鎖方式提高成功率)
五、一人一單問題
需求:修改秒殺業(yè)務(wù),要求同一個優(yōu)惠券,一個用戶只能下一單

在扣減庫存之前,加上一人一單的邏輯:
// 5.一人一單邏輯
Long userId = UserHolder.getUser().getId();
// 5.1.查詢訂單數(shù)量
int count = query().eq("user_id", userId).eq("voucher_id", voucherId).count();
// 5.2.判斷是否下過單
if (count > 0) {
// 用戶已經(jīng)購買過了
return Result.fail("用戶已經(jīng)購買過一次!");
}
此處仍會出現(xiàn)并發(fā)問題,當同一用戶模擬大量請求同時查詢是否下過單時,如果正好都查詢出count為0,就會跳過判斷繼續(xù)執(zhí)行扣減庫存的邏輯,此時就會出現(xiàn)一人下多單的問題
解決方法:
- 由于是判斷查詢的數(shù)據(jù)是否存在,而不是像之前判斷查詢的數(shù)據(jù)是否修改過
- 所以這里只能加悲觀鎖
1. 加鎖分析
- 首先將一人一單之后的邏輯全部加鎖,所以將一人一單之后的邏輯抽取出一個方法進行加鎖,public Result createVoucherOrder(Long voucherId)
- 如果直接在方法上加鎖,則鎖的是this對象,鎖的對象粒度過大,就算是不同的人執(zhí)行都會阻塞住,影響性能,public synchronized Result createVoucherOrder(Long voucherId)
- 所以將鎖的對象改為userId,但是不能直接使用synchronized (userId),因為每次執(zhí)行Long userId = UserHolder.getUser().getId();雖然值一樣,但是對象不同,因此需要這樣加鎖 synchronized (userId.toString().intern()),intern()表示每次從字符串常量池中獲取,這樣值相同時,對象也相同
- 為了防止事務(wù)還沒提交就釋放鎖的問題,則不能將鎖加在createVoucherOrder方法內(nèi)部,例如:
@Transactional
public Result createVoucherOrder(Long voucherId) {
synchronized (userId.toString().intern()) {
。。。
}
}
而是需要等事務(wù)提交完再釋放鎖,例如:
synchronized (userId.toString().intern()) {
// 獲取代理對象(事務(wù))
IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
return proxy.createVoucherOrder(voucherId);
}
2. 事務(wù)分析
由于只有一人一單之后的邏輯涉及到修改數(shù)據(jù)庫,所以只需對該方法加事務(wù)
@Transactional
public Result createVoucherOrder(Long voucherId)
由于只對createVoucherOrder方法加了事務(wù),而該方法是在seckillVoucher方法中被調(diào)用,seckillVoucher方法又沒有加事務(wù),為了防止事務(wù)失效,則不能直接在seckillVoucher方法調(diào)用createVoucherOrder方法,例如:
@Override
public Result seckillVoucher(Long voucherId) {
。。。。
synchronized (userId.toString().intern()) {
return this.createVoucherOrder(voucherId);
}
}
而是需要通過代理對象調(diào)用createVoucherOrder方法,因為@Transactional事務(wù)注解的原理是通過獲取代理對象執(zhí)行目標對象的方法,進行AOP操作,所以需要這樣:
@Override
public Result seckillVoucher(Long voucherId) {
。。。。
// 獲取代理對象(事務(wù))
IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
return proxy.createVoucherOrder(voucherId);
}
并且還要引入依賴:
<dependency>
<groupId>org.aspectj</groupId>
<artifactId>aspectjweaver</artifactId>
</dependency>
還要開啟注解暴露出代理對象:
@EnableAspectJAutoProxy(exposeProxy = true)
@MapperScan("com.hmdp.mapper")
@SpringBootApplication
public class HmDianPingApplication {
public static void main(String[] args) {
SpringApplication.run(HmDianPingApplication.class, args);
}
}
完整VoucherOrderServiceImpl代碼:
@Service
public class VoucherOrderServiceImpl extends ServiceImpl<VoucherOrderMapper, VoucherOrder> implements IVoucherOrderService {
@Resource
private ISeckillVoucherService seckillVoucherService;
@Resource
private RedisIdWorker redisIdWorker;
@Override
public Result seckillVoucher(Long voucherId) {
// 1.查詢優(yōu)惠券
SeckillVoucher voucher = seckillVoucherService.getById(voucherId);
// 2.判斷秒殺是否開始
if (voucher.getBeginTime().isAfter(LocalDateTime.now())) {
// 尚未開始
return Result.fail("秒殺尚未開始!");
}
// 3.判斷秒殺是否已經(jīng)結(jié)束
if (voucher.getEndTime().isBefore(LocalDateTime.now())) {
// 尚未開始
return Result.fail("秒殺已經(jīng)結(jié)束!");
}
// 4.判斷庫存是否充足
if (voucher.getStock() < 1) {
// 庫存不足
return Result.fail("庫存不足!");
}
Long userId = UserHolder.getUser().getId();
synchronized (userId.toString().intern()) {
// 獲取代理對象(事務(wù))
IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
return proxy.createVoucherOrder(voucherId);
}
}
@Transactional
public Result createVoucherOrder(Long voucherId) {
// 5.一人一單邏輯
Long userId = UserHolder.getUser().getId();
// 5.1.查詢訂單數(shù)量
int count = query().eq("user_id", userId).eq("voucher_id", voucherId).count();
// 5.2.判斷是否下過單
if (count > 0) {
// 用戶已經(jīng)購買過了
return Result.fail("用戶已經(jīng)購買過一次!");
}
// 6,扣減庫存
// 樂觀鎖方式,通過CAS判斷庫存是否大于0,解決超賣問題:
boolean success = seckillVoucherService.update()
.setSql("stock= stock -1")
.eq("voucher_id", voucherId).gt("stock",0).update(); // where id = ? and stock > 0
if (!success) {
// 扣減庫存失敗
return Result.fail("庫存不足!");
}
// 7.創(chuàng)建訂單
VoucherOrder voucherOrder = new VoucherOrder();
// 7.1.訂單id
long orderId = redisIdWorker.nextId("order");
voucherOrder.setId(orderId);
// 7.2.用戶id
voucherOrder.setUserId(userId);
// 7.3.代金券id
voucherOrder.setVoucherId(voucherId);
save(voucherOrder);
// 8.返回訂單id
return Result.ok(orderId);
}
}
六、集群模式下并發(fā)安全問題
通過加鎖可以解決在單機情況下的一人一單安全問題,但是在集群模式下就不行了。
我們將服務(wù)啟動兩份,端口分別為8081和8082:

然后修改nginx的conf目錄下的nginx.conf文件,配置反向代理和負載均衡:

修改完后,重新加載nginx配置文件:

現(xiàn)在,用戶請求會在這兩個節(jié)點上負載均衡,再次測試下是否存在線程安全問題:
訪問8081端口的線程進入了synchronized中

訪問8082端口的線程也進入了synchronized中

最終同一個用戶下了2單扣了2個庫存,所以在集群模式下,出現(xiàn)了一人多單的問題:

分析:
- 鎖的原理是每個JVM中都有一個Monitor作為鎖對象,所以當對象相同時,獲取的就是同一把鎖
- 但是不同的JVM中的Monitor不同,所以獲取的不是同一把鎖
- 因此集群模式下,加synchronized鎖也會出現(xiàn)并發(fā)安全問題,需要加分布式鎖

到此這篇關(guān)于Redis優(yōu)惠券秒殺企業(yè)實戰(zhàn)的文章就介紹到這了,更多相關(guān)Redis 優(yōu)惠券秒殺 內(nèi)容請搜索腳本之家以前的文章或繼續(xù)瀏覽下面的相關(guān)文章希望大家以后多多支持腳本之家!
相關(guān)文章
ELK配置轉(zhuǎn)存redis緩存采集nginx訪問日志的操作方法
本文介紹了在服務(wù)器上部署MySQL及如何啟動MySQL服務(wù),并詳細說明了如何查找安裝軟件的日志文件位置,通過使用rpm命令查詢MySQL服務(wù)的日志文件位置,以及通過編輯Logstash配置文件來添加MySQL日志信息,感興趣的朋友一起看看吧2024-11-11

