为什么幂等性是面试必问题?
在分布式系统中,接口可能因网络抖动、客户端重试、消息重复消费等原因被多次调用。如果业务逻辑不保证幂等性,轻则数据重复,重则资金损失(如重复扣款)。
面试官视角:
- 考察候选人对高并发场景的设计能力。
- 检验是否具备从原理到落地的系统性思维。
8大核心方案:从原理到实战
1. Token 机制:先领令牌再办事
核心思想
客户端请求前先申请唯一 Token,服务端存储 Token 并校验,确保同一请求仅处理一次。
底层原理
- 唯一性:Token(如 UUID)全局唯一。
- 原子性:用 Redis 的
Lua 脚本实现“校验+删除”原子操作,防止并发漏洞。
应用场景
- 表单重复提交(如订单创建)。
- 支付接口防重(如网络超时后重试)。
实战代码
// 生成 Token(服务端)
public String generateToken() {
String token = UUID.randomUUID().toString();
redisTemplate.opsForValue().set(token, "1", 5, TimeUnit.MINUTES);
return token;
}
// 校验 Token(使用 Lua 保证原子性)
String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
Long result = redisTemplate.execute(
new DefaultRedisScript<>(script, Long.class),
Collections.singletonList(token),
"1"
);
if (result != 1) {
throw new RuntimeException("重复请求!");
}
面试高频问题
- Q:如何防止 Token 被重复使用?
A:校验后立即删除 Token,且用 Lua 脚本保证原子性。 - Q:Token 过期时间设置多久?
A:根据业务耗时调整,一般 5-30 分钟(如支付场景)。
2. 唯一索引:数据库的最后一道防线
核心思想
利用数据库的唯一索引约束,拦截重复数据写入。
底层原理
- B+树索引:插入数据时,数据库通过 B+树快速判断唯一键是否冲突。
- 幂等兜底:即使业务层未拦截,数据库也能通过
DuplicateKeyException防止脏数据。
应用场景
- 订单创建(订单 ID 唯一)。
- 日志流水表(请求 ID 去重)。
实战代码
-- 建表时定义唯一索引
CREATE TABLE orders (
order_id VARCHAR(64) PRIMARY KEY, -- 主键唯一
user_id BIGINT,
amount DECIMAL
);
// 业务层捕获唯一键冲突
try {
orderDao.insert(order);
} catch (DuplicateKeyException e) {
// 直接返回已存在的订单
return orderDao.selectById(order.getOrderId());
}
面试高频问题
- Q:高并发下唯一索引写入冲突怎么办?
A:业务层预生成唯一 ID(如雪花算法),减少数据库压力。 - Q:唯一索引和业务主键的区别?
A:主键是物理存储依据,唯一索引是逻辑约束,两者均可用于防重。
3. 乐观锁:版本号控制的优雅之道
核心思想
基于 CAS(Compare and Swap)机制,通过版本号控制数据更新。
底层原理
- 版本号校验:更新时校验当前版本号是否匹配,若匹配则更新并递增版本号。
- 无锁竞争:适合读多写少场景,避免悲观锁的性能开销。
应用场景
- 库存扣减(防超卖)。
- 账户余额变更。
实战代码
-- 初始数据:id=1001, stock=10, version=1
UPDATE product
SET stock = stock - 1, version = version + 1
WHERE id = 1001 AND version = 1; -- 影响行数为0则表示失败
// 业务层判断影响行数
int rows = productMapper.updateStock(productId, oldVersion);
if (rows == 0) {
throw new OptimisticLockException("库存更新失败!");
}
面试高频问题
- Q:乐观锁在高并发下大量失败怎么办?
A:结合重试机制(如消息队列异步重试),或降级为悲观锁。 - Q:版本号可以用时间戳代替吗?
A:可以,但需确保时间戳精度足够(如毫秒+序列号)。
4. 分布式锁:锁住关键操作
核心思想
通过互斥锁(如 Redis 的 SETNX)确保同一时刻只有一个请求能执行业务逻辑。
底层原理
- Redisson 看门狗:自动续期锁过期时间,避免业务未完成锁已释放。
- 锁粒度控制:按业务ID加锁(如
lock:order_1001),减少竞争。
应用场景
- 秒杀活动防超卖。
- 定时任务全局唯一执行。
实战代码
// Redisson 分布式锁示例
RLock lock = redissonClient.getLock("order_lock:" + orderId);
try {
if (lock.tryLock(5, 30, TimeUnit.SECONDS)) {
// 执行业务逻辑
}
} finally {
lock.unlock();
}
面试高频问题
- Q:分布式锁的过期时间设置多久?
A:略大于业务平均耗时,或使用 Redisson 自动续期。 - Q:Redis 锁在主从切换时可能失效,如何解决?
A:使用 RedLock 算法(需多数节点加锁成功),但仍有争议,需权衡一致性与性能。
5. 状态机:拒绝非法状态流转
核心思想
通过有限状态机(如订单状态:待支付 → 已支付 → 已完成),限制非法状态变更。
底层原理
- 状态转移规则:预定义合法状态流转路径(如待支付只能变为已支付)。
- 幂等返回:重复请求直接返回当前状态。
应用场景
- 订单状态流转。
- 工单审批流程。
实战代码
public void updateOrderStatus(String orderId, Integer newStatus) {
Order order = orderDao.selectById(orderId);
// 仅允许待支付 → 已支付
if (order.getStatus() == 0 && newStatus == 1) {
orderDao.updateStatus(orderId, newStatus);
} else {
throw new IllegalStateException("非法状态变更!");
}
}
面试高频问题
- Q:如何避免硬编码状态流转逻辑?
A:使用状态机框架(如 Spring Statemachine)或数据库配置化。
6. 其他方案补充
| 方案 | 核心原理 | 适用场景 |
|---|---|---|
| 请求日志/流水表 | 记录唯一请求ID,处理前查询去重 | 支付回调、消息队列消费 |
| 消息队列幂等 | RocketMQ 的 Message ID 去重 | 异步任务(如订单超时关闭) |
| HTTP 幂等语义 | 遵循 RESTful 规范(GET/PUT幂等) | API 设计 |
总结与面试话术
设计原则
- 唯一标识:通过 Token、唯一键等标识请求。
- 前置校验:处理前检查是否已执行(如 Redis、数据库)。
- 后置容错:通过数据库约束、乐观锁等兜底。
面试答案公式
- 分析场景:高频?依赖外部调用?
- 方案组合:如 Token + 唯一索引 + 状态机。
- 原理说明:如 Redis 原子操作、B+树索引。
- 案例佐证:“我在电商项目中用 Token 机制解决重复下单问题”。