特性:唯一性,高可用,递增性,安全性和高性能
符号位:1bit,永远为0
时间戳:31bit,以秒为单位,可以使用69年
序列号:32bit,秒内的计数器,支持每秒产生2^32个不同(理论上)。
MySQL-Redis进阶生成全局唯一ID
服务端发放100张八折优惠券,客户端可以进行抢购。由于是在多线程环境下,难免会产生超卖现象。
业务流程
根据提交的优惠券id查询优惠券信息
判断参数秒杀时间
参数正确
判断库存是否充足
充足则扣减库存生成订单(仍然需要加锁,此处可以使用乐观锁)
不足则直接返回。
@Override
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 success = seckillVoucherService.update()
.setSql("stock= stock -1")
.eq("voucher_id", voucherId).update().gt("stock",0); //where id = ? and stock > 0
if (!success) {
//扣减库存
return Result.fail("库存不足!");
}
//6.创建订单
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);
}
上文采用乐观锁的方法解决了超卖现象;主要逻辑如下:
// 5. 扣减库存; 采用CAS操作。只要库存大于0都可以执行成功
boolean success = seckillVoucherService.update()
.setSql("stock = stock - 1")
.gt("stock", 0)
.eq("voucher_id", voucherOrder.getVoucherId()).update();
if (!success) {
return Result.fail("库存不足");
}
然而却发现一个人抢到了多张票,这是不允许的事情。应该如何解决这个问题呢?这就要讲到一人一单业务。
下单前,应该查询是否数据库中已经下过单了,如果下过单则不允许重复购买。具体逻辑如下图所示。
package com.hmdp.service.impl;
@Service
public class VoucherOrderServiceImpl extends ServiceImpl implements IVoucherOrderService {
@Resource
private ISeckillVoucherService seckillVoucherService;
@Resource
private RedisWorker redisWorker;
@Override
public Result seckillVoucher(Long voucherId) {
// 1. 查询优惠券信息
SeckillVoucher voucherOrder = seckillVoucherService.getById(voucherId);
// 2.判断秒杀是否开始
if (voucherOrder.getBeginTime().isAfter(LocalDateTime.now())) {
return Result.fail("抢购尚未开始");
}
if (voucherOrder.getEndTime().isBefore(LocalDateTime.now())) {
return Result.fail("抢购已经结束");
}
// 3.判断库存是否充足
if (voucherOrder.getStock() < 1) {
return Result.fail("您来晚了,票已被抢完");
}
Long userId = UserHolder.getUser().getId();
// 事务应该在synchronized里面---> 由于事务会失效因此使用其代理对象进行调用。
synchronized (userId.toString().intern()) {
IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
return proxy.createVoucherOrder(voucherId,userId);
}
}
@Transactional
public Result createVoucherOrder(Long voucherId,Long userId) {
// 4. 一人一单逻辑
// 4.1 根据优惠券id和用户id查询订单
Integer count = query().eq("user_id", userId)
.eq("voucher_id", voucherId).count();
// 4.2 订单存在,直接返回
if (count > 0) {
return Result.fail("用户已经购买一次");
}
// 5. 扣减库存
boolean success = seckillVoucherService.update()
.setSql("stock = stock - 1")
.gt("stock", 0)
.eq("voucher_id", voucherId).update();
if (!success) {
return Result.fail("库存不足");
}
// 6.创建订单
VoucherOrder order = new VoucherOrder();
// 6.1 设置id
order.setId(redisWorker.nextId("order"));
// 6.2 设置订单id
order.setVoucherId(voucherId);
// 6.3 设置用户id
order.setUserId(userId);
save(order);
// 7. 返回订单id
return Result.ok(order);
}
}
其中重要的步骤
锁的粒度
// 事务应该在synchronized里面---> 由于事务会失效因此使用其代理对象进行调用。
synchronized (userId.toString().intern()) {
IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
return proxy.createVoucherOrder(voucherId,userId);
}
事务的失效与恢复
// 1. 代理生效
@EnableAspectJAutoProxy(exposeProxy = true)
// 2.导入依赖
org.aspectj
aspectjweaver
到此已经解决了单体系统下一人一单的业务逻辑。但在集群系统下单体的JVM系统锁就不生效了。需要实现分布式锁。
分布式锁的实现可以利用Redis中的setnx命令。或者也可以导入Redisson分布式锁框架。
获取锁:确保只有一个线程获取锁,使用SET lock thread1 EX 10 NX;
释放锁:手动释放锁。DEL lock
定义锁接口
public interface ILock {
/**
*
* @param timeout 释放时间
* @return 获取锁释放成功
*/
boolean tryLock(Long timeout);
void unLock();
}
锁实现
private static final String KEY_PREFIX="lock:"
@Override
public boolean tryLock(long timeoutSec) {
// 获取线程标示
String threadId = Thread.currentThread().getId()
// 获取锁
Boolean success = stringRedisTemplate.opsForValue()
.setIfAbsent(KEY_PREFIX + name, threadId + "", timeoutSec, TimeUnit.SECONDS);
return Boolean.TRUE.equals(success);
}
public void unlock() {
//通过del删除锁
stringRedisTemplate.delete(KEY_PREFIX + name);
}
以上锁实现的问题: 当获取锁的线程阻塞时间超过锁自动释放时间,可能会出现多线程问题。
应该进行修改, 在释放锁之前应该判断锁是否是自己的。如果不是则不进行释放。
需求:修改之前的分布式锁实现,满足
1.在获取锁时存入线程标示(可以用UUID)表示
2.在释放锁时先获取锁中的线程标示,判断是否与当前线程标示一致
如果一致则释放锁
如果不一致则不释放锁
private static final String ID_PREFIX = UUID.randomUUID().toString(true) + "-";
@Override
public boolean tryLock(long timeoutSec) {
// 获取线程标示
String threadId = ID_PREFIX + Thread.currentThread().getId();
// 获取锁
Boolean success = stringRedisTemplate.opsForValue()
.setIfAbsent(KEY_PREFIX + name, threadId, timeoutSec, TimeUnit.SECONDS);
return Boolean.TRUE.equals(success);
}
public void unlock() {
// 获取线程标示
String threadId = ID_PREFIX + Thread.currentThread().getId();
// 获取锁中的标示
String id = stringRedisTemplate.opsForValue().get(KEY_PREFIX + name);
// 判断标示是否一致
if(threadId.equals(id)) {
// 释放锁
stringRedisTemplate.delete(KEY_PREFIX + name);
}
}
在某些极端情况下,由于锁释放并不是原子性的,因此也会出现多线程问题,如下图所示:
// 锁的名称,也可以是业务名称,不同业务有不同的锁。
private String name;
private StringRedisTemplate stringRedisTemplate;
public SimpleRedisLock(String name, StringRedisTemplate stringRedisTemplate) {
this.name = name;
this.stringRedisTemplate = stringRedisTemplate;
}
private static final String KEY_PREFIX = "lock:";
private static final String ID_PREFIX = UUID.randomUUID().toString(true) + "-";
private static final DefaultRedisScript UNLOCK_SCRIPT;
static {
UNLOCK_SCRIPT = new DefaultRedisScript<>();
UNLOCK_SCRIPT.setLocation(new ClassPathResource("unlock.lua"));
UNLOCK_SCRIPT.setResultType(Long.class);
}
@Override
public boolean tryLock(long timeoutSec) {
// 获取线程标示
String threadId = ID_PREFIX + Thread.currentThread().getId();
// 获取锁
Boolean success = stringRedisTemplate.opsForValue()
.setIfAbsent(KEY_PREFIX + name, threadId, timeoutSec, TimeUnit.SECONDS);
return Boolean.TRUE.equals(success);
}
@Override
public void unlock() {
// 调用lua脚本进行释放锁。其中Redisson底层也是如此。
stringRedisTemplate.execute(
UNLOCK_SCRIPT,
Collections.singletonList(KEY_PREFIX + name),
ID_PREFIX + Thread.currentThread().getId());
}
则此一人一单业务实现完成。
【第一步】我们判断数据库中订单是否已经存在来实现一人一单业务--发现一个人抢到了多张票问题
【第二步】我们使用悲观锁解决【第一步】中产生的问题。
【第三步】我们发现在集群环境下,仍然会出现一人抢到多单的问题。
【第四步】我们利用Redis实现分布式锁进行改进,并使用lua脚本进行原子性锁释放。
上面实现的锁机制有如下问题:
不可重入:同一线程无法多次获取同一把锁
不可重试:获取锁只尝试一次就false,没有重试机制
超时释放:当业务执行耗时较长也会导致锁释放。
主从一致性:主从同步存在延迟,当主机宕机时,可能会存在多个线程拿到锁。
Redisson实现可重入:采用hash数据接口进行实现,源码如下:
RFuture tryLockInnerAsync(long waitTime, long leaseTime, TimeUnit unit, long threadId, RedisStrictCommand command) {
internalLockLeaseTime = unit.toMillis(leaseTime);
// 使用hincrby进行重入。
return evalWriteAsync(getName(), LongCodec.INSTANCE, command,
"if (redis.call('exists', KEYS[1]) == 0) then " +
"redis.call('hincrby', KEYS[1], ARGV[2], 1); " +
"redis.call('pexpire', KEYS[1], ARGV[1]); " +
"return nil; " +
"end; " +
"if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then " +
"redis.call('hincrby', KEYS[1], ARGV[2], 1); " +
"redis.call('pexpire', KEYS[1], ARGV[1]); " +
"return nil; " +
"end; " +
"return redis.call('pttl', KEYS[1]);",
Collections.singletonList(getName()), internalLockLeaseTime, getLockName(threadId));
}
Redisson 实现可重试
// 尝试获取锁
boolean isLock = lock.tryLock(1L, TimeUnit.SECONDS);
public boolean tryLock(long waitTime, long leaseTime, TimeUnit unit) throws InterruptedException {
long time = unit.toMillis(waitTime);
long current = System.currentTimeMillis();
long threadId = Thread.currentThread().getId();
// null 表示获取锁成功, 不为null获取锁失败。
Long ttl = tryAcquire(waitTime, leaseTime, unit, threadId);
// lock acquired
if (ttl == null) {
return true;
}
// 剩余等待时间
time -= System.currentTimeMillis() - current;
if (time <= 0) {
acquireFailed(waitTime, unit, threadId);
return false;
}
current = System.currentTimeMillis();
// 订阅别人释放锁的信号。 wait notify
RFuture subscribeFuture = subscribe(threadId);
// 在最大等待时间内,没有信号传来直接返回false
if (!subscribeFuture.await(time, TimeUnit.MILLISECONDS)) {
if (!subscribeFuture.cancel(false)) {
subscribeFuture.onComplete((res, e) -> {
if (e == null) {
unsubscribe(subscribeFuture, threadId);
}
});
}
acquireFailed(waitTime, unit, threadId);
return false;
}
try {
// 剩余时间
time -= System.currentTimeMillis() - current;
if (time <= 0) {
acquireFailed(waitTime, unit, threadId);
return false;
}
// 进行重试。
while (true) {
long currentTime = System.currentTimeMillis();
ttl = tryAcquire(waitTime, leaseTime, unit, threadId);
// lock acquired
if (ttl == null) {
return true;
}
// 剩余时间
time -= System.currentTimeMillis() - currentTime;
if (time <= 0) {
acquireFailed(waitTime, unit, threadId);
return false;
}
// waiting for message
currentTime = System.currentTimeMillis();
if (ttl >= 0 && ttl < time) {
subscribeFuture.getNow().getLatch().tryAcquire(ttl, TimeUnit.MILLISECONDS);
} else {
subscribeFuture.getNow().getLatch().tryAcquire(time, TimeUnit.MILLISECONDS);
}
time -= System.currentTimeMillis() - currentTime;
if (time <= 0) {
acquireFailed(waitTime, unit, threadId);
return false;
}
}
} finally {
unsubscribe(subscribeFuture, threadId);
}
// return get(tryLockAsync(waitTime, leaseTime, unit));
}
Redisson解决超时释放:使得锁释放只能为自己业务执行完毕才会释放。采用一个定时机制,每隔十秒对过期时间进行重置。
private RFuture tryAcquireAsync(long waitTime, long leaseTime, TimeUnit unit, long threadId) {
if (leaseTime != -1) {
return tryLockInnerAsync(waitTime, leaseTime, unit, threadId, RedisCommands.EVAL_LONG);
}
RFuture ttlRemainingFuture = tryLockInnerAsync(waitTime,
commandExecutor.getConnectionManager().getCfg().getLockWatchdogTimeout(),
TimeUnit.MILLISECONDS, threadId, RedisCommands.EVAL_LONG);
ttlRemainingFuture.onComplete((ttlRemaining, e) -> {
if (e != null) {
return;
}
// lock acquired----> 定时机制进行有效期的重置!!!!
if (ttlRemaining == null) {
scheduleExpirationRenewal(threadId);
}
});
return ttlRemainingFuture;
}
Redisson分布式锁原理:
可重入:利用hash结构记录线程id和重入次数
可重试:利用信号量和PubSub功能实现等待、唤醒,获取锁失败的重试机制
超时续约:利用watchDog,每隔一段时间 (releaseTime/3),重置超时时间
Redisson分布式锁主从一致性问题
主节点负责:写操作
从节点负责:读节点命令
在主从同步时候,主节点宕机,造成了锁失效。
Redisson实现的就是进行联锁,一锁存在则永远存在。
难点在于:
Redis判断秒杀库存,可以将库存存入Redis(使用string)
检验一人一单,使用Redis中的(Set集合)
基于Stream流实现异步秒杀
需求:
新增秒杀优惠券的同时,将优惠券信息保存到Redis中
基于Lua脚本,判断秒杀库存、一人一单,决定用户是否抢购成功
如果抢购成功,将优惠券id和用户id封装后存入Redis的Stream
开启线程任务,不断从阻塞队列中获取信息,实现异步下单功能
新增秒杀优惠券的同时,将优惠券信息保存到Redis中
@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);
// 保存秒杀库存到Redis中
stringRedisTemplate.opsForValue().set(SECKILL_STOCK_KEY + voucher.getId(), voucher.getStock().toString());
}
基于Lua脚本,判断秒杀库存、一人一单,决定用户是否抢购成功
-- 1.参数列表
-- 1.1.优惠券id
local voucherId = ARGV[1]
-- 1.2.用户id
local userId = ARGV[2]
-- 1.3.订单id
local orderId = ARGV[3]
-- 2.数据key
-- 2.1.库存key
local stockKey = 'seckill:stock:' .. voucherId
-- 2.2.订单key
local orderKey = 'seckill:order:' .. voucherId
-- 3.脚本业务
-- 3.1.判断库存是否充足 get stockKey
if(tonumber(redis.call('get', stockKey)) <= 0) then
-- 3.2.库存不足,返回1
return 1
end
-- 3.2.判断用户是否下单 SISMEMBER orderKey userId
if(redis.call('sismember', orderKey, userId) == 1) then
-- 3.3.存在,说明是重复下单,返回2
return 2
end
-- 3.4.扣库存 incrby stockKey -1
redis.call('incrby', stockKey, -1)
-- 3.5.下单(保存用户)sadd orderKey userId
redis.call('sadd', orderKey, userId)
return 0
如果抢购成功,将优惠券id和用户id封装后存入Redis的Stream中
开启线程任务,不断从阻塞队列中获取信息,实现异步下单功能
stream 的单消费模式
stream可能会出现漏读情况。
读取消息
stream 的消费组:将多个消费者划到一个组中,竞争读取。
消息分流
消息表示
消息确认
创建消费者组
操作消费者:
手动创建消费者组
改写lua脚本
-- 3.4.扣库存 incrby stockKey -1
redis.call('incrby', stockKey, -1)
-- 3.5.下单(保存用户)sadd orderKey userId
redis.call('sadd', orderKey, userId)
-- 3.6.发送消息到队列stream中, XADD stream.orders * k1 v1 k2 v2 ...
redis.call('xadd', 'stream.orders', '*', 'userId', userId, 'voucherId', voucherId, 'id', orderId)
return 0
实现异步处理
private class VoucherOrderHandler implements Runnable {
@Override
public void run() {
while (true) {
try {
// 1.获取消息队列中的订单信息 XREADGROUP GROUP g1 c1 COUNT 1 BLOCK 2000 STREAMS s1 >
List> list = stringRedisTemplate.opsForStream().read(
Consumer.from("g1", "c1"),
StreamReadOptions.empty().count(1).block(Duration.ofSeconds(2)),
StreamOffset.create("stream.orders", ReadOffset.lastConsumed())
);
// 2.判断订单信息是否为空
if (list == null || list.isEmpty()) {
// 如果为null,说明没有消息,继续下一次循环
continue;
}
// 解析数据
MapRecord record = list.get(0);
Map