分布式锁
在业务量不大的情况下,确保操作原子性可以使用
synchronized
,但是在集群模式下,synchronized并不能保证操作的原子性。
分布式锁代码示例:
//等价于jedis.setnx(),如果redis中存在key为"lock"时,会返回false,否则新增,并且返回true
//加超时时间是为了防止程序宕机时,锁一直存在
String productLockKey = "lock_" + productId;
String uuid = UUID.randomUUID().toString();
Boolean result = stringRedisTemplate.opsForValue().setIfAbsent(productLockKey, uuid, 30, TimeUnit.SECONDS);
if (!result) {
log.info("{} get lock failed.", Thread.currentThread().getName());
return "服务器繁忙,请稍后再试...";
}
try {
Integer stock = Integer.valueOf(stringRedisTemplate.opsForValue().get("stock"));
if (stock > 0) {
stock--;
stringRedisTemplate.opsForValue().set("stock", stock.toString());
log.info("take order succeed,stock:{}", stock);
return "下单成功";
} else {
return "下单失败:库存不足";
}
} finally {
//实现1:防止业务执行时间超过设置timeout值时,productLockKey已失效被删除,其它线程进入重新上锁,此时上一个线程执行完毕会删除其它线程设置的锁
if (uuid.equals(stringRedisTemplate.opsForValue().get(productLockKey))) {
stringRedisTemplate.delete(productLockKey);
}
//实现2:Redisson-Rlock
}
可重入锁实现思路
- value储存一个全局唯一的requestId。
- value储存一个带有MAC地址 + jvm进程ID + 线程ID+重入次数的json字符串
Redisson
redis客户端,对分布式架构的支持很好,底层通过大量的Lua脚本实现原子操作。
Lua脚本
Redis在2.6推出了脚本功能,允许开发者使用Lua语言编写脚本传到Redis中执行。通过内置的Lua解释器,可以使用EVAL命令对Lua脚本进行求值。使用脚本的好处如下:
- 减少网络开销:本来5次网络请求的操作,可以用一个请求完成,原先5次请求的逻辑放在redis服务器上完成。使用脚本,减少了网络往返时延。这点跟管道类似。
- 原子操作:Redis会将整个脚本作为一个整体执行,中间不会被其他命令插入。管道不是原子的,不过redis的批量操作命令(类似mset)是原子的。
- 替代redis的事务功能:redis自带的事务功能很鸡肋,报错不支持回滚,而redis的lua脚本几乎实现了常规的事务功能,支持报错回滚操作,官方推荐如果要使用redis的事务功能可以用redis lua替代。
注意:不要在Lua脚本中出现死循环和耗时的运算,否则redis会阻塞,将不接受其他的命令, 所以使用时要注意不能出现死循环、耗时的运算。redis是单进程、单线程执行脚本。管道不会阻塞redis。
缓存设计
缓存穿透
缓存穿透是指查询一个根本不存在的数据(比如系统中不存在的商品ID), 缓存层和存储层都不会命中, 通常出于容错的考虑, 如果从存储层(如:数据库)查不到数据则不写入缓存层。
出现原因
- 自身业务代码或者数据出现问题。
- 一些恶意攻击、 爬虫等造成大量空命中。
解决方案
- 如果从存储层(如:数据库)查不到数据,则缓存一个空对象。最好设置一个过期时间,以防后续存在时无法获取。
- 布隆过滤器。
缓存失效
由于大批量缓存在同一时间失效可能导致大量请求同时穿透缓存直达数据库,可能会造成数据库瞬间压力过大甚至挂掉,对于这种情况我们在批量增加缓存时最好将这一批数据的缓存过期时间设置为一个时间段内的不同时间。
解决方案
- 设置随机失效时间,防止大批量缓存同时失效,造成服务压力暴增。
缓存雪崩
缓存雪崩指的是缓存层支撑不住或宕机后, 流量会直接打向后端存储层。
由于缓存层承载着大量请求, 有效地保护了存储层, 但是如果缓存层由于某些原因不能提供服务(比如超大并发过来,缓存层支撑不住,或者由于缓存设计不好,类似大量请求访问bigkey,导致缓存能支撑的并发急剧下降), 于是大量请求都会达到存储层, 存储层的调用量会暴增, 造成存储层也会级联宕机的情况。
解决方案
- 保证缓存层服务高可用性,比如使用Redis Sentinel或Redis Cluster。
- 依赖隔离组件为后端限流并降级。比如使用Hystrix限流降级组件(比如:提示活动过于火爆,请稍后再试…)。
- 提前演练。 在项目上线前, 演练缓存层宕掉后, 应用以及后端的负载情况以及可能出现的问题, 在此基础上做一些预案设定。
热点缓存重建
开发人员使用“缓存+过期时间”的策略既可以加速数据读写, 又保证数据的定期更新, 这种模式基本能够满足绝大部分需求。 但是有两个问题如果同时出现, 可能就会对应用造成致命的危害:
- 当前key是一个热点key(例如一个热门的娱乐新闻),并发量非常大。
- 重建缓存不能在短时间完成, 可能是一个复杂计算, 例如复杂的SQL、 多次IO、 多个依赖等。
在缓存失效的瞬间, 有大量线程来重建缓存, 造成后端负载加大, 甚至可能会让应用崩溃。要解决这个问题主要就是要避免大量线程同时重建缓存。我们可以利用互斥锁(分布式锁)来解决,此方法只允许一个线程重建缓存, 其他线程等待重建缓存的线程执行完, 重新从缓存获取数据即可。
性能优化
BigKey
- 字符串类型:它的big体现在单个value值很大(字符串最大为512MB),一般认为超过10KB就是bigkey。
- 非字符串类型:哈希、列表、集合、有序集合,它们的big体现在元素个数(可以存储大约40亿个元素,
2^32-1
个)太多。元素过多批量过期失效时,也会造成阻塞。
危害
- 导致redis阻塞
- 网络拥塞。假设一个bigkey为1MB,客户端每秒访问量为1000,那么每秒产生1000MB的流量,对于普通的千兆网卡根本无法支撑。
- 过期删除。尽量使用异步删除,配置
lazyfree-lazy-expire yes
生效。
优化
- bigkey进行拆分,将bigKey拆分为多个,需要的时候直接取对应数据即可。
- 考虑是否有必要取出所有元素。比如可以使用
hmget
取出部分。 - 选择合适的数据类型。
## 反例
set user:1:name tom
set user:1:age 19
set user:1:favor football
## 正例
hmset user:1 name tom age 19 favor football
- 控制key的生命周期,尽量打散过期时间,防止集中过期。
使用合理命令
- 【推荐】 O(N)命令关注N的数量例如
hgetall、lrange、smembers、zrange、sinter
等并非不能使用,但是需要明确N的值。有遍历的需求可以使用hscan、sscan、zscan代替
。 - 【推荐】:禁用命令
禁止线上使用keys、flushall、flushdb等,通过redis的rename机制禁掉命令,或者使用scan的方式渐进式处理。 - 【推荐】合理使用select
redis的多数据库较弱,使用数字进行区分,很多客户端支持较差,同时多业务用多数据库实际还
是单线程处理,会有干扰。 - 【推荐】使用批量操作(如
mset、mget
或者pipeline
—将多个命令放进管道一次性执行并返回管道中每条命令的结果)提高效率,减少网络开销。 - 【建议】Redis事务功能较弱,不建议过多使用,可以用lua替代 。
合理使用客户端
- 【推荐】避免多个应用使用一个Redis实例。
- 连接池参数优化。
- maxTotal:最大连接数。
- maxIdle
- minIdle
- 【建议】高并发下建议客户端添加熔断功能(例如netflix hystrix)
- 【推荐】设置合理的密码,如有必要可以使用SSL加密访问
- 设置合理的清除策略。
布隆过滤器
一个大型的位数组和几个不一样的无偏 hash 函数
向布隆过滤器中添加 key 时,会使用多个 hash 函数对 key 进行 hash 算得一个整数索引值然后对位数组长度进行取模运算得到一个位置,每个 hash 函数都会算得一个不同的位置。再把位数组的这几个位置都置为 1 就完成了 add 操作。
向布隆过滤器询问 key 是否存在时,跟 add 一样,也会把 hash 的几个位置都算出来,看看位数组中这几个位置是否都为 1,只要有一个位为 0,那么说明布隆过滤器中这个key 不存在。如果都是 1,这并不能说明这个key 就一定存在,只是极有可能存在,因为这些位被置为 1 可能是因为其它的 key 存在所致。如果这个位数组比较稀疏,这个概率就会很大,如果这个位数组比较拥挤,这个概率就会降低。