1. MySQL与Redis数据一致性问题的本质
当我们在系统中同时使用MySQL和Redis时,最头疼的就是这两个数据源之间的同步问题。我经历过一个电商项目,商品库存用MySQL保证事务性,用Redis做秒杀缓存,结果因为同步延迟导致超卖事故。这种"双写"架构下的数据不一致问题,本质上源于三个特性差异:
- 写入路径不同:MySQL走磁盘I/O,Redis走内存操作,两者速度差1-2个数量级
- 事务机制差异:MySQL支持ACID事务,Redis只有简单事务(MULTI/EXEC)
- 数据模型不匹配:MySQL是结构化存储,Redis是键值存储
关键认知:数据一致性不是非黑即白的状态,根据业务场景需要选择合适的一致性级别
2. 五种典型的数据一致性场景分析
2.1 缓存穿透场景
当查询不存在的数据时,请求会穿透Redis直接打到MySQL。我曾用BloomFilter解决这个问题:
-- MySQL商品表结构示例 CREATE TABLE `products` ( `id` bigint NOT NULL AUTO_INCREMENT, `name` varchar(100) NOT NULL, `stock` int NOT NULL DEFAULT '0', PRIMARY KEY (`id`) ) ENGINE=InnoDB;对应的Redis方案:
def get_product(product_id): # 先检查布隆过滤器 if not bloom_filter.exists(product_id): return None # 正常缓存查询流程 cache_data = redis.get(f"product:{product_id}") if cache_data: return json.loads(cache_data) # 回源查询 db_data = mysql.query("SELECT * FROM products WHERE id=%s", product_id) if db_data: redis.setex(f"product:{product_id}", 3600, json.dumps(db_data)) return db_data2.2 缓存雪崩问题
某次大促时,大量缓存同时过期导致MySQL被打挂。我们最终采用三级缓存策略:
- 本地缓存(Caffeine):5秒过期
- Redis集群:随机过期时间(基础30分钟±5分钟随机)
- MySQL:持久层保障
2.3 双写不一致案例
用户修改个人资料时,先更新MySQL成功但Redis更新失败,导致显示旧数据。我们通过以下方案解决:
// 伪代码示例:双写一致性保障 public boolean updateUser(User user) { try { // 开启MySQL事务 startTransaction(); // 先更新数据库 int affected = userDao.update(user); if(affected == 0) { rollback(); return false; } // 同步删除缓存 redis.del("user:"+user.getId()); commit(); return true; } catch(Exception e) { rollback(); // 异步重试机制 mq.sendRetryMessage(new CacheDeleteMessage("user:"+user.getId())); throw e; } }2.4 延迟同步问题
订单支付成功后,Redis库存未及时更新。我们最终采用MySQL binlog监听方案:
![数据库与缓存同步架构图] (图示说明:MySQL -> Canal -> Kafka -> Redis更新服务)
2.5 事务回滚场景
购物车结算时,MySQL扣减库存成功但Redis扣减失败。解决方案:
- 引入分布式事务(Seata)
- 最终一致性补偿(定时对账任务)
3. 主流解决方案的深度对比
3.1 删除缓存 vs 更新缓存
我们在社交APP消息系统中做过对比测试:
| 策略 | 吞吐量(QPS) | 数据不一致时间窗口 | 实现复杂度 |
|---|---|---|---|
| 先删后更新DB | 12,000 | <500ms | 低 |
| 先更新DB后删 | 10,500 | <100ms | 中 |
| 更新DB+更新缓存 | 8,200 | 理论上为0但难实现 | 高 |
最终选择"先更新DB后删缓存",虽然吞吐量降低15%,但不一致窗口缩短80%。
3.2 同步 vs 异步方案
内容平台的实践数据:
同步双写:
- 平均延迟:23ms
- 99线:156ms
- 错误率:0.7%
异步监听binlog:
- 平均延迟:58ms
- 99线:210ms
- 错误率:0.02%
经验:金融类业务用同步,内容类业务用异步
4. 实战中的七个避坑指南
缓存过期时间:不要设置统一的TTL,建议基础时间±随机偏移
# 错误示范 EXPIRE key 3600 # 正确做法 EXPIRE key 3600 + random(0, 300)重试机制:缓存操作必须实现指数退避重试
def delete_with_retry(key, max_retries=3): for i in range(max_retries): try: redis.delete(key) break except Exception as e: sleep(2 ** i) # 指数退避热点key处理:某次秒杀活动中,我们用本地缓存+Redis分片解决
- 本地缓存:1秒过期
- Redis分片:对商品ID取模分到不同实例
大value拆分:用户关系链数据超过1MB时,改用哈希存储
# 原始方案(问题) SET user:relations {...10MB数据...} # 优化方案 HSET user:relations:hash part1 "{...}" HSET user:relations:hash part2 "{...}"缓存预热:大促前通过MapReduce任务预加载
-- 预热脚本示例 SELECT CONCAT('redis-cli SET ', 'product:', id, ' ', '\'', JSON_OBJECT('id',id,'name',name,'stock',stock), '\'', ' EX 3600') FROM products INTO OUTFILE '/tmp/warmup.sh';降级策略:在Redis集群故障时,我们实现了多级降级
- 第一级:本地缓存延长TTL
- 第二级:直接读DB+限流
- 第三级:返回静态默认值
监控指标:必须监控的三个黄金指标
- 缓存命中率(<90%告警)
- 同步延迟(>1s告警)
- 不一致样本数(>0.1%告警)
5. 特殊场景的解决方案
5.1 分布式锁的注意事项
在库存扣减场景下,Redis分布式锁要这样实现:
public boolean deductStock(Long productId, int num) { String lockKey = "lock:product:" + productId; String requestId = UUID.randomUUID().toString(); try { // 尝试获取锁 boolean locked = redis.set(lockKey, requestId, "NX", "PX", 30000); if(!locked) return false; // 实际业务逻辑 Product product = getProduct(productId); if(product.getStock() >= num) { updateProductStock(productId, product.getStock() - num); return true; } return false; } finally { // 确保只释放自己的锁 if(requestId.equals(redis.get(lockKey))) { redis.del(lockKey); } } }5.2 秒杀系统优化方案
经过三次大促迭代,我们的最佳实践:
- 库存预热:提前分段扣减库存到Redis
- 内存标记:用AtomicBoolean做快速过滤
- Redis原子操作:
-- stock.lua local key = KEYS[1] local change = tonumber(ARGV[1]) local stock = tonumber(redis.call('GET', key)) if stock >= change then return redis.call('INCRBY', key, -change) end return -1
5.3 多级缓存架构
对于千万级DAU的社区系统,我们采用:
客户端 -> CDN -> 边缘缓存 -> 应用本地缓存 -> Redis集群 -> MySQL每层缓存策略:
- CDN:静态内容,1小时TTL
- 边缘节点:15分钟TTL+被动更新
- 本地缓存:5秒TTL+主动推送更新
- Redis:30分钟TTL+事件通知
6. 最新技术演进方向
Redis新特性:
- Redis 6.0的Client-side caching
- Redis 7.0的Function特性替代Lua脚本
MySQL 8.0优化:
-- 使用新的降序索引加速缓存重建 ALTER TABLE products ADD INDEX idx_stock_time (stock DESC, update_time DESC); -- 使用CTE优化缓存预热查询 WITH hot_products AS ( SELECT * FROM products WHERE sales > 1000 ORDER BY update_time DESC LIMIT 1000 ) SELECT * FROM hot_products;混合持久化方案:
- Redis + 持久化内存(PMEM)
- MySQL + 列式存储(ClickHouse)
在实际项目中,我们通过A/B测试发现,采用Redis6的客户端缓存特性后,缓存命中率提升22%,同步延迟降低65%。