码上面试项目实战:接口幂等性与分布式锁设计详解
1. 从“码上面试”项目说起:为什么幂等和分布式锁是绕不开的坎
“码上面试”这个项目,光看名字就知道它不是一个简单的增删改查练手项目。它模拟的是真实面试场景下的高并发、高一致性要求,比如候选人提交答案、面试官评分、系统自动判题、消息通知等一系列操作。这些操作背后,有两个技术点几乎一定会被面试官追问,也是实际开发中必须处理好的:接口幂等性和分布式锁。
我自己在参与这个项目的学习过程中,踩过不少坑。比如用户快速点击提交按钮,导致同一份答案被写入两次;比如多个服务实例同时去更新同一个面试状态,结果数据错乱。这些问题看起来是业务逻辑的bug,根子上其实是幂等和并发控制没做到位。所以这篇博文,我想把“码上面试”项目里涉及到的幂等设计、分布式锁、Double-Check以及LiteFlow这几个核心点,结合我自己的实操经验,掰开揉碎了讲清楚。
适合谁看?如果你正在做分布式项目,或者准备面试时被问到“如何保证接口幂等”“分布式锁怎么实现”“LiteFlow怎么用”,那这篇内容应该能帮你省下不少查资料的时间。我会尽量用大白话把原理讲透,同时给出可以直接参考的代码思路和配置方案。
2. 接口幂等性:不只是“防重复提交”那么简单
2.1 幂等性的本质与业务场景拆解
幂等性这个词听起来很学术,其实用一句话就能说清楚:同一个操作,执行一次和执行多次,对系统产生的影响是一样的。注意,这里说的是“影响一样”,而不是“结果一样”。比如你调用一个扣款接口,第一次扣了100块,第二次再调用,不能再扣100块,但返回结果可以是一样的“扣款成功”。
在“码上面试”项目里,哪些地方需要幂等?我梳理了一下,至少有这几个典型场景:
- 候选人提交答案:用户可能因为网络抖动或者手抖,连续点了好几次提交按钮。如果不做幂等,同一份答案会生成多条记录,后续判题和评分都会乱套。
- 面试官提交评分:评分操作往往涉及多个维度的打分,重复提交会导致分数被累加或者覆盖,影响最终结果。
- 消息通知:比如面试结果出来后,系统要发短信或邮件通知候选人。如果消息队列重试机制触发,同一条通知可能发多次,用户体验很差。
- 支付或扣减库存:虽然“码上面试”项目不一定涉及真实支付,但类似“消耗面试次数”这种操作,本质上和扣库存是一样的。
这些场景的共同点是:操作本身不是天然幂等的。比如“插入一条记录”这个动作,执行两次就会产生两条数据。而“查询操作”天然幂等,因为查一次和查十次,数据本身不会变。
2.2 幂等性检查用DB实现好还是Redis实现好
这是热词里出现的一个很实际的问题。我的结论是:没有绝对的好坏,要看具体场景和一致性要求。下面这张表是我在实际项目中总结的对比:
| 对比维度 | 数据库实现 | Redis实现 |
|---|---|---|
| 实现方式 | 唯一索引、去重表、状态机 | SETNX、SET EX NX、Lua脚本 |
| 一致性 | 强一致,依赖数据库事务 | 最终一致,依赖Redis可靠性 |
| 性能 | 相对较低,受磁盘IO影响 | 高,内存操作 |
| 复杂度 | 低,利用现有表结构即可 | 中,需考虑锁续期、误删等问题 |
| 适用场景 | 金融、订单等强一致场景 | 高并发、允许短暂不一致的场景 |
| 缺点 | 并发高时数据库压力大 | 需处理Redis宕机、网络分区 |
在“码上面试”项目里,我一开始用的是数据库唯一索引。具体做法是:在答案提交表上,对“候选人ID+题目ID+面试轮次”建一个联合唯一索引。这样即使重复提交,数据库也会直接报错,业务层捕获这个异常后返回“请勿重复提交”即可。这个方案的好处是简单可靠,不需要额外引入Redis。但缺点是,如果并发量很大,大量重复请求打到数据库,会给数据库造成不必要的压力。
后来我改成了Redis+数据库的组合方案:先用Redis的SET key value EX 60 NX做一层前置过滤,只有拿到锁的请求才允许进入业务逻辑,最后再靠数据库唯一索引兜底。这样既减轻了数据库压力,又保证了最终一致性。具体代码思路如下:
注意:Redis的key一定要设置过期时间,否则一旦业务处理失败,这个key会一直存在,导致后续正常请求也被拦截。过期时间一般设置为业务处理最大耗时的2到3倍。
2.3 幂等性设计的几个实操心得
第一个心得是:幂等键的设计比技术选型更重要。幂等键必须能唯一标识一次业务操作。比如“候选人提交答案”这个操作,幂等键应该是“候选人ID+题目ID+面试轮次”,而不是简单的“候选人ID”。因为同一个候选人在同一轮面试中可能提交多道题的答案,如果只用候选人ID做幂等键,第二道题就提交不了了。
第二个心得是:不要试图用一把锁解决所有幂等问题。有些场景适合用唯一索引,有些场景适合用状态机。比如“面试状态流转”这种操作,更适合用状态机来实现幂等:只有当前状态是“待面试”时,才能流转到“面试中”;只有当前状态是“面试中”时,才能流转到“已完成”。这样即使重复请求,状态也不会被错误地推进。
第三个心得是:幂等和防重是两回事。防重是防止重复提交,幂等是保证重复执行的结果一致。防重可以通过前端按钮置灰、Token机制来实现,但幂等必须在服务端做。因为前端防重很容易被绕过,比如用户直接调接口。
3. 分布式锁:从理论到“码上面试”项目的落地
3.1 为什么需要分布式锁
在单机环境下,我们用一个synchronized或者ReentrantLock就能解决并发问题。但在分布式环境下,多个服务实例部署在不同的机器上,本地锁只能锁住当前JVM,锁不住其他实例。这时候就需要分布式锁。
“码上面试”项目里,哪些地方需要分布式锁?我举几个例子:
- 面试房间分配:多个候选人同时请求进入面试,系统需要分配一个空闲的面试房间。如果不加锁,可能出现两个候选人被分配到同一个房间。
- 定时任务执行:比如每隔5分钟扫描一次超时未完成的面试,并自动关闭。如果多个实例同时执行这个定时任务,同一场面试可能被关闭多次。
- 缓存重建:当缓存失效时,多个请求同时去查数据库并重建缓存,如果不加锁,数据库压力会瞬间增大。
这些场景的共同点是:多个进程需要互斥地访问某个共享资源。
3.2 Redis分布式锁的正确实现方式
网上关于Redis分布式锁的文章很多,但坑也很多。我一开始用的是最简单的SETNX,后来发现一堆问题:锁无法释放、锁被误删、锁过期了业务还没执行完。下面是我总结的一个相对可靠的实现方案。
首先,加锁必须用SET key value NX EX seconds这个原子命令,而不是先SETNX再EXPIRE。因为后者不是原子操作,如果SETNX成功后服务宕机,锁就永远不会过期。
其次,解锁必须用Lua脚本,保证“判断锁是否是自己加的”和“删除锁”这两个操作的原子性。如果直接DEL,可能删掉别人的锁。
注意:锁的过期时间一定要大于业务最大执行时间。如果业务执行时间不确定,可以考虑用“看门狗”机制,定期给锁续期。但续期逻辑会增加复杂度,一般项目里设置一个合理的过期时间就够了。
3.3 Double-Check:分布式锁的优化技巧
Double-Check(双重检查)在分布式锁里是一个很实用的优化手段。它的核心思想是:在加锁前后各检查一次条件,避免不必要的加锁操作。
在“码上面试”项目里,我用Double-Check优化了缓存重建的逻辑。具体做法是:
为什么要做第二次检查?因为在高并发场景下,可能有多个请求同时发现缓存失效,然后都去抢锁。第一个请求拿到锁后重建了缓存,释放锁。第二个请求拿到锁后,如果不做第二次检查,就会再次查数据库并重建缓存,这是不必要的。第二次检查就是为了避免这种重复劳动。
提示:Double-Check在单例模式里也很常见,原理是一样的。核心就是“先检查,再加锁,加锁后再检查”。
3.4 分布式锁的常见坑与排查技巧
第一个坑是锁过期了业务还没执行完。比如你设置了30秒过期,但业务执行了35秒。这时候锁已经自动释放了,其他线程就能拿到锁,导致并发问题。解决办法是合理评估业务执行时间,或者引入续期机制。
第二个坑是误删别人的锁。比如线程A拿到锁后,业务执行时间超过了锁的过期时间,锁自动释放。线程B拿到锁,开始执行。这时候线程A执行完了,直接DEL锁,就把线程B的锁删了。解决办法就是用Lua脚本判断锁的value是不是自己的。
第三个坑是Redis主从切换导致锁丢失。在Redis主从架构下,如果主节点宕机,从节点升级为主节点,但锁数据可能还没同步过去,导致锁丢失。这个问题比较难彻底解决,一般项目里可以接受,如果要求强一致,可以考虑用RedLock或者基于ZooKeeper的分布式锁。
第四个坑是锁的粒度太粗。比如你用一把锁锁住整个面试房间表,那所有房间的操作都串行化了,性能很差。正确的做法是锁到具体的房间ID,比如lock:room:123。
4. LiteFlow:让“码上面试”的业务流程更清晰
4.1 LiteFlow是什么,为什么在面试项目里用它
LiteFlow是一个轻量级的流程编排框架,它的核心思想是把复杂的业务逻辑拆分成一个个独立的组件,然后通过规则文件把这些组件串起来。在“码上面试”项目里,一场面试的完整流程可能包括:简历筛选、笔试、技术面试、HR面试、结果通知等多个环节。如果把这些逻辑都写在一个大方法里,代码会非常臃肿,难以维护。
用LiteFlow的好处是:每个环节都是一个独立的组件,流程通过配置来定义,修改流程不需要改代码。比如面试流程从“笔试→技术面试→HR面试”改成“笔试→技术面试→加面→HR面试”,只需要改规则文件,不需要动Java代码。
4.2 LiteFlow的核心概念与配置方式
LiteFlow有几个核心概念:
- 组件(Component):每个业务节点,比如“简历筛选组件”“技术面试组件”。
- 规则(Rule):定义组件之间的执行顺序和关系,比如串行、并行、条件选择。
- 上下文(Context):在组件之间传递数据。
下面是一个简单的配置示例。假设面试流程是:先做笔试,笔试通过后做技术面试,技术面试通过后做HR面试。
在“码上面试”项目里,我用LiteFlow把面试流程编排起来后,代码结构清晰了很多。每个组件只关心自己的业务逻辑,流程控制交给LiteFlow。而且LiteFlow支持热更新规则,修改流程后不需要重启服务。
4.3 LiteFlow与幂等、分布式锁的结合实践
LiteFlow本身不解决幂等和分布式锁的问题,但它可以和这些技术结合使用。比如在“提交答案”这个环节,我可以在组件内部做幂等检查;在“分配面试房间”这个环节,我可以在组件内部加分布式锁。
一个比较实用的做法是:把幂等检查和分布式锁做成通用的组件,然后在需要的流程节点上引用。比如:
然后在规则文件里,把这个组件放在业务流程的最前面:
这样每个需要幂等的流程,只需要在规则里加上idempotentCmp即可,不需要在每个业务组件里重复写幂等逻辑。
注意:LiteFlow的组件默认是单例的,所以组件内部不要有可变的成员变量,否则会有线程安全问题。如果需要传递数据,用上下文对象。
5. 常见问题与排查技巧实录
5.1 幂等性相关问题速查表
| 问题现象 | 可能原因 | 排查思路 | 解决方案 |
|---|---|---|---|
| 重复提交仍然写入多条数据 | 幂等键设计不合理 | 检查幂等键是否能唯一标识业务操作 | 重新设计幂等键,确保包含所有业务维度 |
| Redis幂等key未过期导致正常请求被拦截 | 未设置过期时间或过期时间过长 | 查看Redis中key的TTL | 设置合理的过期时间,一般为业务耗时的2-3倍 |
| 数据库唯一索引冲突频繁 | 并发量高,大量重复请求打到数据库 | 查看数据库慢查询日志 | 增加Redis前置过滤,减轻数据库压力 |
| 幂等逻辑在事务内执行导致锁未释放 | 事务回滚但Redis key已设置 | 检查代码执行顺序 | 先做幂等检查,再开启事务 |
5.2 分布式锁相关问题速查表
| 问题现象 | 可能原因 | 排查思路 | 解决方案 |
|---|---|---|---|
| 锁无法释放 | 业务异常未执行finally解锁 | 查看日志中是否有异常 | 确保解锁逻辑在finally中执行 |
| 锁被其他线程误删 | 未判断锁的持有者 | 检查解锁代码 | 使用Lua脚本判断value后再删除 |
| 锁过期后业务仍在执行 | 过期时间设置过短 | 评估业务最大执行时间 | 增加过期时间或引入续期机制 |
| 获取锁失败后一直重试导致CPU飙升 | 重试策略不合理 | 查看线程栈 | 设置最大重试次数和重试间隔 |
| Redis主从切换后锁丢失 | 主从同步延迟 | 查看Redis主从状态 | 对一致性要求高的场景改用其他方案 |
5.3 我踩过的几个印象深刻的坑
第一个坑是幂等key用了UUID。一开始我觉得用UUID做幂等key挺方便,每次请求生成一个,后来发现完全没用。因为每次请求的UUID都不一样,根本起不到幂等的作用。幂等key必须是由业务参数生成的,比如“候选人ID+题目ID”。
第二个坑是分布式锁的过期时间设了5秒。当时觉得业务执行很快,5秒足够了。结果有一次数据库响应慢,业务执行了6秒,锁提前释放,导致两个线程同时执行。后来我把过期时间改成了30秒,并且加了监控,如果业务执行超过20秒就告警。
第三个坑是LiteFlow组件里用了成员变量。因为组件是单例的,多个请求同时进来,成员变量被覆盖,导致数据错乱。后来改成所有数据都放在上下文里传递,问题就解决了。
第四个坑是Double-Check里第二次检查忘了做。当时觉得第一次检查已经够了,结果在高并发下,多个请求同时抢到锁,重复查了好几次数据库。加上第二次检查后,数据库压力明显下降。
6. 一些实用的扩展思路
“码上面试”项目里的幂等和分布式锁,其实可以进一步抽象成通用的基础组件。比如把幂等检查封装成一个注解,在需要幂等的方法上加上@Idempotent,通过AOP自动处理。这样业务代码里就不需要写重复的幂等逻辑了。
分布式锁也可以封装成工具类,提供tryLock、unlock、lockAndExecute等方法,屏蔽底层的Redis操作细节。这样即使后续换用其他分布式锁实现,业务代码也不需要改动。
LiteFlow的规则文件可以放到配置中心,实现动态更新。比如面试流程调整了,只需要在配置中心修改规则,不需要重新部署服务。这对于频繁调整业务流程的场景非常实用。
另外,幂等和分布式锁的监控也很重要。可以记录幂等拦截次数、锁等待时间、锁持有时间等指标,通过监控面板实时观察。如果发现幂等拦截次数异常升高,可能是前端防重失效或者有恶意请求;如果锁等待时间过长,可能是锁粒度太粗或者业务执行太慢。
这些扩展思路我在实际项目中都尝试过,效果不错。尤其是注解式的幂等处理,大大减少了重复代码,新来的同事也能快速上手。