1. MySQL与Redis数据一致性深度解析
在分布式系统中,数据库与缓存的数据一致性问题是每个开发者都必须面对的挑战。我经历过多次因缓存与数据库不一致导致的线上事故,今天就来系统梳理这个经典问题的解决方案。
1.1 缓存与数据库不一致的本质原因
先看一个典型场景:用户下单后,订单数据写入MySQL,同时需要更新Redis中的库存缓存。如果这两个操作不是原子性的,就可能出现:
- 先更新数据库成功,但更新缓存失败
- 先删除缓存成功,但数据库更新失败
- 并发请求导致缓存与数据库写入顺序错乱
这种不一致在以下情况会被放大:
- 高并发场景(如秒杀活动)
- 网络波动导致操作失败
- 服务重启或故障转移
1.2 主流解决方案对比
方案1:Cache Aside Pattern(旁路缓存)
这是最常用的模式,核心逻辑:
def get_data(key): # 1. 先查缓存 data = redis.get(key) if not data: # 2. 缓存不存在则查数据库 data = db.query("SELECT * FROM table WHERE id=?", [key]) # 3. 写入缓存 redis.setex(key, 3600, data) return data def update_data(key, value): # 1. 先更新数据库 db.execute("UPDATE table SET value=? WHERE id=?", [value, key]) # 2. 再删除缓存 redis.delete(key)关键经验:删除缓存而非更新,避免并发写导致的脏数据。但可能引发"先更新DB后删缓存失败"的问题。
方案2:Write Through(直写)
通过中间件(如阿里云的Tair)自动同步写操作到数据库和缓存。优点是强一致,但性能损耗大,适合写少读多的场景。
方案3:延迟双删
针对高并发场景的优化方案:
def update_data(key, value): # 第一次删除 redis.delete(key) # 更新数据库 db.update(...) # 延迟再次删除 threading.Timer(0.5, lambda: redis.delete(key)).start()通过二次删除解决并发请求导致的脏数据问题。
1.3 实战中的注意事项
- 重试机制:缓存操作失败时建议采用指数退避重试
def safe_delete(key, retries=3): for i in range(retries): try: redis.delete(key) break except Exception as e: wait = (2 ** i) * 0.1 time.sleep(wait)- 缓存过期时间:建议设置随机TTL避免缓存雪崩
import random ttl = 3600 + random.randint(0, 300) # 3600~3900秒随机过期- 监控指标:必须监控的关键指标
- 缓存命中率(正常应>90%)
- 数据库QPS突增(可能缓存大面积失效)
- 缓存操作失败率
2. Redis与ZooKeeper分布式锁实现对比
分布式锁是解决并发问题的另一利器,但Redis和ZooKeeper的实现机制截然不同。
2.1 Redis分布式锁实现
基础版(SETNX)
SET lock_key unique_value NX PX 30000- NX表示key不存在时才设置
- PX设置过期时间(毫秒)
- unique_value用于安全释放锁(防止误删)
红锁算法(Redlock)
应对Redis单点故障的方案,需要至少3个独立节点:
- 获取当前时间(毫秒)
- 依次向所有节点申请锁
- 当获得多数节点(N/2+1)的锁时才算成功
- 锁的有效时间 = 原始有效时间 - 获取锁耗时
坑点警示:系统时钟漂移可能导致锁提前失效
2.2 ZooKeeper分布式锁实现
临时顺序节点方案
// 创建临时顺序节点 String lockPath = zk.create("/locks/resource_", null, ZooDefs.Ids.OPEN_ACL_UNSAFE, CreateMode.EPHEMERAL_SEQUENTIAL); // 获取所有子节点 List<String> children = zk.getChildren("/locks", false); // 判断自己是否是最小节点 if (isLowestNode(children, lockPath)) { // 获取锁成功 } else { // 监听前一个节点 waitForPreviousNode(children, lockPath); }对比Redis实现的核心差异:
- 自动释放:会话结束临时节点自动删除
- 排队机制:顺序节点天然支持公平锁
- 无过期时间:避免时钟问题
2.3 关键场景对比测试
我们在压测环境中对比了两种方案(100并发,锁持有时间100ms):
| 指标 | Redis(单节点) | Redis(Redlock) | ZooKeeper |
|---|---|---|---|
| 获取锁平均耗时(ms) | 2.3 | 15.7 | 8.2 |
| 死锁发生率 | 0.12% | 0% | 0% |
| 网络分区容忍度 | 低 | 中 | 高 |
| 吞吐量(ops/s) | 4500 | 1200 | 1800 |
选型建议:
- 追求性能:Redis单节点(需做好容灾)
- 强一致性:ZooKeeper
- 折中方案:Redlock
3. 生产环境中的血泪教训
3.1 Redis锁的典型踩坑记录
案例1:未设置唯一标识导致锁误删
# 错误示范 def release_lock(key): redis.delete(key) # 可能删除其他线程的锁 # 正确做法 def release_lock(key, identifier): script = """ if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end """ redis.eval(script, 1, key, identifier)案例2:未考虑业务执行时间超过锁过期时间
# 错误:锁过期时间小于业务执行时间 redis.set("lock", "1", nx=True, px=10000) # 10秒过期 long_running_task() # 可能执行15秒 # 解决方案:自动续期 def renew_lock(): while holding_lock: redis.pexpire("lock", 10000) time.sleep(5000) # 每5秒续期一次3.2 ZooKeeper的注意事项
- watch数量限制:单个znode的watch数量过多会影响性能
- 连接状态处理:SESSION_EXPIRED事件必须正确处理
- 脑裂问题:虽然罕见但仍需考虑,建议配合监控
4. 终极方案选型指南
经过多年实践,我的推荐方案是:
数据一致性:
- 最终一致性场景:Cache Aside + 异步补偿
- 强一致性需求:Write Through + 事务消息
分布式锁:
- 短期锁(<1s):Redis单节点
- 长期锁:ZooKeeper
- 跨地域部署:Redlock + 时钟同步
监控体系:
- Redis:slowlog监控、内存碎片率
- ZooKeeper:watch数量、znode大小
- 通用:锁等待时间、死锁检测
最后分享一个真实案例:某电商平台大促时,因Redis锁竞争导致下单接口超时。最终通过以下优化解决:
- 锁粒度细化(从商品级到SKU级)
- 引入本地缓存减少锁竞争
- 添加锁等待超时机制(避免无限等待)
这些经验都是用真金白银的线上事故换来的,希望你能避开这些坑。记住:没有银弹,只有最适合业务场景的方案。