分布式Redis解决方案之Redisson

1.前言

Redisson是Redis官方推荐的Java版的Redis客户端。底层使用netty框架,并提供了与java对象相对应的分布式对象、分布式集合、分布式锁和同步器、分布式服务等一系列的Redisson的分布式对象。

2.使用准备

1)导入依赖

<dependency>
       <groupId>org.redisson</groupId>
       <artifactId>redisson-spring-boot-starter</artifactId>
       <version>3.11.6</version>
</dependency>

2)基本配置

在application.yml内容如下:(注意,一定要使用yml方式,否则在下面的redisson-config.yaml中通过${}获取会失败)

# redis基本配置
spring:
  redis:
  host: 127.0.0.1
  port: 6379
  #Redisson配置 配置配置文件路径
  redisson:
   config: classpath:redisson-config.yaml

配置redisson,在资源目录下新建redisson-config.yaml,内容如下:

#单节点模式配置
singleServerConfig:
  #节点地址
  address: redis://${spring.redis.host}:${spring.redis.port}
  #密码
  password: null
  #发布和订阅连接的最小空闲连接数
  subscriptionConnectionMinimumIdleSize: 1
  #发布和订阅连接池大小
  subscriptionConnectionPoolSize: 50
  #最小空闲连接数
  connectionMinimumIdleSize: 32
  #连接池大小
  connectionPoolSize: 64
  #数据库编号 0-15
  database: 0
  #连接空闲超时,单位:毫秒
  idleConnectionTimeout: 10000
  #连接超时,单位:毫秒
  connectTimeout: 10000
  #命令等待超时,单位:毫秒
  timeout: 3000
  #命令失败重试次数
  retryAttempts: 3
  #命令重试发送时间间隔,单位:毫秒
  retryInterval: 1500
  #单个连接最大订阅数量
  subscriptionsPerConnection: 5
  #客户端名称
  clientName: null
#线程池数量
threads: 0
#Netty线程池数量
nettyThreads: 0
#对象编码
codec: !<org.redisson.codec.JsonJacksonCodec> { }
#传输模式
transportMode: NIO

#集群配置 出节点地址外,其他同单节点配置
#clusterServersConfig:
#  nodeAddresses:
#    - "地址1"
#    - "地址2"

3.分布式锁

3.1情景模拟

模拟减库存的场景,先在redis中对商品id为1001设置库存(key为product:pro_1001:count,value500),减库存代码如下:

    @Autowired
    private StringRedisTemplate redisTemplate;

    @GetMapping("/test")
    public void test() {
        String key = "product:pro_1001:count";
        BoundValueOperations<String, String> ops = redisTemplate.boundValueOps(key);
        Integer cnt = Integer.parseInt(ops.get());
        if (cnt >= 0) {
            cnt--;
            ops.set(cnt.toString());
            log.info("扣减成功" + cnt);
        } else {
            log.info("存库不足");
        }
    }

复制两个应用并启动,使用nginx进行代理,然后用Jmeter模拟并发请求,在同一时间内并发200次,循环2次

在控制台可看到日志中出现了库存存在相同的情况

原因分析:当多个并发请求同时操作redis数据库时,可能一个请求读取到的库存是500,此时开始减一操作,另一个请求也开始读取库存,此时库存还未还更新完成,任然是500,此请求也是对500减一操作,那么这样就会出现数据错乱,引起超卖问题。

3.2解决方案

可使用分布式锁来解决,当一个请求处理完成后再处理其他请求,单线程按序执行。

    @Autowired
    private StringRedisTemplate redisTemplate;

    @Autowired
    private RedissonClient redissonClient;

    @GetMapping("/test")
    public void test() {
        String key = "product:pro_1001:count";
        RLock redissonClientLock = redissonClient.getLock(key);
        redissonClientLock.lock();//上锁
        try {
            BoundValueOperations<String, String> ops = redisTemplate.boundValueOps(key);
            if (ops.get() != null) {
                Integer cnt = Integer.parseInt(ops.get());
                if (cnt >= 0) {
                    cnt--;
                    ops.set(cnt.toString());
                    log.info("扣减成功" + cnt);
                } else {
                    log.info("存库不足");
                }
            } else {
                log.info("存库不足");
            }
        } catch (Exception e) {
            e.printStackTrace();
        } finally {
            redissonClientLock.unlock();//释放锁
        }
    }

在进行业务处理时时,先进行上锁操作,处理完成后无论正常与否都需释放锁。

3.3更多应用场景

其分布式锁的应用场景是非常多的,除了商品减库存外,还可用以下方面:

  • 突发热点数据。前面也讲到,缓存中存储的都是热点数据,但不排除某非热点数据因个别因素瞬间变为热点数据。那么当多个并发请求在到达后端时,由于缓存中不存在,便会直奔数据库,给数据库造成巨大的压力。解决问题的方式就是利用redis的分布式锁,在向数据库查询数据时,先对第一个请求上锁,待第一个请求查询完成存入缓存后,后续的请求才能继续查询。那么此时要查询的数据已变为热点数据,后续请求即可从缓存中获取数据而不用再从数据库获取。

 

3.4读写锁

在高并发情况下,通常更新完db后再去更新缓存,不加锁显而易见会出现缓存被覆盖的问题:线程1修改完db去更新缓存时卡顿了一下。此时线程2在线程1之后修改完db并成功更新了缓存。此时线程1更新缓存的操作恢复了,然后去更新了缓存。那么此时的缓存是脏数据,应为线程2缓存的数据,但实际上是线程1缓存的数据。也就是写操作出现了脏数据,使用读写锁可解决此问题。

 

读写锁,一次只有一个线程可以占有写模式的读写锁, 但是可以有多个线程同时占有读模式的读写锁,从而保证了数据的一致性。优点如下:

当读写锁在 写 加锁状态时,在这个锁被解锁之前,所有试图对这个锁加锁的线程都会被阻塞;

当读写锁在 读 加锁状态时,所有试图以读模式对它进行加锁的线程都可得到访问权,但是若线程以写模式对此锁进行加锁,则它会被阻塞,直到所有的线程释放锁后才能进行加锁。

换句话说,只要涉及到写锁,则都会阻塞,如果是先写再读,则读锁等待,如果是先读再写,则写锁等待。

代码如下:

    private static final String READ_WRITE_LOCK = "readWrite";
    private static final String READ_WRITE_KEY = "test:uuid";

    @GetMapping("/read")
    public String read() {
        String s = null;
        //读写锁
        RReadWriteLock readWriteLock = redissonClient.getReadWriteLock(READ_WRITE_LOCK);
        //读之前加锁,若该锁已被写锁锁定,则需等待其释放后才能读取
        RLock rLock = readWriteLock.readLock();
        try {
            rLock.lock();
            Thread.sleep(20000);
            s = redisTemplate.opsForValue().get(READ_WRITE_KEY);
        } catch (Exception e) {
            e.printStackTrace();
        } finally {
            rLock.unlock();
        }
        return s;
    }

    @GetMapping("/write")
    public String write() {
        String s = null;
        //读写锁
        RReadWriteLock readWriteLock = redissonClient.getReadWriteLock(READ_WRITE_LOCK);
        //写之前加锁,若该锁已被读锁锁定,则需等待其释放后才能写入
        RLock wLock = readWriteLock.writeLock();
        try {
            wLock.lock();
            Thread.sleep(20000);
            s = UUID.randomUUID().toString();
            redisTemplate.opsForValue().set(READ_WRITE_KEY, s);
        } catch (Exception e) {
            e.printStackTrace();
        } finally {
            wLock.unlock();
        }
        return s;
    }

启动后使用浏览器进行测试,为了演示效果,这里使用20秒延时

1)只访问read请求,访问到的是最新的数据

2)只访问write请求,正常写入数据

3)先访问read请求,再快速访问write请求,通过日志会发现write请求在等待中,直到read请求处理完成才响应write请求

4)先访问write请求,再快速访问read请求,通过日志会发现read请求在等待中,直到write请求处理完成才响应read请求

通过以上方式,会出现读写不一致情况,但可保证redis写入的数据是最新的,解决了db和缓存双写不一致问题。

posted @ 2022-06-26 16:58  钟小嘿  阅读(2594)  评论(0编辑  收藏  举报