1. 为什么我们需要关注数据一致性与分布式锁?
在分布式系统中,数据一致性和并发控制是两个最令人头疼的问题。我经历过一个典型的电商场景:当用户下单时,我们需要同时更新MySQL中的库存数据和Redis中的缓存数据。如果这两个操作不是原子性的,就可能出现MySQL库存已经减少,但Redis缓存仍然显示有货的情况,导致超卖问题。
同样地,在分布式环境下处理秒杀活动时,多个服务实例同时尝试修改同一个资源(比如库存),如果没有可靠的分布式锁机制,就会产生竞态条件。我曾经见过一个案例:由于锁实现不当,导致100件商品被卖出了120件,造成了严重的财务损失。
2. MySQL与Redis数据一致性解决方案
2.1 常见的数据不一致场景
在实际项目中,MySQL和Redis的数据不一致通常发生在以下几种情况:
- 先更新数据库,后删除缓存失败
- 缓存过期后,多个请求同时触发数据库查询
- 主从同步延迟期间读取从库数据
我最近处理的一个生产问题就是典型的案例:系统先更新了MySQL,但在删除Redis缓存时网络抖动导致操作失败。结果后续请求一直读取到脏数据,持续了近2小时才被发现。
2.2 最终一致性解决方案对比
2.2.1 双写模式
// 伪代码示例 @Transactional public void updateProduct(Product product) { // 更新数据库 productMapper.update(product); // 更新缓存 redisTemplate.opsForValue().set( "product:"+product.getId(), product ); }这种模式简单直接,但存在严重问题:如果第二步更新缓存失败,数据就会不一致。我在早期项目中使用过这种方式,结果发现当Redis出现波动时,数据不一致率能达到5%以上。
2.2.2 先更新数据库再删除缓存(Cache-Aside)
更可靠的模式是先更新数据库,再删除缓存:
@Transactional public void updateProduct(Product product) { // 1. 更新数据库 productMapper.update(product); // 2. 删除缓存 redisTemplate.delete("product:"+product.getId()); }这种模式下,即使第二步失败,最坏情况也只是下次查询会回源到数据库,不会出现脏数据。但要注意,在高并发场景下可能会产生短暂的不一致窗口。
2.2.3 基于消息队列的最终一致性
对于要求更高的场景,可以引入消息队列:
// 更新数据库后发送消息 @Transactional public void updateProduct(Product product) { productMapper.update(product); rocketMQTemplate.send( "cache-update-topic", new CacheMessage("delete", "product:"+product.getId()) ); } // 消费者处理消息 @RocketMQMessageListener(...) public class CacheConsumer { public void handleMessage(CacheMessage message) { if(message.getType().equals("delete")) { redisTemplate.delete(message.getKey()); } } }我在金融项目中采用这种方案,配合本地消息表(防止消息丢失),可以将不一致时间窗口控制在毫秒级。
2.3 一致性优化技巧
- 设置合理的缓存过期时间:即使是删除缓存模式,也建议设置过期时间(如30分钟)作为兜底
- 使用canal监听binlog:通过监听MySQL的binlog来触发缓存更新,确保不会遗漏
- 引入版本号机制:在缓存值中加入版本号,客户端对比版本决定是否使用缓存
重要提示:没有完美的解决方案,只有适合场景的权衡。强一致性必然牺牲性能,需要根据业务特点选择。
3. Redis分布式锁深度解析
3.1 基础实现与问题
最基础的Redis锁实现:
-- 加锁 SET lock_key unique_value NX PX 30000 -- 解锁 if redis.call("get",KEYS[1]) == ARGV[1] then return redis.call("del",KEYS[1]) else return 0 end这个方案看起来简单,但在生产环境中我踩过不少坑:
- 锁过期但业务未完成:设置30秒过期,但业务处理需要40秒,导致锁提前释放
- 主从切换问题:主节点写入锁后崩溃,从节点提升为主时可能丢失锁信息
- 锁误删:客户端A的锁被客户端B删除
3.2 Redisson最佳实践
Redisson提供了更完善的实现:
RLock lock = redisson.getLock("orderLock"); try { // 尝试加锁,最多等待100秒,上锁后30秒自动解锁 boolean res = lock.tryLock(100, 30, TimeUnit.SECONDS); if(res) { // 处理业务 } } finally { lock.unlock(); }Redisson通过"看门狗"机制解决了锁续期问题:默认每10秒检查一次,如果业务还在执行就延长锁有效期。我在电商秒杀系统中使用这种方案,成功将锁冲突处理时间降低了70%。
3.3 锁粒度优化技巧
分段锁:将商品库存分成多个段,减少锁竞争
// 对商品ID取模决定使用哪个锁 int segment = productId % 16; RLock lock = redisson.getLock("stock_" + segment);读写分离:使用Redisson的ReadWriteLock,允许多读单写
锁超时时间动态调整:根据历史执行时间动态设置锁超时
4. ZooKeeper分布式锁实现对比
4.1 基于临时节点的实现
ZooKeeper通过临时顺序节点实现分布式锁:
public class ZkLock { private ZooKeeper zk; private String lockPath; public void lock() throws Exception { // 创建临时顺序节点 String node = zk.create( "/lock/order_", new byte[0], ZooDefs.Ids.OPEN_ACL_UNSAFE, CreateMode.EPHEMERAL_SEQUENTIAL ); // 获取所有子节点并排序 List<String> children = zk.getChildren("/lock", false); Collections.sort(children); // 判断自己是否是最小节点 String myNode = node.substring("/lock/".length()); int index = children.indexOf(myNode); if(index == 0) { return; // 获取锁 } else { // 监听前一个节点 String prevNode = children.get(index-1); CountDownLatch latch = new CountDownLatch(1); zk.exists("/lock/"+prevNode, event -> { if(event.getType() == EventType.NodeDeleted) { latch.countDown(); } }); latch.await(); // 等待前一个节点释放 } } }这种方案的优点是严格有序,不会出现Redis锁的主从切换问题。但我在实际使用中发现几个性能瓶颈:
- 创建节点和监听机制导致加锁速度比Redis慢3-5倍
- 大量客户端同时竞争时,会产生"羊群效应"
- 需要维护ZooKeeper连接,增加了系统复杂度
4.2 与Redis锁的关键差异
| 特性 | Redis分布式锁 | ZooKeeper分布式锁 |
|---|---|---|
| 性能 | 高(10w+ QPS) | 中(1w-3w QPS) |
| 可靠性 | 依赖主从同步 | 基于ZAB协议强一致 |
| 锁续期 | 支持(看门狗) | 不支持(临时节点) |
| 实现复杂度 | 简单 | 较复杂 |
| 适用场景 | 高频短时操作 | 低频长时操作 |
在最近的一个物联网项目中,我们同时使用了两种锁:
- Redis锁用于设备状态更新(高频、低延迟)
- ZooKeeper锁用于配置变更(低频、强一致)
5. 生产环境中的经验教训
5.1 数据一致性监控方案
为了及时发现数据不一致问题,我们设计了以下监控体系:
定时校对任务:每小时扫描关键表,对比MySQL和Redis数据
SELECT id FROM products WHERE updated_at > NOW() - INTERVAL 1 HOUR;然后检查Redis中对应的key是否存在且一致
日志标记法:在更新数据库时记录日志ID,缓存查询时验证日志ID
// 更新时 long logId = generateLogId(); productDao.update(product); logService.record(logId, "product_update", product.getId()); // 查询时 Product product = redis.get(key); if(product != null) { long lastLog = logService.getLastLog("product_update", product.getId()); if(product.getLogId() < lastLog) { // 数据可能过期 } }客户端双读验证:1%的请求同时读取Redis和MySQL,对比结果
5.2 分布式锁的注意事项
避免锁嵌套:同一个线程重复获取锁可能导致死锁
// 错误示例 public void methodA() { lock.lock(); methodB(); lock.unlock(); } public void methodB() { lock.lock(); // 这里会阻塞 // ... lock.unlock(); }设置合理的超时时间:太长会导致系统假死,太短会增加冲突
- 根据历史执行时间P99值设置
- 增加20%-30%的缓冲时间
锁释放验证:确保只有锁持有者能释放锁
// Redisson的正确用法 if(lock.isHeldByCurrentThread()) { lock.unlock(); }故障转移处理:设计锁失效时的降级方案
- 如获取锁失败后排队等待
- 或直接返回"系统繁忙"提示
5.3 性能优化实战案例
在最近的一次大促准备中,我们对库存系统进行了以下优化:
热点数据本地缓存:在应用层增加Guava缓存,减少Redis访问
LoadingCache<Long, Integer> localCache = CacheBuilder.newBuilder() .expireAfterWrite(100, TimeUnit.MILLISECONDS) .build(new CacheLoader<Long, Integer>() { public Integer load(Long id) { return redisTemplate.opsForValue().get("stock:"+id); } });异步扣减库存:先扣Redis库存,异步同步到MySQL
public boolean deductStock(long productId, int num) { // Redis原子扣减 Long remain = redisTemplate.opsForValue() .increment("stock:"+productId, -num); if(remain >= 0) { // 发送MQ异步更新数据库 mqTemplate.send(new StockMessage(productId, num)); return true; } else { // 回滚 redisTemplate.opsForValue() .increment("stock:"+productId, num); return false; } }锁分段:将商品库存分成16段,并发能力提升8倍
这些优化使得系统在双11期间平稳支撑了10倍于平时的流量,没有出现超卖或数据不一致问题。