广州的十一月依然带着些许闷热,宿舍楼外的香樟树在夜风里偶尔晃动。室友们大都已经睡了,只有我的机械键盘还在发出轻微的敲击声。屏幕上的代码是一段关于 Redis 的缓存逻辑。
大四了,在这个被称为民办三本的广商度过的这几年,似乎比想象中要快得多。很多人问过我,是怎么拿到 4.02 的绩点和两次国家奖学金的。其实没有什么特别的秘诀,只是在别人打游戏或者抱怨环境的时候,我更愿意坐在电脑前,看着终端里跳动的日志,去弄清楚每一个请求到底经历了什么。
在后端开发里,Redis 是绕不开的名字。书本上说它是基于内存的键值对存储数据库,但在实际的工程里,它是横在应用和物理数据库之间的一道防波堤。合理使用缓存,能将接口的响应时间从几百毫秒降到几毫秒,那种肉眼可见的流畅感,是写代码时最让人安心的反馈。但如果用不好,数据不一致、内存溢出,它也会变成埋在系统里的定时炸弹。
趁着今晚有些思绪,把这几年在项目里摸爬滚打积累的 Redis 缓存策略做个梳理。不全是理论,更多的是踩过坑之后的肌肉记忆。
最基础也最常用的,是 Cache-Aside(旁路缓存)模式。
以前写校园二手墙项目的时候,每次打开首页都要去 MySQL 里做复杂的关联查询,页面转圈要转好几秒。后来我加上了 Redis,逻辑其实很符合直觉,就像你找东西,先摸摸自己的口袋,没有的话再去翻背包,找到了再顺手放进口袋里,方便下次拿。
async function getUser(id: string) {
// 1. 先查缓存,口袋里有没有
let user = await redis.get(`user:${id}`)
if (user) return JSON.parse(user)
// 2. 缓存未命中,查数据库,去翻背包
user = await db.findById(id)
if (!user) return null
// 3. 写入缓存,顺手放进口袋,并设置过期时间
await redis.setex(`user:${id}`, 3600, JSON.stringify(user))
return user
}
这段代码现在看起来很简单,但当时第一次看到响应时间从 800ms 变成 12ms 时,我在屏幕前愣了一会儿。那是工程实践带来的最初的成就感。
除了旁路缓存,还有 Write-Through(写穿)和 Write-Behind(写回)模式。
写穿模式下,写操作会同时更新缓存和数据库。这是一种很求稳的策略,保证了数据的强一致性,但代价是写入的延迟变高了。在处理一些对一致性要求极高的金融积分流转时,我用过这种方式。
而写回模式则完全相反。它先把数据写到缓存里就直接返回成功,然后再由一个异步任务慢慢把数据同步到数据库。性能确实到了极致,但万一 Redis 宕机,数据就丢了。后来我渐渐明白,系统架构设计没有银弹,所有的技术选型,本质上都是在做权衡,看你愿意为了性能放弃多少安全性,或者为了安全承受多少延迟。
随着项目访问量的增加,一些意想不到的问题开始浮现。
首先遇到的是缓存穿透。有天晚上,服务器的 CPU 突然飙升。我连上去看日志,发现有人在用脚本疯狂请求一些根本不存在的用户 ID。因为数据库里没有这些数据,所以永远不会被写进缓存。结果就是,每一次请求都像一根针,直接穿透了 Redis,扎在脆弱的 MySQL 上。
解决这个问题,最快的方法是缓存空值。哪怕数据库里查不到,我也把这个空结果存进 Redis,只设一个很短的过期时间。后来为了更优雅,我引入了布隆过滤器。它像一个守门员,能以极小的内存代价告诉你,这个数据“绝对不存在”或者“可能存在”。把那些绝对不存在的请求直接拒之门外,数据库的压力瞬间就降下来了。
然后是缓存雪崩。那是在一次期末复习周,我做的一个校园资料库小程序在晚上八点准时崩溃了。排查了很久才发现,因为很多资料的缓存都是在同一个时间点生成的,过期时间都设了两个小时。到了八点,大量缓存同时失效,积压的请求像雪崩一样涌入数据库,直接把连接池打满了。
修复的代码其实只有一行,就是在基础的过期时间上,加上一个随机的偏移量。
看着这行代码,有时候会觉得系统和人挺像的。如果所有的事情都被设定在同一个绝对的刻度上去爆发,早晚会崩溃。加一点随机的余地,反而能让整体运转得更健康。
相比之下,缓存击穿更像是一场定点爆破。当某一个极端热点的 key(比如突然爆火的表白墙帖子)在过期的一瞬间,成百上千的并发请求发现缓存没了,就会同时去查数据库。
应对这种情况,我习惯用互斥锁。第一个发现缓存失效的请求,拿到一把锁,去数据库慢慢查,查完写回缓存再释放锁。其他拿不到锁的请求,就稍微等一等,或者直接返回旧数据。这种逻辑上的让步,换来的是整个系统的平稳。
Redis 之所以迷人,不仅仅是因为它快,还因为它提供了非常丰富的数据结构。用对了结构,很多复杂的业务逻辑会变得异常简单。
最普通的 String 用来存用户信息、Session 或是简单的计数。
做校园游戏积分排行的时候,我用了 Sorted Set。它可以自动根据分数进行排序,每次更新积分,排行榜就会实时变动,省去了在应用层写大量排序逻辑的麻烦。
在没来得及引入 RabbitMQ 之前,我用过 List 和 Stream 来做轻量级的异步消息队列,处理一些不那么核心的邮件发送任务。
有一次要做全校用户的连续签到统计,如果用关系型数据库,几万个用户每天的记录会是一张巨大的表。我用了 Bitmap,每个用户每天的签到状态只占 1 个 bit。一个月 30 天,一个用户连 4 个字节都用不到。
后来做 UV 统计,面对海量的去重需求,我用了 HyperLogLog。它虽然有大概 0.81% 的标准误差,但在统计十万、百万级别的访问量时,只需要占用 12KB 的内存。这让我深刻体会到,工程学很多时候就是在接受不完美,用微小的误差去换取巨大的资源节省。
夜已经很深了,笔记本的散热风扇偶尔转动一下,发出轻微的嗡嗡声。
在这个普通的二线城市边缘,在这个并不耀眼的学校里,我敲下了无数行这样的代码。Redis 就像是一个忠实的伙伴,它静静地躺在内存里,帮我挡住了无数次潜在的系统崩溃。
它用得好是加速器,用不好是定时炸弹。其实所有的技术都是如此,关键在于你是否真的愿意沉下心来,去理解业务的真实场景,去推敲每一个请求背后的资源消耗,然后选择一个最合适的策略。
马上就要毕业了,带着这些年积累的代码和两次国奖的证书,即将走向真正的职场。也许外面的世界会有更复杂的并发,更庞大的架构,但无论怎样,只要还能安静地坐在屏幕前,把每一个接口优化到极致,心里就是踏实的。
合上电脑,明天又是新的一天。