优惠卷秒杀实现与优化
在电商系统中,优惠卷秒杀是一个关键功能,它涉及到全局 ID 生成、优惠卷创建、秒杀下单以及库存控制等多个方面。本文将深入探讨这些内容,并对相关问题的解决方案进行详细分析。
全局ID生成器
在系统中,自增的 ID 存在一些问题,如 ID 规律太明显、受单表数据量限制等。因此,需要一个全局 ID 生成器来解决这些问题。
全局 ID 生成器的设计包含符号位、时间戳和序列号三个部分。
- 符号位为 0,
- 时间戳为 31bit,以秒为单位,可以使用 69 年,
- 序列号为 32bit,是秒内的计数器,支持每秒产生 2^32 个不同 ID。
获取全局ID工具类:
@Component
public class RedisIdWorker {
/**
* 开始时间戳
*/
public static final long BEGIN_TIMESTAMP = 1640995200L;
/**
* 序列号的位数
*/
public static final int COUNT_BITS = 32;
private StringRedisTemplate stringRedisTemplate;
public RedisIdWorker(StringRedisTemplate stringRedisTemplate) {
this.stringRedisTemplate = stringRedisTemplate;
}
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 = stringRedisTemplate.opsForValue().increment("icr:" + keyPrefix + ":" + date);
return (timestamp << COUNT_BITS) | count;
}
}
为了测试全局 ID 生成器是否有效,进行了如下测试:
@Resource
private RedisIdWorker redisIdWorker;
private ExecutorService es = Executors.newFixedThreadPool(500);
@Test
void testIdWorker() throws Exception {
CountDownLatch countDownLatch = new CountDownLatch(300);
Runnable task = () -> {
for (int i = 0; i < 100; i++) {
long id = redisIdWorker.nextId("order");
System.out.println("id = " + id);
}
countDownLatch.countDown();
};
long begin = System.currentTimeMillis();
for (int i = 0; i < 300; i++) {
es.submit(task);
}
countDownLatch.await();
long end = System.currentTimeMillis();
System.out.println("(end - begin) = " + (end - begin));
}
优惠卷设计
优惠卷相关的表结构包括tb_voucher
(优惠卷的基本信息)、tb_voucher_order
(优惠卷订单信息)和tb_seckill_voucher
(秒杀卷信息)。
tb_voucher
优惠卷的基本信息
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 '使用规则',
pay_value bigint unsigned not null comment '支付金额,单位是分。例如200代表2元',
actual_value bigint not null comment '抵扣金额,单位是分。例如200代表2元',
type tinyint unsigned default '0' not null comment '0,普通券;1,秒杀券',
status tinyint unsigned default '1' not null comment '1,上架; 2,下架; 3,过期',
create_time timestamp default CURRENT_TIMESTAMP not null comment '创建时间',
update_time timestamp default CURRENT_TIMESTAMP not null on update CURRENT_TIMESTAMP comment '更新时间'
)
collate = utf8mb4_general_ci
row_format = COMPACT;
tb_voucher_order
(优惠卷订单信息)
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 unsigned default '1' not null comment '支付方式 1:余额支付;2:支付宝;3:微信',
status tinyint unsigned default '1' not null comment '订单状态,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 '更新时间'
)
collate = utf8mb4_general_ci
row_format = COMPACT;
tb_seckill_voucher
(秒杀卷信息)
create table tb_seckill_voucher
(
voucher_id bigint unsigned not null comment '关联的优惠券的id'
primary key,
stock int not null comment '库存',
create_time timestamp default CURRENT_TIMESTAMP not null comment '创建时间',
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 '秒杀优惠券表,与优惠券是一对一关系' collate = utf8mb4_general_ci
row_format = COMPACT;
秒杀下单
流程图如下:
代码实现:
@Resource
private ISeckillVoucherService seckillVoucherService;
@Resource
private RedisIdWorker redisIdWorker;
@Override
@Transactional
public 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 update = seckillVoucherService.update()
.setSql("stock=stock - 1")
.eq("voucher_id", voucherId)
.update();
if (!update) {
return Result.fail("库存不足");
}
// 6. 生成订单
VoucherOrder voucherOrder = new VoucherOrder();
// 订单id
long orderId = redisIdWorker.nextId("order");
// 用户id
Long userId = UserHolder.getUser().getId();
// 优惠券id
voucherOrder.setVoucherId(voucherId);
voucherOrder.setUserId(userId);
voucherOrder.setId(orderId);
this.save(voucherOrder);
// 7. 返回订单id
return Result.ok(orderId);
}
库存超卖问题
在秒杀场景中,多个线程同时扣减库存会导致库存超卖问题,这是一个典型的多线程安全问题。
针对这一问题,常见的解决方案是加锁,包括悲观锁、乐观锁和 CAS(Compare-and-Swap)自旋锁。
悲观锁(Pessimistic Locking)、乐观锁(Optimistic Locking)、CAS(Compare-and-Swap)是并发控制机制,用于处理多个线程或进程同时访问共享资源的情况。它们的作用是确保数据的一致性和避免竞态条件(race conditions)。
(一)悲观锁
悲观锁的核心思想是在访问共享资源之前,先获取锁来阻止其他线程的访问。当一个线程获取了悲观锁,其他线程必须等待,直到锁被释放。常见的实现方式包括数据库中的行级锁或表级锁,以及编程中的互斥锁(Mutex)。悲观锁通常会导致并发性能较差,因为它阻止了多个线程同时访问资源,可能会导致性能瓶颈。
(二)乐观锁
乐观锁的核心思想是假定在大多数情况下,共享资源的访问是不会发生冲突的。线程在读取数据时不会加锁,但在更新数据时会检查数据的版本号或标记。如果在更新时发现数据已经被其他线程修改,就会放弃本次更新,或者进行冲突解决操作。乐观锁通常用于减小锁的争用,提高并发性能。
乐观锁的版本号法是一种常见的实现方式,通过在数据中添加版本号来判断数据是否被修改。
(三)CAS 自旋锁
CAS 是 一种乐观锁的实现方式,它是一种原子操作,通常由硬件提供支持。CAS 操作包括三个参数:要更新的内存位置、预期值和新值。CAS 操作会比较内存位置的当前值和预期值,如果相符,则将新值写入内存位置;否则,操作失败。CAS 可用于实现乐观锁,通过原子比较和更新来确保在多线程环境下数据的一致性。
自旋锁的实现:
var5
是操作前读取的内存值,while
中的var1+var2
是预估值,如果预估值 ==
内存值,则代表中间没有被人修改过,此时就将新值去替换 内存值
int var5;
do {
var5 = this.getIntVolatile(var1, var2);
} while(!this.compareAndSwapInt(var1, var2, var5, var5 + var4));
return var5;
用库存代替版本号,就实现了CAS自旋锁。 代码如下:
//5.扣减库存
boolean update = seckillVoucherService.update()
.setSql("stock=stock-1")
.eq("voucher_id", voucherId).eq("stock", voucher.getStock())
.update();
核心含义:只要我扣减库存时的库存和之前我查询到的库存是一样的,就意味着没有人在中间修改过库存,那么此时就是安全的。
然而,通过测试发现这种方式会有很多失败的情况。失败的原因在于:在使用乐观锁过程中,假设 100 个线程同时都拿到了 100 的库存,然后大家一起去进行扣减,但是 100 个人中只有 1 个人能扣减成功,其他的人在处理时,库存已经被修改过了,所以此时其他线程都会失败。 修改:
boolean update = seckillVoucherService.update()
.setSql("stock=stock-1")
.eq("voucher_id", voucherId).gt("stock", 0)
.update();
一人一单
为了实现一个人只能抢购一个优惠卷的需求,在代码中进行了如下处理:
// 一人一单
Long userId = UserHolder.getUser().getId();
int count = this.query().eq("user_id", userId)
.eq("voucher_id", voucherId).count();
if (count > 0) {
return Result.fail("您已经抢购过了");
}
但是这样还是会存在一个人买多个优惠券的情况。乐观锁适合用在更新数据的情况,而这里是插入数据,所以考虑使用悲观锁。
@Transactional()
public synchronized Result createVoucherOrder(Long voucherId) {
// 一人一单
Long userId = UserHolder.getUser().getId();
int count = this.query().eq("user_id", userId)
.eq("voucher_id", voucherId).count();
if (count > 0) {
return Result.fail("您已经抢购过了");
}
// 5. 扣减库存
boolean update = seckillVoucherService.update()
.setSql("stock=stock - 1")
.eq("voucher_id", voucherId).gt("stock", 0)
.update();
if (!update) {
return Result.fail("库存不足");
}
// 6. 生成订单
VoucherOrder voucherOrder = new VoucherOrder();
// 订单id
long orderId = redisIdWorker.nextId("order");
// 优惠券id
voucherOrder.setVoucherId(voucherId);
voucherOrder.setUserId(userId);
voucherOrder.setId(orderId);
this.save(voucherOrder);
// 7. 返回订单id
return Result.ok(orderId);
}
但是,如果按照上面这种方式加锁,锁的力度太大了,因为会导致每个线程进来都会锁住,所以需要控制锁的力度。
修改后的代码如下:
@Transactional
public Result createVoucherOrder(Long voucherId) {
// 一人一单
Long userId = UserHolder.getUser().getId();
synchronized (userId.toString().intern()) {
int count = this.query().eq("user_id", userId)
.eq("voucher_id", voucherId).count();
if (count > 0) {
return Result.fail("您已经抢购过了");
}
// 5. 扣减库存
boolean update = seckillVoucherService.update()
.setSql("stock=stock - 1")
.eq("voucher_id", voucherId).gt("stock", 0)
.update();
if (!update) {
return Result.fail("库存不足");
}
// 6. 生成订单
VoucherOrder voucherOrder = new VoucherOrder();
// 订单id
long orderId = redisIdWorker.nextId("order");
// 优惠券id
voucherOrder.setVoucherId(voucherId);
voucherOrder.setUserId(userId);
voucherOrder.setId(orderId);
this.save(voucherOrder);
// 7. 返回订单id
return Result.ok(orderId);
}
}
intern()
这个方法是从常量池中拿到数据,如果我们直接使用userId.toString()
他拿到的对象实际上是不同的对象,new
来的对象,我们使用锁必须保证锁必须是同一把,所以我们需要使用intern()
方法
以上代码还是存在问题,问题的原因在于当前方法被 Spring 的事务控制,如果在方法内部加锁,可能会导致当前方法事务还没有提交,但是锁已经释放,也会导致问题。所以我们选择将当前方法整体包裹起来,确保事务不会出现问题:
Long userId = UserHolder.getUser().getId();
synchronized (userId.toString().intern()) {
return this.createVoucherOrder(voucherId);
}
但是以上做法依然有问题,因为调用的方法是通过 this.
的方式调用的,事务想要生效,还得利用代理来生效。所以这个地方,我们需要获得原始的事务对象,来操作事务。
为此,导入了 aspectjweaver
依赖,并开启了 @EnableAspectJAutoProxy(exposeProxy = true)
:
<dependency>
<groupId>org.aspectj</groupId>
<artifactId>aspectjweaver</artifactId>
</dependency>
@MapperScan("com.hmdp.mapper")
@SpringBootApplication
@EnableAspectJAutoProxy(exposeProxy = true)
public class HmDianPingApplication {
public static void main(String[] args) {
SpringApplication.run(HmDianPingApplication.class, args);
}
}
使用代理的代码如下:
Long userId = UserHolder.getUser().getId();
synchronized (userId.toString().intern()) {
IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
return proxy.createVoucherOrder(voucherId);
}
集群并发
为了模拟集群环境,复制了一份配置,修改端口为 8082,并修改了 VM 参数 -Dserver.port=8082
。
形成集群:
形成集群后,修改了 nginx 配置如下:
加载nginx配置
nginx -s reload
此时可以达到负载均衡的效果,但在分布式情况下,依然会发生并发问题,同一个人还是可以抢到两个优惠卷,因为 synchronized
是在两台不同的 JVM 里面。要解决这个问题,就需要用到分布式锁。