redis 的调用可以再精致点
分布式锁
消息折叠
“折叠” 这个词,也是回福州工作后才接触的概念。用得太久,以至于现在如果让我重新给它起个名字,都毫无灵感了。具体的场景例子是这样的:在课程业务中,学习进度的计算是一个实时性不太敏感,计算量又很重的逻辑。但前面触发进度计算的地方隐匿在系统各处,恨不得一秒触发一百次进度百分比的更新。好在中间的位置有个收口,大家都是通过同一个MQ消息触发的进度计算。于是在消息的生产者这边加了折叠逻辑, 也就是同一个学员同一个课程的进度计算,一分钟内只发送一条。多余的消息给它吞掉(忽略)。
实现的原理, 就是用 userId + courseId 作为key,给它上个redis分布式锁。同时把消息也改成延时一分钟的。
上锁的代码是五年前的人写的。某一天,我们压测过程中发现,有个毛刺,一分钟一根,就是来自这个MQ消息。打开代码一看。。哎。。 get()、set() 分开调用的,真的是批哩一点的并发概念都没有啊。
像这种地方,改成 setnx() 也就够了。但自己心里要清楚,只从技术角度看,分布式锁的实现还可以再深入很多步,做得更精致。如果有条件,应该把这类代码放入公共包里给更高级的开发人员维护。
击穿
击穿的意思是在高并发的前提下,某个缓存因为过期或淘汰策略等原因,暂时在缓存里取不到,大量的请求几乎同时打到数据库。
如何避免大量的请求同时打到数据库,是不是跟前面 消息折叠 中的毛刺问题很像。但是侧重点不同,消息折叠只需要 setnx() 就够了, 也只偏向 tryLock() 的语义。讨论击穿的问题,因为没有具体的业务和功能场景,作为一个通用的解决方案,更强调纯粹的锁的技术。在 setnx() 的基础上,再考虑下面几个问题,把锁的实现再完善一点。
- 需要把 setnx() 完善成 lock(), tryLock(), unLock() 三个最基本的操作。(没有工程思想的算法工程师不是一个好程序员)
- 如何避免 程序未执行完,锁就过期自动失效了?
- 如何避免 自己加的锁,被别人解锁了?或者自己去释放别人的锁?
问题1中,unLock() 对应删除缓存。 tryLock() 的行为与 setnx() 一致,把是否设置成功的 true/false 返回给业务代码就行。 lock() 则需要在内部处理 setnx() 的返回值,设置失败的时候需要阻塞,最简单的实现可以是随机等待几十毫秒重试。等待的实现,可以看看 LockSupport 这个类,不能只会 sleep()。
对于问题2, 可以增加一个守护线程,在工作线程执行期间,专门负责检查锁的自动过期时间,延长过期时间。注意别跟主动解锁的操作打架了,别等工作线程主动解锁了,你这还打算去延长过期时间。
对于问题3,可以从 redis 的 value 值下手,加锁时把 value 设置成自己的唯一值。解锁前检查一下。
进一步深入的问题还有很多,但是简单处理下上面的问题,就可以说这是一把可用的 分布式的 不可重入的 自旋+阻塞的 锁了。
是否一定要用分布式锁呢?JVM 提供的锁同样可以把大量的并发限制到服务实例的个数,这就可以用了,没必要精确限制到1。@Cacheable 注解的 sync 属性就提供这个功能。
雪崩
雪崩与击穿的区别,是它失效的 key 不止单个, 而是一大批 key 同时失效。
想办法把时间打散,实在没招就套用击穿的方案:
redisson
我一直是反感 redisson 的,恰恰是因为它的 开源、活力、长期的积累。导致它的模型复杂而庞大。
我一直期待,遇到一个场景,是下面这三个接口方法不够用的。
lock()
tryLock()
unlock()
何必引入庞大的接口定义呢?团队里的人都那么牛逼,hold得住嘛?JUC里的锁实现都熟悉了嘛?
这天,时间大概在一年一度的组织架构调整期间,收到一个原本是其他小组维护的服务,组织架构调整调过来的,代码来了但人没来。里面有段不停打异常日志的代码是这样的。
RLock xLock = null, xxLock = null;
try {
xLock = redissonClient.getFairLock("lockKey0");
xLock.lock(10, TimeUnit.SECONDS);
...
xLock.unlock()
...
xxLock = redissonClient.getFairLock("lockKey1");
xxLock.lock(10, TimeUnit.SECONDS);
...
xxLock.unlock()
...
} catch (Throwable e) {
...
} finally {
if (null != xLock && xLock.isLocked()) {
xLock.unlock();
}
if (null != xxLock && xxLock.isLocked()) {
xxLock.unlock();
}
}
它试图用 xxLock.isLocked()
判断 “自己是否加锁成功了”,但 xxLock.isLocked()
却完全不是这个语义,被别人锁着也是 true。
这是一位应届生职级的同学,可能还未系统学习过jdk里的并发包,心还大到敢直接顾名思义,也没有点进去看源码注释的习惯。编码习惯也不好,一边希望减小锁的粒度,混在业务代码中途调 unLock(),一边又把 try catch 的范围放到最大。
keys *
但凡是有点自主学习能力,看过官网的。。。命令介绍里那么黑的加粗说明。
以一己之力,发明了MemoryCache
跟前面讲的消息折叠里遇到的bug一样, 也是五六年前的老代码了。顺带跑个题,讲讲一个OOM的事情吧。那个服务大概启动一两周左右,内存就会满,不断地fgc,回收不掉,只能重启。
把内存dump下来,看到占内存最多的,除了日志打印,就都是同一个sql语句的缓存。特点是 where 条件里包含 in (?, ? ...)
, 这里面的 ?
可能有上万个, 而且不是固定数量。
ORM 框架会把 sql 缓存下来,但是因为 ?
的个数有几万种可能,就缓存了几万遍。
ORM 框架是动不了了,在用的低版本的没法处理这个问题, 又不敢升级版本。保底咱还能把 ?
分批,多执行几次sql,至少让内存不爆是吧。但我又挣扎了一下,想看看完整的业务逻辑,感觉不会有什么功能是必须要用这么奇葩的sql实现的。结果就发现,相关的数据库操作明显有优化过一波的痕迹,mysql前面加过一层redis,确认这个sql查询是没删干净的,直接删了就完事。
回到正题,”mysql前面加过一层redis”, 它是怎么加的呢,业务逻辑已经记不清了, 但我永远忘不了,它把 list 类型的数据整个列表从 redis 里 get() 出来, 添加一个元素,再 set() 回去。
缓存与DB的数据一致性
有这么个不解之谜,缓存里的内容和数据库中的数据不一致了。并且这部分的代码很新,很清晰。
读接口用的 @Cacheable 注解,实现了先从缓存取, 如果缓存里没有再走数据库,还会把数据库里取到的放进缓存里。
写接口用的 @CacheEvict 注解,实现了修改完数据库之后删除缓存。
已经是相对较优的实现了,再追求更强的一致性,就得去权衡代价和收益了。最终这个bug是被切了无法复现,手动清下缓存,改小了缓存有效期 就算结了。
咱们从理论上猜一下可能有哪些场景会导致数据不一致(先认为所有的远程调用都是可靠的)。
- 数据库主从延迟。写接口更新了主库,删除了缓存。读接口从从库里取出来的还是旧数据,并把旧数据又放进了缓存。
- 读接口 发现缓存里没数据, 走到数据库里去取,取完卡了一下,还没来得及放进缓存。写接口 更新完数据,删完缓存。读接口 从卡住的状态恢复过来,把旧数据放进了缓存。
关键的因素有两个,这两个因素构成了主要矛盾。
一是 读写两个接口之间的通信,只通过缓存的有无来交换信息,信息量不够。
二是 把缓存的修改操作分给了两个接口,这就需要更多信息量的交换才能协作。
我们不好去完全断开两个接口之间的通信与协作, 因为除了数据的新旧,他们还在沟通哪些数据应该进缓存。
所以就只好增加信息量,
-
写接口,可以一次多说点。假设数据中有version字段表示数据的版本。写接口 数据更新时,删除缓存的主体内容,但保留最新的version值。读接口就能根据版本号,判断要放进缓存的是不是旧数据。(这又引入了一个新的要注意的问题,多说多错,远程调用真的可靠吗,要保证缓存中的version字段和数据库中的一致, 又要进一步考虑增加两步提交的逻辑,代价太大了。完全删除的动作,不止是传递了数据更新的信息,在缓存的场景下还具有强大的容错力。)
-
写接口,可以多说几次。延迟一会儿再删一次缓存。从技术上虽然不解决根本问题,但从业务功能效果上可太香了。
-
读接口,也要有主观能动性。时不时的抽查下数据质量,不能说缓存里有数据就 100% 认为是合格的数据。那检查抽取样本的标准呢…
穿透
接口查询的是 DB 里都不存在的数据。
- 把 null 值也缓存下来 (原本把 null 特殊对待,不让它进缓存,就是一件很奇怪的事情)
- 布隆过滤器之类的东西,提前判断出 DB 里没数据。