Redis使用

分布式锁

Redission

使用原生Redis设置锁的问题:

  1. 服务器拿到锁后宕机,锁不能释放,导致阻塞。
    设置锁失效时间可以解决上面的问题,但是会导致新的问题:
  2. 设置锁失效时间,在服务器负载过高的时候,会发生锁失效业务还没完成的情况,导致业务代码不互斥。

0信任:不要期待网络服务器按照理想情况运行。

使用Redission自动为锁续命,可以解决上述问题。

String lockKey = req.getBusinessUniqueKey() + "-" + req.getBusinessCode();
RLock lock = null;
try {
lock = redissonClient.getLock(lockKey);
boolean tryLock = lock.tryLock(0, TimeUnit.SECONDS);
if (!tryLock) {
LOG.info("获取锁失败");
throw new BusinessException(BusinessExceptionEnum.CONFIRM_ORDER_TICKET_COUNT_ERROR);
}

// Business Code

} catch (InterruptedException e) {
throw new RuntimeException(e);
} finally {
LOG.info("释放锁");
if (lock != null && lock.isHeldByCurrentThread()) {
lock.unlock();
}
}

锁设计原则:谁持有锁,谁才有资格释放锁。

不过,使用这个方案有问题:Redis Master加锁后宕机,新的Master没有同步到加锁的数据,就会存在多把锁。
这时我们需要引入ZooKeeper保持强一致性;或者可以使用RedLock,即集群半数以上机器加锁成功,才是真的加锁成功。
然而,这两种方法都会带来性能开销。

对于难以解决的棘手问题,应该思考如何避免问题发生。

高性能分布式锁:分段锁

针对不同段进行加锁,这样就能允许多把锁存在,从而获得一定并发性能。

基本操作

General

# 返回给定模式的keys
KEYS patter
KEYS * # 返回全部
KEYS set* # 返回set开头的keys
EXISTS key
TYPE key
DEL key

String

SET key value
GET key
# Set Extend Time
SETEX key seconds value
# Set When Key Not Exist
SETNX key value

Hash

HSET key field value
HGET key field
HDEL key field
# Get All Fields
HKEYS key
# Get All Values
HVALS key
flowchart LR
key[key]
item[
field1: value1
field2: value2
]
key --> item

List

LPUSH key value1 value2
# Get Key From Start To Stop
LRANGE key start stop
# Right POP
RPOP key
# List Length
LLEN key

典型场景

当作栈使用

订阅消息

队列,先来后到

  • 如微信、微博订阅消息
  • 阻塞队列B<L|R>POP,队列为空就等待

Set

SADD key mem1 mem2
SMEMBERS key
# Set Size
SCARD key
SINTER key1 key2
SUNION key1 key2
# Delete
SREM key mem1 mem2

典型场景

抽奖

SADD lottery {user_id}
SMEMBERS lottery
# 开奖(适用于单个奖品)
SRANDMEMBER lottery {drawing_count}
# 开奖并删除(适用于多项奖品,不重复得奖)
SPOP lottery {drawing_count}

点赞、收藏、标签

# 点赞
SADD like:{msg_id} {user_id}
# 取消
SREM like:{msg_id} {user_id}
# 用户是否点赞
SISMEMBER like:{msg_id} {user_id}
# 点赞用户列表
SMEMBERS like:{msg_id}
# 点赞用户数
SCARD like:{msg_id}

关注、商品筛选

利用集合特性运算

  • 如共同关注、推荐关注
  • 商品筛选

Sorted Set / ZSet

ZADD key score1 mem1 score2 mem2
# Show List
ZRANGE key start stop (WITHSCORES)
# Increse Member
ZINCRBY key increment member
ZREM key mem1 mem2

典型场景

日/月/年热点排行榜

# 记录浏览量
ZINCRBY hotNews:{date} 1 {news_id}
# Top 10
ZREVRANGE hotNews:{date} 0 9 WITHSCORES
# Recent 7 days
ZUNIONSTORE hotNews:{start_date}-{end_date} 0 9 WITHSCORES

Redis for Java

  • Jedis (Official Recommand)
  • Lettuce
  • Spring Data Redis

Redis Data Redis

pom.xml
<!-- Redis -->
<dependency>
<groupId>org.springframwork.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
application.yml
spring:
redis:
host: localhost
port: 6379
password: yourPassword
@Configuration
@Slf4j
public class RedisConfiguration {
@Bean
public RedisTemplate redisTemplate(RedisConnectionFactory redisConnectionFactory) {
log.info("开始创建Redis模板对象...");
RedisTemplate redisTemplate = new RedisTemplate();
// Set Redis Connection Factory Object
redisTemplate.setConnectionFactory(redisConnectionFactory);
// Set Key Serializer
redisTemplate.setKeySerializer(new StringRedisSerializer());
return redisTemplate;
}
}

持久化

RDB

# redis-cli
save
# 将会生成一个dump.rdb
bgsave
# 后台保存

AOF

保存执行的命令为日志。每次重启加载所有命令。

# redis.windows.conf
appendonly yes
appendsync always/everysec/no
auto-aof-rewrite-percentage 100
auto-aof-rewrite-min-size 64mb

# redis-cli
bgrewriteaof # 重新编排命令,让重启时执行更高效

事务

# 开始事务
multi

set key value
# ... your command
# 中途取消
discard

# 执行
exec

乐观锁

乐观锁:不认为别人会来抢占资源,所以会直接对数据进行操作,在操作时验证是否资源已被占用。

乐观锁会比较数据是否和原数据一致,一致,说明没有人抢占资源,可以修改。

watch key
# 通过版本号,而不通过值来判断
unwatch key

典型场景

对象缓存

SET user:1 <json>
MSET user:1:name the_name user1:age the_age

分布式锁

SETNX product:10003 true ex 10 nx
# success return 1, or fail return 0

... # your operation

# release the lock
DEL product:10003

计数器

INCR article:readcount:{article_id}
GET article:readcount:{article_id}

Web集群共享session

Spring Session + Redis

分布式系统全局序列号

INCRBY order_id 1000

Scan 流式遍历

SCAN cursor <KEY RegularExpression> <Count>

cursor相当于一个下标,每次查询,scan会给出下一次开始的cursor,由此实现分批查询。

Redis Lua

Lua脚本可以一次性原子地执行多条redis语句。减少网络开销、替代Redis事务。

A Redis script is transactional by defination, so everything your can do with a Redis transaction, you can also do with a script, and usually the script will be both simpler and faster.

EVAL script numkeys key [key...] arg [arg...]

numkeys: 键名参数个数
接着可以以1为基址访问keys,如KEYS[1]

$ eval "return {KEYS[1], KEYS[2], ARGV[1], ARGV[2]}" 2 key1 key2 first second

key1
key2
first
second

Jedis使用lua脚本

jedis.set("prod_stock_10016", "15");
String script = """
local count = redis.call('get', KEYS[1])
local a = tonumber(count)
local b = tonumber(ARGV[1])
if a >= b then
redis.call('set', KEYS[1], a - b)
return 1
end
return 0
"""

Object obj = jedis.eval(script, Arrays.asList("prod_stock_10016"), Arrays.asList("10"));

缓存

缓存穿透

缓存穿透:查询不存在的数据。缓存层、存储层都不命中,造成无效的数据库访问。

缓存的存在是为了控制流量和服务压力、保护后端,防止请求直接查询数据库。
但是查询不存在的数据,或者遭受恶意的缓存穿透攻击,后端服务就容易宕机。

解决方案

  1. 缓存空对象,即使恶意攻击也不能到达数据库。
  2. 布隆过滤器:不存在的数据一定不存在。直接丢弃恶意请求。

布隆过滤器是一个大型bit数组和几个不同的无偏(均匀分布的)Hash函数。

  • Set:布隆过滤器会使用多个Hash函数(如CRC16、CRC32)对Key进行哈希、取模,得到索引值。每个Hash函数都得到一个slot,然后将这些slot都设为1.
  • Get:布隆过滤器会使用同样的哈希计算,如果得出的slot中存在0,那么数据不存在,抛弃。
    但是由于使用多个Hash函数,所以即使slot全为1,也不能说明数据一定存在,只是很可能存在。
    布隆过滤器的优点是缓存空间占用少,适用于以下场景:
  • 缓存命中率低
  • 数据固定
  • 实时性低(数据集大)

Redission布隆过滤器

BloomFilter只有add操作,不能更新数据(可能破坏其他数据slot);想要更新,需要重新初始化。

RBloomFilter<String> bloomFilter = redission.getBloomFilter("name");
// 预计元素规模100_000_000,误差率3%
bloomFilter.tryInit(100000000L, 0.03);

bloomFilter.add("value");
if (!bloomFilter.contains("value")) {
throw new Exception(Enum.DATA_NOT_EXISTS);
}

缓存失效

缓存失效:同一时间大量缓存同时失效,导致数据库压力过大宕机。

解决方案

  1. 设置随机的过期时间
int expireTime = new Random().nextInt(300) + 300;
  1. 设置互斥锁,同一个类型的请求,只访问一次数据库并重建缓存。

缓存雪崩

缓存雪崩:缓存层宕机,所有请求直接访问数据库。

解决方案

  1. 保证高可用性,Redis集群
  2. 设置令牌,服务熔断、降级;让用户错峰重试
  3. 异步削峰,消息队列异步排队处理请求
  4. 做好冗余和备用方案

缓存双写一致性

两个线程并发更新数据库,由于线程切换,可能导致最终缓存结果与数据库实际数据不一致。

正确的更新方式:

  • 更新数据库后,删除缓存。
    但是如果多个线程并发读写,写完后没来得及删除缓存,读请求仍然会缓存脏数据。

解决方案

  1. 牺牲一致性,容忍短时间不一致[1]
    • 延迟删除:隔一段时间再删除一次缓存,可以丢到消息队列里异步删除
  2. 牺牲性能,读写锁串行执行,保证并发读写/写写原子执行
    • 适合读多写少场景。Redission读写锁实现如下:
// 读写锁用同一把key
RReadWriteLock rwlock = redission.getReadWriteLock(lockKey);
RLock rlock = rwlock.readLock();
RLock wlock = rwlock.writeLock();

rlock.lock();
...
wlock.lcok();
  1. 两全,增加复杂度,Cannal监听binlog,同步数据;用这个方案不需要手动更新/删除缓存
  2. 写多读多场景,不适合用缓存;缓存适合实时性、一致性要求不高的场景。

规范

键值设计

Key:业务:数据库名:id,如u:{uid}:fr:msg:{mid}
Value:拒绝BigKey,String < 10KB、数据结构元素 < 5k
非字符串BigKey,不要使用del,而是使用scan渐进删除;也要预防过期自动删除(自动删除使用del)造成阻塞。

优化BigKey

容易设计出BigKey的场景:

  1. 大V的粉丝列表
  2. 按天存储的统计/用户集合
  3. 将数据库所有字段Serializable存到Redis

优化思路:

  1. 拆分成list或hash,分段存储
  2. 局部读取、删除,而不是对整个BigKey操作

  1. 12306余票查询经常与实际库存不一致:查到有余票2,提交订单提示没票 ↩︎