黑马点评3
黑马点评3
优惠卷秒杀
自增的id存在一些问题:id规律太明显,受单表数据量限制,因此需要全局ID生成器
全局ID生成器
符号位:0
时间戳:31bit,以秒为单位,可以使用69年
序列号:32bit,秒内的计数器,支持每秒产生2^32个不同ID
具体代码实现:
获取2022-1-1的时间戳为1640995200L
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);
}
获取全局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;
}
}
测试是否有效:
@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 优惠卷的基本信息
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;
控制器:
/**
* 新增秒杀券
* @param voucher 优惠券信息,包含秒杀信息
* @return 优惠券id
*/
@PostMapping("seckill")
public Result addSeckillVoucher(@RequestBody Voucher voucher) {
voucherService.addSeckillVoucher(voucher);
return Result.ok(voucher.getId());
}
业务逻辑:
@Override
@Transactional
public void addSeckillVoucher(Voucher voucher) {
// 保存优惠券
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);
}
实现秒杀下单
流程图如下:
具体实现:
控制器:
@Resource
private IVoucherOrderService voucherOrderService;
@PostMapping("seckill/{id}")
public Result seckillVoucher(@PathVariable("id") Long voucherId) {
return voucherOrderService.seckillVoucher(voucherId);
}
逻辑层:
@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);
}
库存超卖问题
多个线程相当于一起去扣减库存,此时就会出现库存的超卖问题。
超卖问题是典型的多线程安全问题,针对这一问题的常见解决方案就是加锁:
悲观锁(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);
}
但是如果按照上面 这种方式加锁,锁的力度太大了,因为如果锁的力度 太大,会导致每个线程进来都会锁住,所以我们需要去控制锁的力度,
intern() 这个方法是从常量池中拿到数据,如果我们直接使用userId.toString() 他拿到的对象实际上是不同的对象,new
来的对象,我们使用锁必须保证锁必须是同一把,所以我们需要使用intern()方法
@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);
}
}
以上代码还是存在问题,问题的原因在于当前方法被spring的事务控制,如果你在方法内部加锁,可能会导致当前方法事务还没有提交,但是锁已经释放也会导致问题,所以我们选择将当前方法整体包裹起来,确保事务不会出现问题:
Long userId = UserHolder.getUser().getId();
synchronized (userId.toString().intern()) {
return this.createVoucherOrder(voucherId);
}
但是以上做法依然有问题,因为你调用的方法,其实是this.的方式调用的,事务想要生效,还得利用代理来生效,所以这个地方,我们需要获得原始的事务对象, 来操作事务
导入依赖:
<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里面,要解决这个问题,就需要用到分布式锁。