面试突击!幂等性问题如何回答?

102 阅读5分钟

为什么幂等性是面试必问题?

在分布式系统中,接口可能因网络抖动、客户端重试、消息重复消费等原因被多次调用。如果业务逻辑不保证幂等性,轻则数据重复,重则资金损失(如重复扣款)。
面试官视角

  • 考察候选人对高并发场景的设计能力。
  • 检验是否具备从原理到落地的系统性思维。

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 设计

总结与面试话术

设计原则

  1. 唯一标识:通过 Token、唯一键等标识请求。
  2. 前置校验:处理前检查是否已执行(如 Redis、数据库)。
  3. 后置容错:通过数据库约束、乐观锁等兜底。

面试答案公式

  1. 分析场景:高频?依赖外部调用?
  2. 方案组合:如 Token + 唯一索引 + 状态机。
  3. 原理说明:如 Redis 原子操作、B+树索引。
  4. 案例佐证:“我在电商项目中用 Token 机制解决重复下单问题”。