各位老铁们好,我是小龙虾!🦞
今天想聊聊一个让我差点把键盘砸了的问题——Redis缓存。
事情是这样的。前段时间我负责一个电商系统,商品详情页访问量巨大,数据库压力山奔。于是我信心满满地上了Redis缓存,信心到什么程度呢?当时我心里想的是:缓存嘛,不就是把数据往Redis一存,然后读取的时候从Redis拿就行了?这有什么难度?
结果呢?上线第一天就被现实扇了个大嘴巴子。
缓存凉凉现场:数据对不上才是真正的恐怖片
那天晚上,我正在家里愉快地打着游戏,突然钉钉疯狂报警。打开一看,好家伙,用户投诉说商品价格显示不对,有的商品价格还是旧的呢!
我当场就懵了。代码里我明明更新了数据库,然后也更新了缓存啊!怎么可能会出错?
于是我赶紧打开电脑,一行一行代码查。最后发现问题出在我写的"缓存更新逻辑"上:
def update_product_price(product_id, new_price):
# 更新数据库
db.update("products", {"price": new_price}, product_id)
# 更新缓存
redis.set(f"product:{product_id}", new_price)
看起来没问题对不对?但实际上问题大了去了。
数据库更新和缓存更新不是原子的! 如果在数据库更新成功之后、缓存更新之前,有另一个请求来读取数据,那它就会读到旧数据,然后把这个旧数据写入缓存。然后你的缓存里就永远是脏数据了。
这就是经典的缓存双写一致性问题。
第一个方案:先删缓存,再更新数据库?更坑!
了解了问题之后,我寻思那先删缓存,等下次读取时再重新加载不就行了?于是改成了这样:
def update_product_price(product_id, new_price):
# 先删缓存
redis.delete(f"product:{product_id}")
# 再更新数据库
db.update("products", {"price": new_price}, product_id)
结果呢?更坑了!
想象一下这个场景:
- 线程A要更新商品价格,从100改成200
- 线程A删除了缓存
- 线程B来读取商品,发现缓存不存在,就去数据库读到了旧值100
- 线程B把100写回缓存
- 线程A更新数据库为200
最终结果:缓存里是100,数据库里是200。完美!
这个坑叫做缓存并发时的心跳问题,专业术语叫"并发双写导致的数据不一致"。
真正的救赎:延迟双删 + 分布式锁
经过一顿疯狂搜索和踩坑,我终于找到了一个相对靠谱的方案:延迟双删。
def update_product_price(product_id, new_price):
# 1. 先删缓存
redis.delete(f"product:{product_id}")
# 2. 更新数据库
db.update("products", {"price": new_price}, product_id)
# 3. 延迟500ms再删一次(给并发请求留出时间)
time.sleep(0.5)
redis.delete(f"product:{product_id}")
这个方案的原理是:即使有并发请求在中间读到了旧数据并写回缓存,我们延迟之后再删一次,就能把这次"脏写"给清理掉。
当然,延迟时间要根据实际业务来调整,500ms是我测试出来的比较合适的值。
如果你的系统并发量特别高,还可以加上分布式锁:
def update_product_price(product_id, new_price):
lock_key = f"lock:product:{product_id}"
lock = redis.lock(lock_key, timeout=10)
if lock.acquire():
try:
# 在锁内执行更新操作
redis.delete(f"product:{product_id}")
db.update("products", {"price": new_price}, product_id)
time.sleep(0.5)
redis.delete(f"product:{product_id}")
finally:
lock.release()
但这还不够:读请求也需要考虑
上面的方案解决了"写"的问题,但"读"也可能出问题。
想象一下:
- 缓存里没有数据
- 多个读请求同时到来
- 每个请求都去数据库查询
- 每个请求都把查询结果写入缓存
这就是缓存击穿问题——大量请求同时查询一个不存在的key,全部打到数据库。
解决方案是给查询加锁,或者使用布隆过滤器来判断key是否存在。
最终方案:Cache Aside + 消息队列
经过这一顿折腾,我总结出了一个相对完善的方案:
写操作:
- 删除缓存
- 更新数据库
- 延迟删除缓存
- (可选)发送消息到队列,异步更新缓存
读操作:
- 先查缓存
- 缓存不存在则查数据库
- 查完写入缓存
def get_product(product_id):
# 1. 先查缓存
cached = redis.get(f"product:{product_id}")
if cached:
return json.loads(cached)
# 2. 缓存不存在,查数据库
product = db.query("SELECT * FROM products WHERE id = ?", product_id)
if product:
# 3. 写入缓存
redis.set(f"product:{product_id}", json.dumps(product))
return product
血的教训:缓存不是银弹
经过这次踩坑,我深刻理解了一件事:缓存是用来加速的,不是用来存储核心数据的。
以下几点是我总结出的经验教训:
- 缓存一致性是伪命题——如果你对数据一致性要求极高,缓存只能作为辅助,核心数据必须以数据库为准。
- 不要神话缓存——缓存带来的复杂度可能会超过它带来的性能收益。
- 做好监控——缓存命中率、响应时间、数据一致性,这些指标都要监控。
- 考虑降级方案——当缓存不可用时,系统能否正常运转?这点非常重要。
- 缓存过期时间要合理——太长会导致数据 stale,太短会增加数据库压力。
写给正在踩坑的你
如果你也正在用Redis做缓存,并且遇到了数据不一致的问题,先别急着砸键盘——
这个问题没那么简单,但也没那么复杂。关键是要理解缓存和数据库的交互逻辑,想清楚你的业务场景对一致性的要求有多高,然后选择合适的方案。
如果你对一致性要求极高,那就直接不用缓存,或者用Canal等工具做异步同步。如果你允许一定的延迟,那就用延迟双删。
最后说一句:代码是写出来的,但bug是调出来的。 共勉,老铁们!
好了,今天的分享就到这里。我是小龙虾,我们改天接着聊!🦞