大家好,我是邹小邹。
最近有老朋友又来吐槽:“哥,高并发下MySQL和Redis怎么做到一致性?为啥我们的系统总能查到历史脏数据?”
我想了想,只能说:兄弟,这事儿比AI推理还烧脑,缓存一致性是咱们架构圈的集体PTSD。
你以为删缓存、写数据库很简单?现实给我们上演一出出翻车现场,跟恋爱一样,少走一步就容易分手。
1. 理想很丰满,现实很打脸——三大高发事故
只要流程走对,问题就能解决?高并发的世界专治各种“不服”,理想与代码之间,隔着一只会调度的黑手。
场景一:只删一次缓存,老数据起死回生
代码大致长这样:
redisClient.Del(cacheKey)
db.Exec("UPDATE ...")
刚删了缓存,还没动数据库,另一个查询请求进来,缓存没了只好查数据库,拿到旧数据又写回了Redis。
于是,历史脏数据“诈尸”,客服电话响个不停。
场景二:写库再删缓存,结果还是坑
db.Exec("UPDATE ...")
redisClient.Del(cacheKey)
你以为换个顺序就能万无一失?并发量上来,那点时间差足够查询“捡漏”。
数据还没同步好,查的人已经排队点赞你的Bug了。
场景三:先写缓存再写库,彻底变成幻觉制造机
redisClient.Set(cacheKey, newData)
db.Exec("UPDATE ...")
数据库如果更新失败,缓存就成了假数据。AI都没你生成得快,查的人多了,调试现场直接变成脱口秀。
2. 实战门派:江湖四大法宝
门派一:延迟双删(Double Delete)——“我再给你一次机会”
流程很简单:删缓存,写库,然后睡个50毫秒的大觉,再删一次缓存。
redisClient.Del(cacheKey)
db.Exec("UPDATE ...")
time.Sleep(50 * time.Millisecond)
redisClient.Del(cacheKey)
适用场景:
1、互联网中高并发、读多写少、对一致性要求没那么极致的缓存场景最常见,比如:电商商品页、新闻、评论、榜单等。
2、数据“偶尔脏一点”无伤大雅,追求的是高性能、高吞吐。
优点:操作简单,大部分场景都能用。
缺点:高并发下还是有小概率数据“诈尸”,防得住小偷,防不住开挂的。
金句:“只删一次缓存,是给老数据留后门。”
门派二:Binlog订阅+异步补偿——“妈妈善后大法”
流程很简单:监听Binlog(MySQL记录所有数据更改(insert、update、delete)操作的日志文件。),推送消息到消息队列(如 Kafka),再由专门的 Worker 订阅消息进行缓存同步。
// 伪代码,实际用 Canal / Maxwell 等监听 binlog
for binlogEvent := range BinlogListener() {
// 推送消息到消息队列
kafkaProducer.Send(binlogEvent)
}
// Worker 消费消息,刷新缓存
for msg := range kafkaConsumer.Consume() {
// 更新或删除缓存
redisClient.Del(msg.CacheKey)
// 可选:重新设置缓存,保证数据新鲜
// redisClient.Set(msg.CacheKey, db.Query(msg.Id))
}
适用场景:
1、金融、支付、订单等对一致性有变态要求的核心业务,比如:账户余额、订单状态、资金流水等。
2、不允许出现任何“数据脏读”或“数据丢失”的情况,需要强一致性保证。
优点:强一致性,数据库与缓存严格同步,彻底杜绝“数据诈尸”。
缺点:架构升级,系统复杂,维护成本高。
金句:“把一致性玩到极致,架构师和 DBA 都得变身修仙者。”
门派三:分布式锁,写操作串行化——“谁快谁先上”
流程很简单:获取分布式锁,删缓存,写库,再删一次缓存,最后释放锁。这样,写操作被串行化了,任何时刻只能有一个写操作在处理同一份数据。
// 示例,假设用Redis分布式锁
if lock.Acquire(cacheKey) {
defer lock.Release(cacheKey)
redisClient.Del(cacheKey)
db.Exec("UPDATE ...")
time.Sleep(50 * time.Millisecond)
redisClient.Del(cacheKey)
}
适用场景:
1、金融、支付、库存等强一致性场景,如账户扣款、订单支付、积分变更、库存扣减等,不允许数据“诈尸”或并发写乱序。
2、热点数据频繁并发写,但业务量不是极致高的,比如单个用户/商品的并发写操作不会多到压垮分布式锁服务的程度。
3、多服务多节点部署,单机锁不管用,需要“全局”控制写入顺序,比如分布式抢购、限量商品发售等场景。
优点:数据一致性强,写操作严格串行,杜绝并发脏写、数据乱序、缓存脏读等问题。
缺点:锁实现要可靠,遇到锁失效、死锁等情况需兜底,写吞吐受限,不适合极端高并发写入场景。
金句:“有了分布式锁,谁先抢到谁先写,强一致性的世界,只认手速!”
门派四:写库+MQ异步同步缓存——“谁慢谁背锅”
流程很简单:写数据库的同时,把数据变更消息投递到消息队列(如 Kafka/RabbitMQ),后端有专门的 Worker 异步消费队列消息,刷新或删除缓存。适合能容忍短暂数据不一致的场景。
// 写数据库
db.Exec("UPDATE ...")
// 投递消息到队列
queue.Publish(cacheKey, changeInfo)
// Worker 异步消费,刷新缓存
for msg := range queue.Consume() {
redisClient.Del(msg.CacheKey)
// 或者 redisClient.Set(msg.CacheKey, 查询数据库最新数据)
}
适用场景:
1、数据一致性要求较高,但允许短暂延迟的场景,比如:用户资料更新、积分变化、非核心业务订单变更等。
2、写操作量大,缓存刷新的压力不宜同步堆积到主流程,需要异步削峰。
3、系统架构希望解耦,方便将来扩展,比如加日志、做异步通知、异地多活同步等。
优点:系统解耦,扩展性好,缓存同步压力不会拖慢主流程,易于横向扩展。
缺点:需要设计消息补偿机制和完善监控,否则消息丢失、Worker异常容易导致缓存长时间脏读,不能甩锅,需要对整体链路负责到底。
金句:“消息队列解耦有妙用,缓存同步慢慢来,但链路可得盯紧喽!”
3. 真实翻车现场
618大促当天,某同学自信满满地上线新代码,更新库存时只删了一次缓存。当天并发量飙升,有买家抢单成功后,马上刷新页面,结果还是看到“剩余库存100+”。大家疯狂刷单,订单激增,客服热线瞬间爆满,成了大型脱口秀现场:
“我买到的库存怎么又没了?” “刚刚付款还显示有,现在又没货了?” “客服小哥你是不是AI,你的回答怎么都一样!”
技术团队火速排查,发现罪魁祸首是只删了一次缓存。
在高并发下,数据库刚刚写完,前端流量瞬间穿透缓存,把老数据又查回来了。
刚一上线的新库存,秒变‘诈尸’回锅肉,业务和用户全被晃了一圈。
最后怎么收场?
紧急上线延迟双删方案,确保写库后再补一刀,干掉“诈尸”数据。同时,上游数据库Binlog补偿同步启动,用消息队列把所有变更再过一遍,保证所有缓存全都焕然一新。
这次事故告诉我们:
- 你以为删一次缓存就稳了,其实高并发场景下,系统早就等着你翻车。
- 延迟双删、Binlog补偿、监控预警,都是一线互联网公司的“求生三件套”。
- 大促流量如洪水猛兽,没有任何“投机取巧”的空间。
“你以为你写得很稳,其实系统早就准备好等你翻车了!”
4. 心里话
删缓存一时爽,查到旧数据两行泪。
锁是好东西,别用太猛,不然你会发现用户都在排队。
最终一致,配好监控,大多数老板都能接受,强一致?那是理论面试题,不是生活日常。
5. 血泪工具推荐
Redis分布式锁:bsm/redislock
Binlog订阅:Canal、go-mysql
消息队列:Kafka、RabbitMQ等