分布式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和缓存双写不一致问题。