分布式锁与幂等设计
分布式系统里最常见的两个"防乱套"手段,就是分布式锁和幂等。它们经常一起出现,但解决的问题完全不同:
- 分布式锁:控制并发访问共享资源
- 幂等:允许重复请求或重复消息,但结果不失控
很多系统的问题恰恰来自把这两件事混用。该做幂等的地方上了锁,结果吞吐大降;该做锁保护的地方只做幂等,结果关键资源还是被并发打穿。
分布式锁与幂等的本质区别
对比表格
| 维度 | 分布式锁 | 幂等设计 |
|---|---|---|
| 解决问题 | 并发访问共享资源 | 重复请求/消息处理 |
| 关注点 | 同一时间只有一个执行者 | 即使执行多次,结果也不乱 |
| 性能影响 | 串行化,吞吐下降 | 几乎无影响 |
| 适用场景 | 定时任务、库存扣减、资源抢占 | 支付回调、消息消费、接口重试 |
| 实现复杂度 | 中等(需要考虑锁超时、续期等) | 低(状态机 + 唯一约束) |
| 失败策略 | 获取锁失败 → 等待或放弃 | 重复请求 → 直接返回成功 |
典型误区
× 错误理解:有了锁就不需要幂等
→ 锁超时释放后,请求可能重复执行
× 错误理解:有了幂等就不需要锁
→ 并发写入可能导致数据不一致
√ 正确理解:锁和幂等各司其职,互相补充分布式锁解决什么问题
分布式锁适合处理"多个节点同时争抢同一份资源"的场景,例如:
- 定时任务:只能有一个节点执行
- 数据处理:同一批数据只允许一个实例处理
- 临界区保护:某个短临界区需要串行化
它不适合当成通用并发控制手段,更不适合拿去包长事务。
分布式锁的核心要求
一个可靠的分布式锁必须满足:
- 互斥性:任意时刻,只有一个客户端持有锁
- 防死锁:锁必须有过期时间,避免客户端崩溃后锁无法释放
- 防误删:只能删除自己加的锁,不能删除别人的锁
- 高可用:锁服务本身要高可用
- 可重入:同一个线程可以多次获取同一把锁(可选)
幂等解决什么问题
幂等面对的是重复执行问题:
- 用户重复提交:连续点击多次按钮
- 接口超时重试:客户端超时后自动重试
- 消息重复投递:MQ 至少一次投递语义
- 第三方回调:支付平台多次通知
幂等的目标是:
同一个业务请求执行多次,最终结果保持一致,不产生额外副作用
幂等的数学定义
f(x) = f(f(x))
即:执行一次和执行多次的结果相同为什么锁和幂等不能互相替代
分布式锁关注的是:
- "同一时间只有一个执行者"
幂等关注的是:
- "即使执行了多次,结果也不乱"
所以:
- 有锁,不代表重复请求就一定安全(锁超时后可能重复执行)
- 有幂等,也不代表共享资源并发冲突就一定消失(并发写入可能导致数据不一致)
常见分布式锁实现方式对比
| 实现方式 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| Redis SET NX | 性能高、实现简单 | 主从切换可能丢锁、需要续期 | 中等一致性要求 |
| Redisson | 功能完善、支持续期、可重入 | 依赖 Redis 集群稳定性 | 生产环境推荐 |
| ZooKeeper | 强一致、可靠性高 | 性能较低、实现复杂 | 强一致性要求 |
| 数据库锁 | 简单、无需额外组件 | 性能差、锁表风险 | 低并发场景 |
| etcd | 强一致、支持租约 | 需要额外部署 | 云原生环境 |
Redis 分布式锁实现详解
基础实现:SET NX EX
最常见的是基于 Redis 的:
SET key value NX EX seconds- NX:只在 key 不存在时设置(Not eXists)
- EX:设置过期时间(EXpire)
完整实现示例(防误删)
@Service
public class RedisDistributedLock {
@Autowired
private StringRedisTemplate redisTemplate;
private static final String LOCK_PREFIX = "lock:";
private static final long DEFAULT_EXPIRE = 30; // 默认 30 秒
/**
* 加锁
* @param lockKey 锁的 key
* @param requestId 请求唯一标识(用于防误删)
* @param expireSeconds 过期时间(秒)
* @return 是否加锁成功
*/
public boolean tryLock(String lockKey, String requestId, long expireSeconds) {
String key = LOCK_PREFIX + lockKey;
Boolean result = redisTemplate.opsForValue()
.setIfAbsent(key, requestId, expireSeconds, TimeUnit.SECONDS);
return Boolean.TRUE.equals(result);
}
/**
* 释放锁(Lua 脚本保证原子性)
* @param lockKey 锁的 key
* @param requestId 请求唯一标识
* @return 是否释放成功
*/
public boolean releaseLock(String lockKey, String requestId) {
String key = LOCK_PREFIX + lockKey;
String script =
"if redis.call('get', KEYS[1]) == ARGV[1] then " +
" return redis.call('del', KEYS[1]) " +
"else " +
" return 0 " +
"end";
RedisScript<Long> redisScript = RedisScript.of(script, Long.class);
Long result = redisTemplate.execute(
redisScript,
Collections.singletonList(key),
requestId
);
return Long.valueOf(1).equals(result);
}
}使用示例
@Service
public class DailySettlementService {
@Autowired
private RedisDistributedLock lockService;
@Autowired
private SettlementService settlementService;
public void executeDailySettlement() {
String lockKey = "job:daily-settlement";
String requestId = UUID.randomUUID().toString();
// 尝试加锁
if (lockService.tryLock(lockKey, requestId, 300)) { // 5 分钟过期
try {
// 执行任务
settlementService.execute();
} finally {
// 释放锁(只释放自己加的锁)
lockService.releaseLock(lockKey, requestId);
}
} else {
log.info("其他节点正在执行,本次跳过");
}
}
}Redis 锁的核心问题与解决
问题 1:锁超时后业务还没执行完怎么办?
问题场景:
- 线程 A 获取锁,设置 30 秒过期
- 业务执行超过 30 秒
- 锁自动释放
- 线程 B 获取锁并开始执行
- 线程 A 执行完毕,释放了线程 B 的锁
解决方案:锁续期(Watch Dog)
@Service
public class RedisLockWithWatchdog {
@Autowired
private StringRedisTemplate redisTemplate;
private ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(10);
private ConcurrentHashMap<String, ScheduledFuture<?>> watchdogs = new ConcurrentHashMap<>();
/**
* 加锁并启动看门狗
*/
public boolean tryLockWithWatchdog(String lockKey, String requestId, long expireSeconds) {
boolean locked = tryLock(lockKey, requestId, expireSeconds);
if (locked) {
// 启动看门狗,每隔过期时间的 1/3 续期一次
long watchInterval = expireSeconds * 1000 / 3;
ScheduledFuture<?> future = scheduler.scheduleAtFixedRate(() -> {
try {
renewLock(lockKey, requestId, expireSeconds);
} catch (Exception e) {
log.error("锁续期失败", e);
}
}, watchInterval, watchInterval, TimeUnit.MILLISECONDS);
watchdogs.put(lockKey, future);
}
return locked;
}
/**
* 续期
*/
private boolean renewLock(String lockKey, String requestId, long expireSeconds) {
String key = "lock:" + lockKey;
String script =
"if redis.call('get', KEYS[1]) == ARGV[1] then " +
" return redis.call('expire', KEYS[1], ARGV[2]) " +
"else " +
" return 0 " +
"end";
RedisScript<Long> redisScript = RedisScript.of(script, Long.class);
Long result = redisTemplate.execute(
redisScript,
Collections.singletonList(key),
requestId,
String.valueOf(expireSeconds)
);
return Long.valueOf(1).equals(result);
}
/**
* 释放锁并停止看门狗
*/
public boolean releaseLockWithWatchdog(String lockKey, String requestId) {
// 停止看门狗
ScheduledFuture<?> future = watchdogs.remove(lockKey);
if (future != null) {
future.cancel(false);
}
// 释放锁
return releaseLock(lockKey, requestId);
}
}问题 2:解锁时如何避免误删别人加的锁?
问题场景:
- 线程 A 获取锁
- 业务执行超时,锁自动过期
- 线程 B 获取锁
- 线程 A 执行完毕,直接 DEL 删除了线程 B 的锁
解决方案:Lua 脚本保证原子性
-- 释放锁的 Lua 脚本
if redis.call('get', KEYS[1]) == ARGV[1] then
return redis.call('del', KEYS[1])
else
return 0
end只有在 value 等于自己的 requestId 时才删除,保证不会误删别人的锁。
问题 3:Redis 主从切换时锁状态是否存在风险?
问题场景:
- 客户端 A 在 Master 上加锁成功
- Master 还未同步到 Slave 就宕机
- Slave 升级为新的 Master
- 客户端 B 在新 Master 上加锁成功
- 两个客户端同时持有锁
解决方案:Redlock 算法
Redlock 算法核心思想:在多个独立的 Redis 节点上加锁,只有超过半数节点加锁成功才算成功。
// 使用 Redisson 实现 Redlock
Config config1 = new Config();
config1.useSingleServer().setAddress("redis://node1:6379");
Config config2 = new Config();
config2.useSingleServer().setAddress("redis://node2:6379");
Config config3 = new Config();
config3.useSingleServer().setAddress("redis://node3:6379");
RedissonClient client1 = Redisson.create(config1);
RedissonClient client2 = Redisson.create(config2);
RedissonClient client3 = Redisson.create(config3);
RLock lock1 = client1.getLock("myLock");
RLock lock2 = client2.getLock("myLock");
RLock lock3 = client3.getLock("myLock");
RedissonRedLock redLock = new RedissonRedLock(lock1, lock2, lock3);
try {
// 尝试加锁
boolean locked = redLock.tryLock(10, 30, TimeUnit.SECONDS);
if (locked) {
// 执行业务
}
} finally {
redLock.unlock();
}Redisson 推荐配置
生产环境推荐使用 Redisson,它已经内置了锁续期、可重入等能力:
@Configuration
public class RedissonConfig {
@Bean
public RedissonClient redissonClient() {
Config config = new Config();
// 单节点配置
config.useSingleServer()
.setAddress("redis://127.0.0.1:6379")
.setConnectionPoolSize(64)
.setConnectionMinimumIdleSize(24)
.setIdleConnectionTimeout(10000)
.setConnectTimeout(10000)
.setTimeout(3000)
.setRetryAttempts(3)
.setRetryInterval(1500);
// 集群配置
// config.useClusterServers()
// .addNodeAddress("redis://node1:6379", "redis://node2:6379", "redis://node3:6379");
return Redisson.create(config);
}
}
@Service
public class DistributedLockService {
@Autowired
private RedissonClient redissonClient;
public void executeWithLock(String lockKey, Runnable task) {
RLock lock = redissonClient.getLock(lockKey);
try {
// 尝试加锁,最多等待 10 秒,锁 30 秒后自动释放
boolean locked = lock.tryLock(10, 30, TimeUnit.SECONDS);
if (locked) {
task.run();
} else {
throw new RuntimeException("获取锁失败");
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new RuntimeException("线程被中断", e);
} finally {
if (lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
}
}所以分布式锁更适合:
- 短
- 快
- 可降级
而不是把核心交易链路全部压在它上面。
常见幂等实现方式
幂等实现方式对比
| 实现方式 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| 业务唯一单号 | 简单、可靠 | 需要业务支持 | 支付、订单等有单号的场景 |
| 数据库唯一约束 | 强一致、可靠 | 依赖数据库、可能锁表 | 插入型操作 |
| 幂等 Token | 灵活、可控 | 需要双方配合 | 接口幂等 |
| 状态机约束 | 业务语义清晰 | 需要设计状态机 | 状态流转类操作 |
| 去重表 | 解耦业务 | 需要额外表 | 通用幂等 |
| Redis 标记 | 性能高 | 需要持久化配合 | 快速幂等判断 |
实现方式一:业务唯一单号
@Service
public class PaymentService {
@Autowired
private PaymentRecordRepository paymentRepository;
@Transactional(rollbackFor = Exception.class)
public void handlePayment(PaymentRequest request) {
// 幂等检查:是否已处理
if (paymentRepository.existsByPayNo(request.getPayNo())) {
log.info("支付已处理,幂等返回: {}", request.getPayNo());
return;
}
// 执行业务逻辑
// ...
// 记录支付流水
PaymentRecord record = PaymentRecord.builder()
.payNo(request.getPayNo())
.amount(request.getAmount())
.build();
paymentRepository.save(record);
}
}实现方式二:数据库唯一约束
-- 创建唯一索引
CREATE UNIQUE INDEX uk_order_no ON orders(order_no);@Service
public class OrderService {
@Autowired
private OrderRepository orderRepository;
@Transactional(rollbackFor = Exception.class)
public Order createOrder(CreateOrderCommand command) {
try {
Order order = Order.create(command);
return orderRepository.save(order);
} catch (DataIntegrityViolationException e) {
// 唯一索引冲突,说明订单已存在
log.info("订单已存在,幂等返回: {}", command.getOrderNo());
return orderRepository.findByOrderNo(command.getOrderNo());
}
}
}实现方式三:幂等 Token
流程:
- 客户端先请求获取 Token
- 服务端生成 Token 并存储(Redis)
- 客户端携带 Token 发起请求
- 服务端验证并删除 Token(原子操作)
- 验证成功执行业务,失败返回重复请求
@Service
public class IdempotentTokenService {
@Autowired
private StringRedisTemplate redisTemplate;
private static final String TOKEN_PREFIX = "idempotent:token:";
/**
* 生成 Token
*/
public String createToken(String bizType) {
String token = UUID.randomUUID().toString();
String key = TOKEN_PREFIX + bizType + ":" + token;
// Token 有效期 5 分钟
redisTemplate.opsForValue().set(key, "1", 5, TimeUnit.MINUTES);
return token;
}
/**
* 验证并删除 Token(原子操作)
*/
public boolean validateAndDeleteToken(String bizType, String token) {
String key = TOKEN_PREFIX + bizType + ":" + token;
// Lua 脚本:存在则删除,返回 1;不存在返回 0
String script =
"if redis.call('exists', KEYS[1]) == 1 then " +
" return redis.call('del', KEYS[1]) " +
"else " +
" return 0 " +
"end";
RedisScript<Long> redisScript = RedisScript.of(script, Long.class);
Long result = redisTemplate.execute(redisScript, Collections.singletonList(key));
return Long.valueOf(1).equals(result);
}
}
@RestController
public class OrderController {
@Autowired
private IdempotentTokenService tokenService;
@Autowired
private OrderService orderService;
/**
* 获取 Token
*/
@GetMapping("/api/order/token")
public String getToken() {
return tokenService.createToken("order");
}
/**
* 创建订单(幂等)
*/
@PostMapping("/api/order/create")
public Order createOrder(
@RequestHeader("Idempotent-Token") String token,
@RequestBody CreateOrderCommand command
) {
// 验证 Token
if (!tokenService.validateAndDeleteToken("order", token)) {
throw new RuntimeException("重复请求或 Token 已失效");
}
// 执行业务
return orderService.createOrder(command);
}
}实现方式四:状态机约束
@Service
public class OrderStateMachine {
@Autowired
private OrderRepository orderRepository;
// 定义允许的状态流转
private static final Map<OrderStatus, Set<OrderStatus>> ALLOWED_TRANSITIONS = Map.of(
OrderStatus.INIT, Set.of(OrderStatus.PAID, OrderStatus.CANCELLED),
OrderStatus.PAID, Set.of(OrderStatus.SHIPPED, OrderStatus.REFUNDED),
OrderStatus.SHIPPED, Set.of(OrderStatus.COMPLETED),
OrderStatus.COMPLETED, Set.of(),
OrderStatus.CANCELLED, Set.of(),
OrderStatus.REFUNDED, Set.of()
);
@Transactional(rollbackFor = Exception.class)
public void payOrder(String orderNo) {
Order order = orderRepository.findByOrderNo(orderNo);
// 状态机检查:只允许 INIT -> PAID
if (!canTransition(order.getStatus(), OrderStatus.PAID)) {
throw new IllegalStateException(
"订单状态不允许支付: " + order.getStatus()
);
}
// 更新状态
order.setStatus(OrderStatus.PAID);
order.setPayTime(LocalDateTime.now());
orderRepository.save(order);
}
private boolean canTransition(OrderStatus from, OrderStatus to) {
Set<OrderStatus> allowedTargets = ALLOWED_TRANSITIONS.get(from);
return allowedTargets != null && allowedTargets.contains(to);
}
}实现方式五:去重表
CREATE TABLE idempotent_record (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
biz_type VARCHAR(50) NOT NULL COMMENT '业务类型',
biz_key VARCHAR(200) NOT NULL COMMENT '业务唯一键',
request_hash VARCHAR(64) COMMENT '请求参数哈希(可选)',
create_time DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY uk_biz_type_key (biz_type, biz_key)
) COMMENT '幂等记录表';@Service
public class IdempotentRecordService {
@Autowired
private IdempotentRecordRepository idempotentRepository;
/**
* 检查并记录(原子操作)
*/
@Transactional(rollbackFor = Exception.class)
public boolean checkAndRecord(String bizType, String bizKey) {
try {
IdempotentRecord record = IdempotentRecord.builder()
.bizType(bizType)
.bizKey(bizKey)
.build();
idempotentRepository.save(record);
return true; // 首次请求
} catch (DataIntegrityViolationException e) {
return false; // 重复请求
}
}
}实现方式六:Redis 标记(配合数据库持久化)
@Service
public class RedisIdempotentService {
@Autowired
private StringRedisTemplate redisTemplate;
@Autowired
private IdempotentRecordRepository dbRepository;
private static final String IDEMPOTENT_PREFIX = "idempotent:";
/**
* 检查幂等(先查 Redis,再查数据库)
*/
public boolean isProcessed(String bizType, String bizKey) {
String key = IDEMPOTENT_PREFIX + bizType + ":" + bizKey;
// 1. 先查 Redis
if (Boolean.TRUE.equals(redisTemplate.hasKey(key))) {
return true;
}
// 2. 再查数据库(防止 Redis 数据丢失)
return dbRepository.existsByBizTypeAndBizKey(bizType, bizKey);
}
/**
* 标记已处理
*/
public void markProcessed(String bizType, String bizKey) {
String key = IDEMPOTENT_PREFIX + bizType + ":" + bizKey;
// 1. 写入 Redis(过期时间 1 天)
redisTemplate.opsForValue().set(key, "1", 1, TimeUnit.DAYS);
// 2. 写入数据库(持久化)
IdempotentRecord record = IdempotentRecord.builder()
.bizType(bizType)
.bizKey(bizKey)
.build();
dbRepository.save(record);
}
}其中最稳的一类通常是"业务结果层约束",比如数据库唯一键和状态机,而不是只靠入口防抖。
示例场景
场景一:支付回调幂等
第三方支付平台可能重复通知多次。
正确做法通常是:
- 按支付单号做幂等
- 订单状态只允许
INIT -> PAID - 已经处理过的通知直接返回成功
这个场景通常不需要分布式锁,核心是状态机和幂等约束。
完整实现:
@Service
public class PaymentCallbackService {
@Autowired
private OrderRepository orderRepository;
@Autowired
private PaymentRecordRepository paymentRepository;
@Autowired
private EventPublisher eventPublisher;
@Transactional(rollbackFor = Exception.class)
public void handlePaymentCallback(PaymentCallback callback) {
// 1. 幂等检查:是否已处理
if (paymentRepository.existsByPayNo(callback.getPayNo())) {
log.info("支付回调已处理,幂等返回: {}", callback.getPayNo());
return;
}
// 2. 查询订单
Order order = orderRepository.findByOrderNo(callback.getOrderNo());
if (order == null) {
throw new RuntimeException("订单不存在: " + callback.getOrderNo());
}
// 3. 状态机检查:只允许 INIT -> PAID
if (order.getStatus() != OrderStatus.INIT) {
log.warn("订单状态不允许支付: orderNo={}, status={}",
order.getOrderNo(), order.getStatus());
return; // 直接返回,不抛异常
}
// 4. 更新订单状态
order.setStatus(OrderStatus.PAID);
order.setPayTime(callback.getPayTime());
orderRepository.save(order);
// 5. 记录支付流水
PaymentRecord record = PaymentRecord.builder()
.payNo(callback.getPayNo())
.orderNo(order.getOrderNo())
.amount(callback.getAmount())
.payTime(callback.getPayTime())
.build();
paymentRepository.save(record);
// 6. 发送下游事件(异步)
eventPublisher.publish("order-paid", order);
}
}场景二:定时任务单实例执行
报表汇总、库存对账、补偿扫描这类任务,经常要求同一时刻只能一个实例跑。
这时分布式锁就比较合适:
- 加锁成功的节点执行任务
- 其他节点快速退出
- 任务结束后正常释放锁
实现要点:
@Service
public class DailySettlementJob {
@Autowired
private RedissonClient redissonClient;
@Autowired
private SettlementService settlementService;
@Scheduled(cron = "0 0 2 * * ?") // 每天凌晨 2 点执行
public void execute() {
RLock lock = redissonClient.getLock("job:daily-settlement");
try {
// 尝试加锁,最多等待 0 秒,锁 1 小时后自动释放
boolean locked = lock.tryLock(0, 3600, TimeUnit.SECONDS);
if (locked) {
log.info("获取锁成功,开始执行对账任务");
settlementService.execute();
} else {
log.info("其他节点正在执行,本次跳过");
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
log.error("获取锁被中断", e);
} finally {
if (lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
}
}场景三:秒杀下单
秒杀场景如果对每个请求都上全局锁,吞吐会立刻崩掉。
更合理的思路通常是:
- 入口限流:令牌桶、漏桶、滑动窗口
- 库存预扣减:Redis 原子操作
- 业务单号幂等:防止重复下单
- 细粒度锁:必要时只对热点资源做细粒度锁控制
这里幂等和限流通常比"大锁包全场"更重要。
实现方案:
@Service
public class SeckillService {
@Autowired
private StringRedisTemplate redisTemplate;
@Autowired
private OrderService orderService;
/**
* 秒杀下单
*/
public OrderResult seckill(SeckillRequest request) {
String productId = request.getProductId();
String userId = request.getUserId();
// 1. 幂等检查:是否已下单
String orderKey = "seckill:order:" + productId + ":" + userId;
if (Boolean.TRUE.equals(redisTemplate.hasKey(orderKey))) {
throw new RuntimeException("您已参与过秒杀");
}
// 2. 库存扣减(Lua 脚本保证原子性)
String stockKey = "seckill:stock:" + productId;
String stockScript =
"if tonumber(redis.call('get', KEYS[1])) > 0 then " +
" return redis.call('decr', KEYS[1]) " +
"else " +
" return -1 " +
"end";
RedisScript<Long> redisScript = RedisScript.of(stockScript, Long.class);
Long stock = redisTemplate.execute(redisScript, Collections.singletonList(stockKey));
if (stock == null || stock < 0) {
throw new RuntimeException("商品已售罄");
}
try {
// 3. 创建订单(数据库事务)
Order order = orderService.createOrder(request);
// 4. 标记已下单
redisTemplate.opsForValue().set(orderKey, order.getOrderNo(), 1, TimeUnit.DAYS);
return OrderResult.success(order);
} catch (Exception e) {
// 5. 创建订单失败,回滚库存
redisTemplate.opsForValue().increment(stockKey);
throw new RuntimeException("下单失败", e);
}
}
}分布式锁性能优化
锁粒度优化
// × 错误:锁粒度太粗,吞吐低
public void updateInventory(Long productId, Integer quantity) {
RLock lock = redissonClient.getLock("inventory");
lock.lock();
try {
// 更新库存
} finally {
lock.unlock();
}
}
// √ 正确:锁粒度细,只锁定特定商品
public void updateInventory(Long productId, Integer quantity) {
RLock lock = redissonClient.getLock("inventory:" + productId);
lock.lock();
try {
// 更新库存
} finally {
lock.unlock();
}
}锁等待优化
// × 错误:无限等待,可能阻塞线程池
RLock lock = redissonClient.getLock("myLock");
lock.lock();
// √ 正确:设置合理的等待时间
RLock lock = redissonClient.getLock("myLock");
boolean locked = lock.tryLock(3, 30, TimeUnit.SECONDS);
if (!locked) {
// 快速失败或降级处理
return;
}联锁(MultiLock)处理多个资源
// 需要同时锁定多个资源
public void transfer(Account from, Account to, BigDecimal amount) {
RLock lock1 = redissonClient.getLock("account:" + from.getId());
RLock lock2 = redissonClient.getLock("account:" + to.getId());
// 按固定顺序加锁,避免死锁
RLock multiLock = redissonClient.getMultiLock(lock1, lock2);
try {
multiLock.lock();
// 执行转账
} finally {
multiLock.unlock();
}
}什么时候优先考虑幂等
出现这些情况时,优先考虑幂等而不是锁:
- 请求可能重试
- 消息可能重复
- 回调可能重复到达
- 最终副作用可通过状态约束去重
什么时候才上分布式锁
只有当确实存在"共享资源并发冲突"且无法用状态机、唯一键、CAS 等方式解决时,再考虑分布式锁。
而且通常要满足:
- 临界区够短
- 失败后可接受或可补偿
- 锁失效风险可控
治理重点
| 治理项 | 分布式锁 | 幂等设计 |
|---|---|---|
| 基本要求 | 锁要有过期时间 | 幂等键要有唯一性 |
| 防误操作 | 解锁要校验 value,避免误删 | 幂等判断要落在结果层 |
| 监控告警 | 锁等待时间、持有时间监控 | 重复请求次数监控 |
| 降级策略 | 获取锁失败后的降级方案 | 幂等失败后的处理方案 |
常见误区
- 把分布式锁当成万能并发药:滥用锁导致性能问题
- 明明可以用唯一键或状态机解决,却先上锁:简单问题复杂化
- 锁持有时间过长:导致吞吐急剧下降
- 只做入口防重,不做结果层幂等:并发场景仍然有问题
- 以为加了锁就不需要补偿和监控:锁失效后无感知
最佳实践总结
选择决策树
问题类型是什么?
├─ 并发访问共享资源 → 分布式锁
│ └─ 是否可以用数据库锁/CAS 解决?
│ ├─ 是 → 使用数据库锁/CAS
│ └─ 否 → 使用分布式锁(Redis/ZooKeeper)
│
└─ 重复请求/消息处理 → 幂等设计
└─ 是否有业务单号?
├─ 是 → 业务单号 + 唯一约束
└─ 否 → 幂等 Token / 去重表实施清单
分布式锁:
- 设置合理的过期时间
- 使用 Lua 脚本防误删
- 实现锁续期机制(Watch Dog)
- 按固定顺序加锁,避免死锁
- 设置合理的等待时间
- 监控锁等待时间和持有时间
幂等设计:
- 幂等键具有唯一性
- 幂等判断落在结果层(数据库/状态机)
- 使用唯一索引防止重复插入
- 状态机约束非法流转
- 监控重复请求次数
- 保留幂等记录便于排查
面试补充
高频面试题
Q1:分布式锁和幂等分别解决什么问题?
A:
- 分布式锁:解决并发冲突问题,保证同一时间只有一个执行者访问共享资源
- 幂等:解决重复执行问题,保证同一个请求执行多次结果一致
Q2:为什么支付回调更适合幂等而不是大锁?
A:因为支付回调的重点是重复通知的结果收敛,而不是并发临界区串行。支付平台可能多次通知同一笔支付,只需要保证不重复处理即可,不需要加锁串行化。
Q3:Redis 分布式锁至少要注意什么?
A:
- 过期时间:避免客户端崩溃后锁无法释放
- 防误删:使用 Lua 脚本保证"判断 + 删除"的原子性
- 续期:业务执行时间超过锁过期时间时需要续期
- 主从切换风险:考虑使用 Redlock 算法或 ZooKeeper
Q4:幂等最稳的落点在哪里?
A:业务结果层,例如:
- 数据库唯一索引(防止重复插入)
- 状态机约束(限制非法状态流转)
- 业务单号 + 唯一约束(业务层面的幂等)
入口层的幂等(如 Redis 标记)不够可靠,可能因为缓存失效导致重复处理。
Q5:为什么秒杀场景不能靠一个全局锁硬扛?
A:因为吞吐会被锁串行化彻底拖垮。秒杀场景的核心矛盾是高并发,应该:
- 入口限流(令牌桶、漏桶)
- 库存预扣减(Redis 原子操作)
- 业务单号幂等(防重复下单)
- 细粒度锁(必要时只锁定热点商品)
Q6:TCC 和分布式锁有什么关系?
A:TCC 的 Try 阶段通常需要预留资源(如库存预占),这个阶段需要加锁保护,避免并发冲突。但 TCC 的核心是事务补偿,锁只是其中的一个手段。
Q7:如何实现一个可靠的分布式锁?
A:
- 使用 Redis 的
SET key value NX EX seconds命令 - value 使用唯一标识(如 UUID),用于防误删
- 释放锁时使用 Lua 脚本保证原子性
- 实现锁续期机制(Watch Dog)
- 考虑主从切换风险(Redlock 算法)
- 设置合理的等待时间和过期时间
Q8:分布式锁的锁续期机制是什么?
A:当业务执行时间超过锁的过期时间时,通过定时任务(Watch Dog)定期延长锁的过期时间,避免锁提前释放导致并发问题。Redisson 已经内置了这个机制。
版本差异(技术原理说明)
| 维度 | 说明 |
|---|---|
| 技术原理 | 分布式一致性/事务/锁/ID 生成等原理与具体版本无关,长期有效 |
| 落地选型 | 新项目建议优先使用 Nacos/Redis/Seata 等成熟组件(JDK 17+ 兼容) |
| Java 版本 | 示例代码基于 JDK 8 编写,JDK 17/21 下语法兼容 |
本文讲解的分布式系统核心问题与解决方案原理稳定,不随框架版本变化;落地时选用支持 JDK 17/21 的组件版本即可。