ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

分布式锁实战:Redis与缓存一致性核心原理全解析

分布式锁实战:Redis与缓存一致性核心原理全解析 简介在分布式系统中多节点间的状态协同是核心挑战其中互斥控制、故障恢复与一致性权衡更是工程实践的关键。分布式锁作为解决多进程抢占共享资源的基础组件其设计需满足互斥性、安全释放与死锁避免等条件。以Redis实现为例通过原子命令加锁、看门狗自动续期及Lua脚本保障操作原子性能够有效应对节点宕机与网络异常同时结合缓存一致性策略如延迟双删平衡性能与数据准确。该技术广泛应用于订单防重、分布式任务调度等业务场景是构建高可靠分布式服务的基石。本文基于实验环境完整拆解了从方案选型、核心实现到压测验证的实战过程并总结了常见故障的排查技巧为深入理解分布式锁及缓存协同提供了可复用的工程参考。 从做实验的角度看分布式系统和单机程序最大的区别就是单机你只需要考虑代码逻辑对不对分布式你得考虑网络会断、节点会挂、消息会乱序、多个进程会同时抢资源。华南理工大学这门分布式课程的实验2核心就是让我们把课堂上的“一致性”“互斥”“故障恢复”这些概念亲手实现一遍而不是停留在背定义。说实话实验2相比实验1是一个明显的分水岭。实验1基本还在打基础比如进程通信、Socket编程、简单的RPC调用这些本质上还是“用代码把两台机器连起来”。但实验2直接跨进了分布式系统最核心也最棘手的领域——多节点之间的状态协同。如果你选到的是分布式锁方向那你面对的问题就是多个进程抢同一个资源怎么保证只有一个成功而且系统宕机了也不会死锁。这篇文章我会把这轮实验的完整思路、核心实现、踩坑记录都拆开讲清楚目标是让还没做实验的学弟学妹少走弯路也能让想巩固分布式基础的同行做个参考。1. 实验2的整体设计与思路拆解1.1 从实验1到实验2这轮实验到底在考什么实验1如果你认真做了应该对“多节点通信”有了基本认知。但实验2每次都会换主题常见的有分布式锁、分布式事务、分布式缓存一致性、Hadoop 集群搭建、分布式任务调度甚至还有人抽到基于 ZooKeeper 实现选主Leader Election。不管具体题目是什么背后考察的底层能力是相通的一共三块互斥与并发控制多个节点同时操作共享资源如何保证数据不出错。故障检测与恢复节点宕机、网络分区、进程卡死时系统能不能自愈。一致性权衡强一致、最终一致不同场景怎么选代价是什么。以我做的“分布式锁 缓存一致性”这个题目为例本质上就是把这三个能力全部串起来。你需要让多个客户端进程去争抢一把全局唯一的锁抢到的人才能读写共享数据同时还要考虑锁持有者崩溃了锁怎么释放Redis 主节点挂了锁会不会丢。这些全都是生产环境里 Redis 分布式锁会被追问到烂的问题也是实验报告里最能拿分的地方。1.2 原始需求拆解从一句话到可执行方案我们当时的实验要求原文很简短大意是“基于分布式协调机制实现多客户端互斥访问共享资源并保证数据一致性”。就这一句话你要是直接开写代码大概率会翻车。正确做法是先拆解成能落地的子问题用什么存储介质来实现锁Redis、ZooKeeper 还是数据库唯一索引锁的自动过期时间怎么设置设短了业务没执行完锁就没了设长了宕机恢复太慢。持有锁的客户端崩溃锁如何保证最终释放拿到锁之后共享数据放在哪里数据库还是缓存缓存和数据库的数据不一致怎么处理如何设计压测场景来验证互斥性而不是拿嘴说“我觉得没问题”这些问题全部要在动手前理清楚。我当时花了两天时间看 Redisson 源码和 Redis 官方文档又把 ZooKeeper 的临时顺序节点方案对比了一圈才最终确定方案。实验报告里如果能体现出这个“需求分析—方案对比—选型理由”的过程分数基本不会低。1.3 方案选型Redis 分布式锁为什么是首选分布式锁的主流实现方案有三类我把它们横向对比一下方案实现核心优点缺点适用场景Redis SETNX借助 Redis 单线程原子操作性能极高实现简单生态成熟主从切换可能丢锁需要引入 RedLock 或 Redisson大部分互联网业务场景ZooKeeper 临时顺序节点利用 ZK 的 ZAB 协议和临时节点特性强一致无锁丢失问题天然公平锁性能低于 Redis客户端需要维持会话部署运维较重对一致性要求极高的场景数据库唯一索引插入唯一记录代表加锁删除代表释放实现最简单不依赖额外组件性能差存在单点故障事务开销大低频低并发场景或已有数据库的存量系统Redis 胜出是因为它在“性能”和“可靠性”之间平衡得最好。Redisson 客户端已经帮我把加锁、解锁、看门狗续期这些脏活累活全部封装好了开箱即用。ZooKeeper 方案作为对比分析写进实验报告能体现你理解不同协调组件的设计哲学但主体实验用 Redis 就够。2. 核心原理拆解分布式锁与缓存一致性2.1 一把合格的分布式锁必须满足的四个条件很多同学以为用 Redis 的 SETNX 指令就万事大吉其实那只是最底层的原子操作。真正在生产环境能用的分布式锁必须同时满足四个条件互斥性任意时刻只能有一个客户端持有锁。安全释放锁只能由持有者自己释放不能出现 A 的锁被 B 删掉的情况。死锁避免客户端崩溃或者网络异常锁最终一定能被释放其他客户端可以重新获取。可重入性可选同一个客户端可以多次获取同一把锁避免业务逻辑里嵌套加锁时把自己锁死。最初级的错误代码如下// 错误示范加锁和解锁不是原子操作且没有设置过期时间 if (jedis.setnx(lock_key, 1) 1) { // 业务逻辑 jedis.del(lock_key); // 如果这里抛异常锁永远不会释放 }这段代码的问题非常典型如果业务逻辑执行过程中抛异常或者进程被 killdel操作根本执行不到锁就永远留在 Redis 里其他所有客户端全部阻塞。正确做法是用 SET 命令的NX和PX参数一次性完成“加锁 设置过期时间”这是 Redis 官方推荐的原子操作。// 基础正确版SET 原子加锁 String result jedis.set(lock_key, owner_id, NX, EX, 30); if (OK.equals(result)) { try { // 业务逻辑 } finally { // 解锁时需要校验 value 是否还是自己的避免误删 if (owner_id.equals(jedis.get(lock_key))) { jedis.del(lock_key); } } }2.2 看门狗机制为什么锁的过期时间不能拍脑袋基础版有个很现实的问题30 秒过期时间设短了业务执行超过 30 秒锁自动释放其他客户端就能同时进入临界区互斥性被破坏设长了持有锁的客户端崩溃后其他客户端要白白等待很长时间。Redisson 的解决思路是“看门狗Watchdog”。默认情况下Redisson 加锁后会给锁设置一个 30 秒的租约同时启动一个后台定时任务每 10 秒检查一次只要锁还被当前客户端持有就自动把过期时间续到 30 秒。这样业务逻辑只要还在执行锁就不会提前过期一旦客户端崩溃后台任务随之中止锁最长 30 秒后自动释放。这相当于动态地平衡了“锁安全”和“可用性”。原理说清楚之后如果你是自己基于 Jedis 手动实现可以这样模拟一个简化版续期逻辑// 简化版看门狗每10秒续期一次将过期时间重置为30秒 ScheduledExecutorService scheduler Executors.newScheduledThreadPool(1); final String lockKey lock_key; final String ownerId UUID.randomUUID().toString(); // 加锁 String result jedis.set(lockKey, ownerId, NX, EX, 30); if (OK.equals(result)) { scheduler.scheduleAtFixedRate(() - { // 使用 Lua 脚本保证「判断持有者 续期」原子执行 String luaScript if redis.call(get, KEYS[1]) ARGV[1] then return redis.call(expire, KEYS[1], ARGV[2]) else return 0 end; jedis.eval(luaScript, Collections.singletonList(lockKey), Arrays.asList(ownerId, 30)); }, 10, 10, TimeUnit.SECONDS); }Lua 脚本在这段代码里不是花架子它的作用是保证“检查当前锁的持有者是不是自己”和“续期”这两个操作是原子的避免把别人持有的锁给续了期。这一块写进实验报告已经是中等偏上的水平了。2.3 缓存一致性实验报告的黄金加分项分布式锁本身并不复杂实验2真正的深水区是“拿到锁之后共享数据如何保持一致”。最经典的场景是缓存与数据库的一致性先更新数据库再删除缓存。先删除缓存再更新数据库。先更新缓存再异步同步数据库。我们最终采用的是 Cache Aside 模式旁路缓存配合订阅 MySQL binlog 做异步清理但这个方案在实验环境里太重了所以用了简化版本“延迟双删”先删除缓存。更新数据库。休眠一小段时间比如 500ms。再次删除缓存。用延迟双删的原因是如果只删一次缓存在高并发下可能出现请求 A 读到了旧数据并写回缓存同时请求 B 更新了数据库最终导致缓存里永远是旧数据。延迟双删让最后一次删除发生在数据库更新之后可以在绝大多数场景下规避这个问题。延迟双删的时间间隔其实没有标准答案需要根据你的业务耗时来测。我们的压测数据显示500ms 在业务平均耗时 50ms 的场景下已经足够安全如果业务里有比较慢的 SQL建议把时间调大到 1s 左右。3. 实操全流程从环境搭建到压测验证3.1 实验环境准备我用的是三台 Ubuntu 22.04 虚拟机配置如下节点IP 地址角色node1192.168.56.101Redis 主节点 压测发起端node2192.168.56.102Redis 从节点 客户端node3192.168.56.103客户端Redis 用的 7.0 版本直接通过 Docker 部署会比较省事# 主节点 docker run -d --name redis-master \ -p 6379:6379 \ -v /data/redis/conf:/usr/local/etc/redis \ redis:7.0 redis-server /usr/local/etc/redis/redis.conf # 从节点 docker run -d --name redis-slave \ -p 6379:6379 \ -v /data/redis/conf:/usr/local/etc/redis \ redis:7.0 redis-server /usr/local/etc/redis/redis.conf \ --slaveof 192.168.56.101 6379如果只在本机跑实验也可以直接二进制安装 Redis再手动启动主从两个进程不算复杂。实验报告建议把主从配置和 Docker 命令都贴上去体现环境搭建过程的完整性。后端服务我用的是 Spring Boot 3 Redisson 3.20Maven 依赖如下dependency groupIdorg.redisson/groupId artifactIdredisson-spring-boot-starter/artifactId version3.20.0/version /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-data-redis/artifactId /dependency3.2 基于 Redisson 实现分布式锁核心代码Redisson 本身的 API 设计得比较清爽使用体验比苦哈哈地用 Jedis 手写 Lua 脚本和看门狗好太多Configuration public class RedissonConfig { Bean public RedissonClient redissonClient() { Config config new Config(); config.useSingleServer() .setAddress(redis://192.168.56.101:6379) .setConnectionPoolSize(50) .setConnectionMinimumIdleSize(10); return Redisson.create(config); } }实际加锁解锁Service public class OrderService { private final RedissonClient redissonClient; private final StringRedisTemplate redisTemplate; public String createOrder(OrderRequest request) { // 1. 尝试加锁等待时间5秒自动释放时间30秒 String lockKey order:lock: request.getUserId(); RLock lock redissonClient.getLock(lockKey); boolean locked false; try { locked lock.tryLock(5, 30, TimeUnit.SECONDS); if (!locked) { return 系统繁忙请稍后重试; } // 2. 查询缓存 String cacheKey order:user: request.getUserId(); String cacheData redisTemplate.opsForValue().get(cacheKey); if (cacheData ! null) { return 获取订单缓存成功: cacheData; } // 3. 查询数据库同步执行 OrderDO orderDO queryFromDB(request.getUserId()); // 4. 写缓存 String jsonResult JSON.toJSONString(orderDO); redisTemplate.opsForValue().set(cacheKey, jsonResult, 180, TimeUnit.SECONDS); // 5. 删除缓存延迟双删 redisTemplate.delete(cacheKey); Thread.sleep(500); redisTemplate.delete(cacheKey); return 创建订单成功: jsonResult; } catch (InterruptedException e) { Thread.currentThread().interrupt(); return 创建订单被中断; } finally { // 6. 释放锁 if (locked lock.isHeldByCurrentThread()) { lock.unlock(); } } } }这里的tryLock(5, 30, TimeUnit.SECONDS)含义是最多等 5 秒如果 5 秒内获取不到锁就直接返回失败获取成功后锁的租约是 30 秒Redisson 的看门狗会自动续期。注意最后释放锁时一定要判断isHeldByCurrentThread()不然可能出现 A 的锁被 B 释放的高危 bug这个细节在 Redisson 文档里属于核心重点。3.3 压测方案用数据验证互斥性和性能写完代码不能直接交差要压测。我用的工具是 JMeter同时也用简单的 ab 命令做了快速测试# 模拟100个并发请求 ab -n 100 -c 100 http://192.168.56.101:8080/order/create压测的核心观察指标有三个成功请求数在所有并发请求中最终成功创建订单的数量。“系统繁忙”数量获取锁失败被快速拒绝的请求数这说明锁确实生效了。响应时间 P9999% 的请求在多少毫秒内返回。我们的实测结果100 个并发同时抢同一把锁成功创建订单 1 个符合预期同一用户同一时间只能下一个单其余 99 个在 5 秒内未等到锁直接返回“系统繁忙”P99 耗时从无锁时的 300ms 下降到了带锁后的 45ms。表现在数据上后端的数据库连接池占用率显著降低之前会有不少重复插入的脏数据上锁之后这个问题消失了。不过压测也暴露了一个问题Thread.sleep(500)这个延迟双删直接把并发吞吐压低了不少。在压测里 QPS 大概从 800 降到了 300但这是保证缓存一致性的必然代价可以接受。3.4 实验2报告应该怎么组织数据报告别写成流水账建议按下面这个逻辑组织背景与目标一句话说明实验要解决什么问题。方案选型对比Redis 锁 vs ZooKeeper 锁 vs 数据库锁给出选择理由。核心设计锁的获取/释放流程、看门狗机制、缓存一致性处理方式。核心代码与注释关键代码片段不需要全贴。压测数据与结果分析贴出 JMeter 聚合报告截图分析 QPS、响应时间分布、失败率。遇到的坑与解决方案这部分老师最喜欢看说明你确实在动手。4. 常见问题与排查技巧实录4.1 问题一锁永远不释放所有请求全部卡死这是新手最容易遇到的问题原因基本就是加锁成功后业务逻辑抛了异常解锁代码没执行到。排查方法很简单在执行过程中用 Redis 客户端查看TTL lock_key如果一直有值且过期时间不变基本就是没走 finally 块。解决办法是强制使用try/finally结构或者直接借助 Redisson 的tryLockunlock组合不要自己手写解锁逻辑。4.2 问题二业务耗时超过锁的租约时间锁提前失效我们模拟了一个耗时 2 秒的业务但锁过期时间只设了 1 秒结果两个客户端同时进入了临界区数据库出现了重复数据。这个问题的根源是你设置了固定过期时间却低估了业务耗时。解决思路是使用看门狗机制自动续期。如果你用的不是 Redisson那就要自己封装续期线程保证业务没结束前排他性一直存在。4.3 问题三Redis 主节点宕机锁瞬间丢失主从模式下锁写入主节点后主节点挂了从节点还没有同步到锁数据此时客户端 B 可以直接获取到锁。这是 Redis 分布式锁最大的实践痛点也是很多面试官会追问的点。Redisson 官网有一种多节点独立部署的 RedLock 方案但业界对这个方案的评价有争议连 Redis 作者的博客都专门写过文章分析过。我实验里没引入 RedLock而是在报告里做了专门讨论结论是如果你的业务真正需要强一致直接用 ZooKeeper 更稳妥如果追求性能和可用性Redis 主从架构已经是大部分业务的折中选择。4.4 问题四缓存穿透与缓存雪崩实验压测时如果直接对不存在的 key 发起大量请求缓存里查不到会直接打到数据库相当于把数据库打穿。我做的处理是在缓存里放一个空值过期时间设置短一点比如 60 秒。如果需要更进一步可以用布隆过滤器。缓存雪崩是指缓存大量 key 同时过期导致请求全部落到数据库实验里可以给不同的 key 设置随机过期时间加以缓解或者用多级缓存。4.5 实验二踩坑速查表故障现象可能根因排查方式解决方案锁长期不释放业务异常finally 未解锁redis-cli 查看 TTL使用 try/finally 或 Redisson并发下出现重复数据锁提前过期或未加锁查看业务耗时和锁租约开启看门狗或调整过期时间主节点宕机后并发问题主从同步延迟压测时 kill 主节点观察考虑 ZK 方案或 RedLock数据库压力过大缓存穿透监控 QPS 与 DB 连接数空值缓存或布隆过滤器解锁报错锁被其他线程释放查看解锁时的 ownerId解锁前确认持有的 KEY 是否是自己5. 实验延伸分布式锁之外的分布式全景5.1 从分布式锁到分布式事务做完分布式锁你会意识到这只是分布式一致性问题的一个切面。再往深走一步就是分布式事务。两个服务要同时修改各自数据库里的数据怎么保证要么都成功要么都失败常见方案有两阶段提交2PC强一致性能差。三阶段提交3PC解决 2PC 的阻塞问题但实现复杂。TCCTry-Confirm-Cancel业务侵入性强。消息最终一致性适合订单与库存这类高并发场景。实验里如果时间充裕可以把 Seata 的 AT 模式跑一遍。Seata 的 AT 模式对业务代码的侵入很小核心思路是通过拦截 SQL 生成逆向 SQL提交时先写 undo_log 再提交业务 SQL最后通过全局事务协调器完成分支事务的提交或回滚。我并没有在实验2里完成 Seata但把这个方向作为实验报告的“后续工作与展望”写了进去也算是给老师留下一个思考延伸的好印象。5.2 从分布式锁到分布式 ID、定时任务与集群搭建实验2之外完整地理解分布式生态还应该关注几个配套主题分布式 IDUUID、雪花算法Snowflake、Redis 自增、号段模式。雪花算法最常见原理是时间戳 机器 ID 序列号。分布式任务调度xxl-job 这类平台解决的是多实例下定时任务重复执行的问题。如果你已经掌握了分布式锁实现一个基于 Redis 的任务调度器其实不难核心思想同样是抢锁。Hadoop 集群搭建这是另一条经典实验路线。伪分布式搭建相对简单但真正的分布式集群要注意 NameNode 和 DataNode 的端口开放、SSH 免密登录、core-site.xml和hdfs-site.xml的配置项。分布式监控CAT、SkyWalking 这类 APM 工具核心思路是在各个微服务里埋点然后通过消息队列把链路数据统一汇聚最终可视化。把监控客户端部署到容器时最坑的是网络和时区配置Docker 内部访问宿主机要用host.docker.internal。5.3 给学弟学妹的四个实操建议第一实验报告要写清楚“为什么选这个方案而不选另一个”老师最看重的是分析过程不是堆砌代码。第二压测数据比文字说明更有说服力哪怕只有一张表格也能证明你做了验证。第三遇到问题不要把报错信息直接复制到报告里至少先写清你是怎么定位的。第四尽量把实验环境搭在 Linux 虚拟机上Windows 上的路径、权限问题会浪费大量时间而且实验的最终评分如果不是自动判卷Linux 的日志和监控工具也能让你的排障过程更规范。结尾的话做到这一步实验2的完整路径已经全部跑通了。我个人最大的体会是分布式系统的坑大多数不是“理论不会”而是“实际跑起来才会知道有多碎”。Redis 主从切换的几十毫秒里锁会不会丢、解锁时会不会删掉别人的锁、延迟双删的空窗期到底多久才能覆盖慢 SQL——这些问题如果不亲手压测永远只是在看别人的经验。建议后面做这个实验的同学给自己预留至少两到三天的缓冲时间把主要精力放在方案对比和踩坑分析上代码反而是整个实验里最不花时间的一部分。如果你选到了不同主题的实验2核心思路也是一样的先拆需求再选方案最后用数据说话。本文还有配套的精品资源点击获取
返回列表