面试题:Redis项目总结二

1 全局唯一ID

特性:唯一性,高可用,递增性,安全性和高性能

面试题:Redis项目总结二_第1张图片
  • 符号位:1bit,永远为0

  • 时间戳:31bit,以秒为单位,可以使用69年

  • 序列号:32bit,秒内的计数器,支持每秒产生2^32个不同(理论上)。

MySQL-Redis进阶生成全局唯一ID

2 解决超卖问题

服务端发放100张八折优惠券,客户端可以进行抢购。由于是在多线程环境下,难免会产生超卖现象。

面试题:Redis项目总结二_第2张图片

业务流程

  • 根据提交的优惠券id查询优惠券信息

  • 判断参数秒杀时间

  • 参数正确

  • 判断库存是否充足

  • 充足则扣减库存生成订单(仍然需要加锁,此处可以使用乐观锁)

  • 不足则直接返回。

面试题:Redis项目总结二_第3张图片
@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);
 
}

3 分布式系统下一人一单业务

上文采用乐观锁的方法解决了超卖现象;主要逻辑如下:

// 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("库存不足");
            }

然而却发现一个人抢到了多张票,这是不允许的事情。应该如何解决这个问题呢?这就要讲到一人一单业务

下单前,应该查询是否数据库中已经下过单了,如果下过单则不允许重复购买。具体逻辑如下图所示。

面试题:Redis项目总结二_第4张图片
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分布式锁框架。

面试题:Redis项目总结二_第5张图片
  • 获取锁:确保只有一个线程获取锁,使用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);
    }
}
在某些极端情况下,由于锁释放并不是原子性的,因此也会出现多线程问题,如下图所示:
面试题:Redis项目总结二_第6张图片

// 锁的名称,也可以是业务名称,不同业务有不同的锁。
    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脚本进行原子性锁释放。

4 Redisson 分布式锁框架

上面实现的锁机制有如下问题:

  • 不可重入:同一线程无法多次获取同一把锁

  • 不可重试:获取锁只尝试一次就false,没有重试机制

  • 超时释放:当业务执行耗时较长也会导致锁释放。

  • 主从一致性:主从同步存在延迟,当主机宕机时,可能会存在多个线程拿到锁。

面试题:Redis项目总结二_第7张图片
面试题:Redis项目总结二_第8张图片

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;
    }

面试题:Redis项目总结二_第9张图片

Redisson分布式锁原理:

  • 可重入:利用hash结构记录线程id和重入次数

  • 可重试:利用信号量和PubSub功能实现等待、唤醒,获取锁失败的重试机制

  • 超时续约:利用watchDog,每隔一段时间 (releaseTime/3),重置超时时间

Redisson分布式锁主从一致性问题

主节点负责:写操作

从节点负责:读节点命令

面试题:Redis项目总结二_第10张图片

在主从同步时候,主节点宕机,造成了锁失效

Redisson实现的就是进行联锁,一锁存在则永远存在。

面试题:Redis项目总结二_第11张图片

5 Redis 实现异步秒杀

难点在于:

  • Redis判断秒杀库存,可以将库存存入Redis(使用string)

  • 检验一人一单,使用Redis中的(Set集合)

  • 基于Stream流实现异步秒杀

面试题:Redis项目总结二_第12张图片

需求:

  1. 新增秒杀优惠券的同时,将优惠券信息保存到Redis中

  1. 基于Lua脚本,判断秒杀库存、一人一单,决定用户是否抢购成功

  1. 如果抢购成功,将优惠券id和用户id封装后存入Redis的Stream

  1. 开启线程任务,不断从阻塞队列中获取信息,实现异步下单功能

  1. 新增秒杀优惠券的同时,将优惠券信息保存到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());
    }
  1. 基于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
  1. 如果抢购成功,将优惠券id和用户id封装后存入Redis的Stream中

  1. 开启线程任务,不断从阻塞队列中获取信息,实现异步下单功能

stream 的单消费模式

stream可能会出现漏读情况。

面试题:Redis项目总结二_第13张图片

读取消息

面试题:Redis项目总结二_第14张图片
面试题:Redis项目总结二_第15张图片

stream 的消费组:将多个消费者划到一个组中,竞争读取。

  • 消息分流

  • 消息表示

  • 消息确认

面试题:Redis项目总结二_第16张图片

创建消费者组

面试题:Redis项目总结二_第17张图片

操作消费者:

面试题:Redis项目总结二_第18张图片
面试题:Redis项目总结二_第19张图片
面试题:Redis项目总结二_第20张图片

手动创建消费者组

改写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 value = record.getValue();
                    VoucherOrder voucherOrder = BeanUtil.fillBeanWithMap(value, new VoucherOrder(), true);
                    // 3.创建订单
                    createVoucherOrder(voucherOrder);
                    // 4.确认消息 XACK
                    stringRedisTemplate.opsForStream().acknowledge("s1", "g1", record.getId());
                } catch (Exception e) {
                    log.error("处理订单异常", e);
                    handlePendingList();
                }
            }
        }

        private void handlePendingList() {
            while (true) {
                try {
                    // 1.获取pending-list中的订单信息 XREADGROUP GROUP g1 c1 COUNT 1 BLOCK 2000 STREAMS s1 0
                    List> list = stringRedisTemplate.opsForStream().read(
                            Consumer.from("g1", "c1"),
                            StreamReadOptions.empty().count(1),
                            StreamOffset.create("stream.orders", ReadOffset.from("0"))
                    );
                    // 2.判断订单信息是否为空
                    if (list == null || list.isEmpty()) {
                        // 如果为null,说明没有异常消息,结束循环
                        break;
                    }
                    // 解析数据
                    MapRecord record = list.get(0);
                    Map value = record.getValue();
                    VoucherOrder voucherOrder = BeanUtil.fillBeanWithMap(value, new VoucherOrder(), true);
                    // 3.创建订单
                    createVoucherOrder(voucherOrder);
                    // 4.确认消息 XACK
                    stringRedisTemplate.opsForStream().acknowledge("s1", "g1", record.getId());
                } catch (Exception e) {
                    log.error("处理订单异常", e);
                }
            }
        }
    }

你可能感兴趣的:(面试题,Redis,redis,java,面试)