面试官:Redis分布式锁解决方案是什么?

本文涉及的产品
Redis 开源版,标准版 2GB
推荐场景:
搭建游戏排行榜
简介: 面试官:Redis分布式锁解决方案是什么?

1.Redis分布式锁原理


1.1.简述


我们知道分布式锁的特性是排他、避免死锁、高可用。分布式锁的实现可以通过数据库的乐观锁(通过版本号)或者悲观锁(通过for update)、Redis的setnx()命令、Zookeeper(在某个持久节点添加临时有序节点,判断当前节点是否是序列中最小的节点,如果不是则监听比当前节点还要小的节点。如果是,获取锁成功。当被监听的节点释放了锁(也就是被删除),会通知当前节点。然后当前节点再尝试获取锁,如此反复)


1.2.分布式锁实现


随着业务发展的需要,原单体单机部署的系统被演化成分布式集群系统后,由于分布式系统多线程、多进程并且分布在不同机器上,这将使原单机部署情况下的并发控制锁策略失效,单纯的Java API并不能提供分布式锁的能力。为了解决这个问题就需要一种跨JVM的互斥机制来控制共享资源的访问,Redis分布式锁就是做这个工作的


1.3.分布式锁主流的实现方案:


a.基于数据库实现分布式锁

b.基于缓存(Redis等)

c.基于Zookeeper

每一种分布式锁解决方案都有各自的优缺点:

性能:redis最高

可靠性:zookeeper最高

这里,我们就基于redis实现分布式锁。


1.4.基本实现


借助于redis中的命令setnx(key, value),key不存在就新增,存在就什么都不做。同时有多个客户端发送setnx命令,只有一个客户端可以成功,返回1(true);其他的客户端返回0(false)


2021072010060091.png


2.Redis分布式锁解决方案


2.1.Redis配置

spring.redis.host=
spring.redis.port=6379
#reids超时连接时间
spring.redis.timeout=100000
spring.redis.password=
#连接池最大连接数
spring.redis.pool.max-active=10000
#连接池最大空闲数
spring.redis.pool.max-idle=1000
#连接池最大等待时间
spring.redis.pool.max-wait=10000
@Component
@Getter
@Setter
@ConfigurationProperties(prefix = "spring.redis")
public class RedisConfig {
    @Value("${spring.redis.host}")
    private String host;
    @Value("${spring.redis.port}")
    private int port;
    @Value("${spring.redis.password}")
    private String password;
    @Value("${spring.redis.timeout}")
    private int timeout;
    @Value("${spring.redis.pool.max-active}")
    private int poolMaxActive;
    @Value("${spring.redis.pool.max-idle}")
    private int poolMaxIdle;
    @Value("${spring.redis.pool.max-wait}")
    private int poolMaxWait;
}
@Component
public class RedisPoolFactory {
    @Autowired
    private RedisConfig redisConfig;
    @Bean
    public JedisPool jedisPoolFactory() {
        JedisPoolConfig poolConfig = new JedisPoolConfig();
        poolConfig.setMaxIdle(redisConfig.getPoolMaxIdle());
        poolConfig.setMaxTotal(redisConfig.getPoolMaxActive());
        poolConfig.setTestOnBorrow(true);
        poolConfig.setMaxWaitMillis(redisConfig.getPoolMaxWait());
        JedisPool jp = new JedisPool(poolConfig, redisConfig.getHost(), redisConfig.getPort(),
                redisConfig.getTimeout(), redisConfig.getPassword(), 0);
        return jp;
    }
}

为了区分不同模块的key,我抽象出了一个KeyPrefix接口和BasePrefix类。

public interface KeyPrefix {
    int expireSeconds();
    String getPrefix();
}
/**
 * @author cmazxiaoma
 * @version V1.0
 * @Description: TODO
 * @date 2018/5/10 12:35
 */
public abstract class BasePrefix implements KeyPrefix {
    private int expireSeconds;
    private String prefix;
    public BasePrefix(int expireSeconds, String prefix) {
        this.expireSeconds = expireSeconds;
        this.prefix = prefix;
    }
    public BasePrefix(String prefix) {
        this(0, prefix);
    }
    @Override
    public int expireSeconds() {
        return expireSeconds;
    }
    @Override
    public String getPrefix() {
        String className = getClass().getSimpleName();
        return className + ":" + prefix;
    }
}

2.2.分布式锁分析与编码


下面进入正文。因为分布式系统之间是不同进程的,单机版的锁无法满足要求。所以我们可以借助中间件Redis的setnx()命令实现分布式锁。setnx()命令只会对不存在的key设值,返回1代表获取锁成功。对存在的key设值,会返回0代表获取锁失败。这里的value是System.currentTimeMillis() (获取锁的时间)+锁持有的时间。我这里设置锁持有的时间是200ms,实际业务执行的时间远比这200ms要多的多,持有锁的客户端应该检查锁是否过期,保证锁在释放之前不会过期。因为客户端故障的情况可能是很复杂的。比如现在有A,B俩个客户端。A客户端获取了锁,执行业务中做了骚操作导致阻塞了很久,时间应该远远超过200ms,当A客户端从阻塞状态下恢复继续执行业务代码时,A客户端持有的锁由于过期已经被其他客户端占有。这时候A客户端执行释放锁的操作,那么有可能释放掉其他客户端的锁。


我这里设置的客户端等待锁的时间是200ms。这里通过轮询的方式去让客户端获取锁。如果客户端在200ms之内没有锁的话,直接返回false。实际场景要设置合适的客户端等待锁的时间,避免消耗CPU资源。


如果获取锁的逻辑只有这三行代码的话,会造成死循环,明显不符合分布式锁的特性。

                if (jedis.setnx(realKey, value) == 1) {
                    return true;
                }

所以,我们要加上锁过期,然后获取锁的策略。通过realKey获取当前的currentValue。currentValue也就是获取锁的时间 + 锁持有的时间。 如果currentValue不等于null 且 currentValue 小于当前时间,说明锁已经过期。这时候如果突然来了C,D两个客户端获取锁的请求,不就让C,D两个客户端都获取锁了吗。如果防止这种现象发生,我们采用getSet()命令来解决。getSet(key,value)的命令会返回key对应的value,然后再把key原来的值更新为value。也就是说getSet()返回的是已过期的时间戳。如果这个已过期的时间戳等于currentValue,说明获取锁成功。


假设客户端A一开始持有锁,保存在redis中的value(时间戳)等于T1。


这时候客户端A的锁已经过期,那么C,D客户端就可以开始争抢锁了。currentValue是T1,C客户端的value是T2,D客户端的value是T3。首先C客户端进入到String oldValue = jedis.getSet(realKey, value);这行代码,获得的oldValue是T1,同时也会把realKey对应的value更新为T2。再执行后续的代码,oldValue等于currentValue,那么客户端C获取锁成功。接着D客户端也执行到了String oldValue = jedis.getSet(realKey, value);这行代码,获取的oldValue是T2,同时也会把realKey对应的value更新为T3。由于oldValue不等于currentValue,那么客户端D获取锁失败。

    public boolean lock(KeyPrefix prefix, String key, String value) {
        Jedis jedis = null;
        Long lockWaitTimeOut = 200L;
        Long deadTimeLine = System.currentTimeMillis() + lockWaitTimeOut;
        try {
            jedis = jedisPool.getResource();
            String realKey = prefix.getPrefix() + key;
            for (;;) {
                if (jedis.setnx(realKey, value) == 1) {
                    return true;
                }
                String currentValue = jedis.get(realKey);
                // if lock is expired
                if (!StringUtils.isEmpty(currentValue) &&
                        Long.valueOf(currentValue) < System.currentTimeMillis()) {
                    // gets last lock time
                    String oldValue = jedis.getSet(realKey, value);
                    if (!StringUtils.isEmpty(oldValue) && oldValue.equals(currentValue)) {
                        return true;
                    }
                }
                lockWaitTimeOut = deadTimeLine - System.currentTimeMillis();
                if (lockWaitTimeOut <= 0L) {
                    return false;
                }
            }
        } finally {
            returnToPool(jedis);
        }
    }


我们讲解了获取的逻辑,接着讲讲释放锁的逻辑。我们在这里加上!StringUtils.isEmpty(currentValue) && value.equals(currentValue)判断是为了防止释放了不属于当前客户端的锁。还是举个例子,如果没有这个逻辑,A客户端调用unlock()方法之前,锁突然就过期了。这时候B客户端发现锁过期了,立马获取了锁。然后A客户端接着调用unlock()方法,却释放了原本属于B客户端的锁。


    public void unlock(KeyPrefix prefix, String key, String value) {
        Jedis jedis = null;
        try {
            jedis = jedisPool.getResource();
            String realKey = prefix.getPrefix() + key;
            String currentValue = jedis.get(realKey);
            if (!StringUtils.isEmpty(currentValue)
                    && value.equals(currentValue)) {
                jedis.del(realKey);
            }
        } catch (Exception ex) {
            log.info("unlock error");
        } finally {
            returnToPool(jedis);
        }
    }


编码RedisController,模拟商品秒杀操作。测试分布式锁是否可行。(强调:这里只是举一个例子,更直观的判断分布式锁可行,不适合实际场景!!!!!实际上抢购,是直接将库存放入到redis,是否结束标记放入到内存中,通过内存标记和redis中的decr()预减库存,然后将秒杀消息入队到消息队列中,最后消费消息并落地到DB中)


/**
 * @author cmazxiaoma
 * @version V1.0
 * @Description: TODO
 * @date 2018/8/28 9:27
 */
@RestController
@RequestMapping("/redis")
public class RedisController {
    private static LongAdder longAdder = new LongAdder();
    private static Long LOCK_EXPIRE_TIME = 200L;
    private static Long stock = 10000L;
    @Autowired
    private RedisService redisService;
    static {
        longAdder.add(10000L);
    }
    @GetMapping("/v1/seckill")
    public String seckillV1() {
        Long time = System.currentTimeMillis() + LOCK_EXPIRE_TIME;
        if (!redisService.lock(SeckillKeyPrefix.seckillKeyPrefix, "redis-seckill", String.valueOf(time))) {
            return "人太多了,换个姿势操作一下";
        }
        if (longAdder.longValue() == 0L) {
            return "已抢光";
        }
        doSomeThing();
        if (longAdder.longValue() == 0L) {
            return "已抢光";
        }
        longAdder.decrement();
        redisService.unlock(SeckillKeyPrefix.seckillKeyPrefix, "redis-seckill", String.valueOf(time));
        Long stock = longAdder.longValue();
        Long bought = 10000L - stock;
        return "已抢" + bought + ", 还剩下" + stock;
    }
    @GetMapping("/detail")
    public String detail() {
        Long stock = longAdder.longValue();
        Long bought = 10000L - stock;
        return "已抢" + bought + ", 还剩下" + stock;
    }
    @GetMapping("/v2/seckill")
    public String seckillV2() {
        if (longAdder.longValue() == 0L) {
            return "已抢光";
        }
        doSomeThing();
        if (longAdder.longValue() == 0L) {
            return "已抢光";
        }
        longAdder.decrement();
        Long stock = longAdder.longValue();
        Long bought = 10000L - stock;
        return "已抢" + bought + ", 还剩下" + stock;
    }
    @GetMapping("/v3/seckill")
    public String seckillV3() {
        if (stock == 0) {
            return "已抢光";
        }
        doSomeThing();
        stock--;
        Long bought = 10000L - stock;
        return "已抢" + bought + ", 还剩下" + stock;
    }
    public void doSomeThing() {
        try {
            TimeUnit.MILLISECONDS.sleep(100);
        } catch (InterruptedException ex) {
            ex.printStackTrace();
        }
    }
}


http://localhost:8081/redis/v1/seckill进行压测,我使用的压测工具是ab测试工具。这里用10000个并发用户,20000个请求来进行压测。


ab -c 10000 -n 20000 http://localhost:8081/redis/v1/seckill


压测结果如下:


E:\cmazxiaoma_download\httpd-2.4.34-o102o-x64-vc14\Apache24\bin>ab -c 10000 -n 2
0000 http://localhost:8081/redis/v1/seckill
This is ApacheBench, Version 2.3 <$Revision: 1826891 $>
Copyright 1996 Adam Twiss, Zeus Technology Ltd, http://www.zeustech.net/
Licensed to The Apache Software Foundation, http://www.apache.org/
Benchmarking localhost (be patient)
Completed 2000 requests
Completed 4000 requests
Completed 6000 requests
Completed 8000 requests
Completed 10000 requests
Completed 12000 requests
Completed 14000 requests
Completed 16000 requests
Completed 18000 requests
Completed 20000 requests
Finished 20000 requests
Server Software:
Server Hostname:        localhost
Server Port:            8081
Document Path:          /redis/v1/seckill
Document Length:        22 bytes
Concurrency Level:      10000
Time taken for tests:   108.426 seconds
Complete requests:      20000
Failed requests:        19991
   (Connect: 0, Receive: 0, Length: 19991, Exceptions: 0)
Total transferred:      3420218 bytes
HTML transferred:       760218 bytes
Requests per second:    184.46 [#/sec] (mean)
Time per request:       54213.000 [ms] (mean)
Time per request:       5.421 [ms] (mean, across all concurrent requests)
Transfer rate:          30.80 [Kbytes/sec] received
Connection Times (ms)
              min  mean[+/-sd] median   max
Connect:        0    0   6.3      0     549
Processing:  2393 36477 16329.1  45101   90269
Waiting:      182 36435 16351.4  45046   90267
Total:       2393 36477 16329.0  45101   90269
Percentage of the requests served within a certain time (ms)
  50%  45101
  66%  47680
  75%  49136
  80%  50392
  90%  53200
  95%  53743
  98%  54510
  99%  56014
 100%  90269 (longest request)


我们再来看看是否有超卖现象,貌似还是正常。


20210720101242352.png

2.3.回溯分析


我打开RedisDesktopManager查看db0的key信息时,发现还有一个key没有删除掉。说明我们写的unlock()方法在1w并发用户,2w请求下还是存在问题。


20210720101312108.png


仔细推敲自己之前写的代码发现(还是拿上面的例子说事),客户端D虽然获取锁失败,但是之前进行了String oldValue = jedis.getSet(realKey, value);操作,还是成功的更新了realKey对应的value。我们进行unlock()操作时,释放客户端的锁是根据value来标识当前客户端的。一开始客户端C的value是T2,由于客户端D的getSet()操作,覆盖掉了客户端C的value,让其更新成T3。由于value.equals(currentValue)条件不成立,所以不会执行到jedis.del(realKey)


其实lock()方法也经不起推敲:


1.分布式各个系统时间不一致,如果要这样做,只能进行时间同步。

2.当某个客户端锁过期时,多个客户端开始争抢锁。虽然最后只有一个客户端能成功锁,但是获取锁失败的客户端能覆盖获取锁成功客户端的过期时间。

3.当客户端的锁过期时间被覆盖,会造成锁不具有标识性,会造成客户端没有释放锁。


所以我们要重写lock与unlock()的逻辑,看到网上已经有很多的解决方案。(不过也有很多错误案例)


我们可以通过redis的set(key,value,NX,EX,timeout)合并普通的set()和expire()操作,使其具有原子性


 /**
   * Set the string value as value of the key. The string can't be longer than 1073741824 bytes (1
   * GB).
   * @param key
   * @param value
   * @param nxxx NX|XX, NX -- Only set the key if it does not already exist. XX -- Only set the key
   *          if it already exist.
   * @param expx EX|PX, expire time units: EX = seconds; PX = milliseconds
   * @param time expire time in the units of <code>expx</code>
   * @return Status code reply
   */
  public String set(final String key, final String value, final String nxxx, final String expx,
      final long time) {
    checkIsInMultiOrPipeline();
    client.set(key, value, nxxx, expx, time);
    return client.getStatusCodeReply();
  }


通过set(key,value,NX,EX,timeout)方法,我们就可以轻松实现分布式锁。值得注意的是这里的value作为客户端锁的唯一标识,不能重复。


    public boolean lock1(KeyPrefix prefix, String key, String value, Long lockExpireTimeOut,
                         Long lockWaitTimeOut) {
        Jedis jedis = null;
        try {
            jedis = jedisPool.getResource();
            String realKey = prefix.getPrefix() + key;
            Long deadTimeLine = System.currentTimeMillis() + lockWaitTimeOut;
            for (;;) {
                String result = jedis.set(realKey, value, "NX", "PX", lockExpireTimeOut);
                if ("OK".equals(result)) {
                    return true;
                }
                lockWaitTimeOut = deadTimeLine - System.currentTimeMillis();
                if (lockWaitTimeOut <= 0L) {
                    return false;
                }
            }
        } catch (Exception ex) {
            log.info("lock error");
        } finally {
            returnToPool(jedis);
        }
        return false;
    }


我们可以使用lua脚本合并get()和del()操作,使其具有原子性。一切大功告成。


    public boolean unlock1(KeyPrefix prefix, String key, String value) {
        Jedis jedis = null;
        try {
            jedis = jedisPool.getResource();
            String realKey = prefix.getPrefix() + key;
            String luaScript = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
            Object result = jedis.eval(luaScript, Collections.singletonList(realKey),
                    Collections.singletonList(value));
            if ("1".equals(result)) {
                return true;
            }
        } catch (Exception ex) {
            log.info("unlock error");
        } finally {
            returnToPool(jedis);
        }
        return false;
    }


目录
相关文章
|
4月前
|
存储 负载均衡 NoSQL
【赵渝强老师】Redis Cluster分布式集群
Redis Cluster是Redis的分布式存储解决方案,通过哈希槽(slot)实现数据分片,支持水平扩展,具备高可用性和负载均衡能力,适用于大规模数据场景。
348 2
|
4月前
|
存储 缓存 NoSQL
【📕分布式锁通关指南 12】源码剖析redisson如何利用Redis数据结构实现Semaphore和CountDownLatch
本文解析 Redisson 如何通过 Redis 实现分布式信号量(RSemaphore)与倒数闩(RCountDownLatch),利用 Lua 脚本与原子操作保障分布式环境下的同步控制,帮助开发者更好地理解其原理与应用。
282 6
|
5月前
|
存储 缓存 NoSQL
Redis核心数据结构与分布式锁实现详解
Redis 是高性能键值数据库,支持多种数据结构,如字符串、列表、集合、哈希、有序集合等,广泛用于缓存、消息队列和实时数据处理。本文详解其核心数据结构及分布式锁实现,帮助开发者提升系统性能与并发控制能力。
|
3月前
|
NoSQL Java 调度
分布式锁与分布式锁使用 Redis 和 Spring Boot 进行调度锁(不带 ShedLock)
分布式锁是分布式系统中用于同步多节点访问共享资源的机制,防止并发操作带来的冲突。本文介绍了基于Spring Boot和Redis实现分布式锁的技术方案,涵盖锁的获取与释放、Redis配置、服务调度及多实例运行等内容,通过Docker Compose搭建环境,验证了锁的有效性与互斥特性。
220 0
分布式锁与分布式锁使用 Redis 和 Spring Boot 进行调度锁(不带 ShedLock)
|
3月前
|
缓存 NoSQL 关系型数据库
Redis缓存和分布式锁
Redis 是一种高性能的键值存储系统,广泛用于缓存、消息队列和内存数据库。其典型应用包括缓解关系型数据库压力,通过缓存热点数据提高查询效率,支持高并发访问。此外,Redis 还可用于实现分布式锁,解决分布式系统中的资源竞争问题。文章还探讨了缓存的更新策略、缓存穿透与雪崩的解决方案,以及 Redlock 算法等关键技术。
|
存储 Java
【IO面试题 四】、介绍一下Java的序列化与反序列化
Java的序列化与反序列化允许对象通过实现Serializable接口转换成字节序列并存储或传输,之后可以通过ObjectInputStream和ObjectOutputStream的方法将这些字节序列恢复成对象。
|
存储 算法 Java
大厂面试高频:什么是自旋锁?Java 实现自旋锁的原理?
本文详解自旋锁的概念、优缺点、使用场景及Java实现。关注【mikechen的互联网架构】,10年+BAT架构经验倾囊相授。
大厂面试高频:什么是自旋锁?Java 实现自旋锁的原理?
|
存储 缓存 算法
面试官:单核 CPU 支持 Java 多线程吗?为什么?被问懵了!
本文介绍了多线程环境下的几个关键概念,包括时间片、超线程、上下文切换及其影响因素,以及线程调度的两种方式——抢占式调度和协同式调度。文章还讨论了减少上下文切换次数以提高多线程程序效率的方法,如无锁并发编程、使用CAS算法等,并提出了合理的线程数量配置策略,以平衡CPU利用率和线程切换开销。
面试官:单核 CPU 支持 Java 多线程吗?为什么?被问懵了!
|
存储 缓存 Java
大厂面试必看!Java基本数据类型和包装类的那些坑
本文介绍了Java中的基本数据类型和包装类,包括整数类型、浮点数类型、字符类型和布尔类型。详细讲解了每种类型的特性和应用场景,并探讨了包装类的引入原因、装箱与拆箱机制以及缓存机制。最后总结了面试中常见的相关考点,帮助读者更好地理解和应对面试中的问题。
312 4
|
算法 Java 数据中心
探讨面试常见问题雪花算法、时钟回拨问题,java中优雅的实现方式
【10月更文挑战第2天】在大数据量系统中,分布式ID生成是一个关键问题。为了保证在分布式环境下生成的ID唯一、有序且高效,业界提出了多种解决方案,其中雪花算法(Snowflake Algorithm)是一种广泛应用的分布式ID生成算法。本文将详细介绍雪花算法的原理、实现及其处理时钟回拨问题的方法,并提供Java代码示例。
1988 2