搞分布式锁这事,我以前一直挺自信的:SET key value NX EX 30,不就完了?

结果上个月一个“看起来不可能”的线上问题把我教育得很彻底:

  • 业务偶发出现“重复扣减/重复发放”
  • 日志里明明看到锁加成功了
  • 但同一个资源在很短时间内被两个实例都处理了

我最开始怀疑是代码逻辑写炸了,后来一路排查到 Redis 锁的实现细节:加锁只是第一步,解锁的正确姿势更重要;另外续期、重入、异常时序都能让你翻车。

这篇我不讲大而全的“分布式锁原理”,就围绕一个很小但很致命的点:为什么 SET NX EX 还会踩坑,以及怎么用 token + Lua 把“解锁误删”彻底堵上。顺便把“需要续期时怎么做”也给一个可落地的实现。


1)问题是什么:我明明加锁了,为什么还是会重复执行?

现象

  • 服务 A 获取到锁,开始执行。
  • 执行过程中发生超时/卡顿(比如下游慢、GC、网络抖动)。
  • 锁过期后,服务 B 又获取到了锁并执行。
  • 服务 A 终于执行完,准备释放锁——它把服务 B 的锁删了

这就是经典的“锁过期 + 误删他人锁”场景。

很多人(包括我当时)写的解锁代码是这样的:

import redis.clients.jedis.Jedis;

public class BadUnlockDemo {

    public static void main(String[] args) {
        try (Jedis jedis = new Jedis("127.0.0.1", 6379)) {
            String lockKey = "lock:order:1001";

            // 加锁
            String ok = jedis.set(lockKey, "any", "NX", "EX", 30);
            if (!"OK".equals(ok)) {
                System.out.println("lock failed");
                return;
            }

            // 业务逻辑...

            // 错误解锁:直接 del
            jedis.del(lockKey);
        }
    }
}

问题描述del(lockKey) 没有校验“锁是不是我加的”。如果锁过期后被别人抢走,你这一下 del 就是在帮别人开门。

原因分析:Redis 锁本质上只是一个 KV,DEL 没有所有权概念。你不把“所有者身份”放进 value 里,并且在删除时校验,就一定有概率误删。

解决方案:加锁时写入一个唯一 token(比如 UUID),解锁时必须先判断 value==token,再删除,而且要原子化。


2)为什么“先 GET 再 DEL”也不行:并发窗口会让你继续翻车

很多人会把解锁改成这样:

import redis.clients.jedis.Jedis;

import java.util.UUID;

public class GetThenDelStillBad {

    public static void main(String[] args) {
        try (Jedis jedis = new Jedis("127.0.0.1", 6379)) {
            String lockKey = "lock:order:1001";
            String token = UUID.randomUUID().toString();

            String ok = jedis.set(lockKey, token, "NX", "EX", 30);
            if (!"OK".equals(ok)) return;

            // ...业务执行

            // 看起来“更安全”的解锁
            String val = jedis.get(lockKey);
            if (token.equals(val)) {
                jedis.del(lockKey);
            }
        }
    }
}

问题描述:这段逻辑依然会误删。

原因分析GETDEL 不是原子操作,中间存在并发窗口:

  1. 线程 A GET 到 token 相等
  2. 锁刚好过期
  3. 线程 B 抢到新锁(value=tokenB)
  4. 线程 A 执行 DEL,把 B 的锁删掉

所以正确做法必须是:比较 + 删除一次性完成

解决方案:用 Lua 脚本把“校验 value + 删除 key”合并成 Redis 内部原子执行。


3)正确解锁:Lua 原子校验 + 删除(最关键的一段)

下面这段 Lua 是我现在的固定模板:

-- KEYS[1] = lockKey
-- ARGV[1] = token
if redis.call('get', KEYS[1]) == ARGV[1] then
  return redis.call('del', KEYS[1])
else
  return 0
end

Java(Jedis)调用示例:

import redis.clients.jedis.Jedis;

import java.util.Collections;
import java.util.UUID;

public class RedisLockWithLua {

    private static final String UNLOCK_LUA =
            "if redis.call('get', KEYS[1]) == ARGV[1] then " +
            "  return redis.call('del', KEYS[1]) " +
            "else " +
            "  return 0 " +
            "end";

    public static void main(String[] args) {
        try (Jedis jedis = new Jedis("127.0.0.1", 6379)) {
            String lockKey = "lock:order:1001";
            String token = UUID.randomUUID().toString();

            String ok = jedis.set(lockKey, token, "NX", "EX", 30);
            if (!"OK".equals(ok)) {
                System.out.println("lock failed");
                return;
            }

            try {
                // 业务逻辑
                System.out.println("do business...");
            } finally {
                Object res = jedis.eval(
                        UNLOCK_LUA,
                        Collections.singletonList(lockKey),
                        Collections.singletonList(token)
                );
                System.out.println("unlock result = " + res);
            }
        }
    }
}

问题描述:解锁必须保证“只有持有 token 的线程能删”。

原因分析:Lua 脚本在 Redis 内部执行,期间不会被其他命令打断,因此不会出现 GET/DEL 并发窗口。

解决方案:token 放 value,Lua 原子解锁。

到这里,你已经把最常见、最隐蔽的坑堵死了。


4)锁超时怎么办:要不要续期?怎么续期才不把别人锁续了?

上面说的“锁过期导致误删”,有一半原因是:业务执行时间不可控。如果你的业务可能超过锁 TTL(比如 30s),那光靠 token 解锁还不够。

你有两个选择:

1)把 TTL 设置得足够大:简单粗暴,但会降低并发度,且遇到死锁风险(进程挂了等 TTL)。

2)续期(watch dog):执行中定时延长 TTL。

续期也有坑:你必须保证“只给自己的锁续期”。否则锁过期后被别人拿走,你还在续期,相当于把别人锁保活了。

所以续期也要用 Lua:校验 token 一致才 PEXPIRE

Lua 续期脚本:

-- KEYS[1] = lockKey
-- ARGV[1] = token
-- ARGV[2] = ttlMillis
if redis.call('get', KEYS[1]) == ARGV[1] then
  return redis.call('pexpire', KEYS[1], ARGV[2])
else
  return 0
end

Java 续期线程示例(可运行,注意 finally 里关闭线程):

import redis.clients.jedis.Jedis;

import java.util.Collections;
import java.util.UUID;
import java.util.concurrent.*;

public class RedisLockRenewDemo {

    private static final String RENEW_LUA =
            "if redis.call('get', KEYS[1]) == ARGV[1] then " +
            "  return redis.call('pexpire', KEYS[1], ARGV[2]) " +
            "else " +
            "  return 0 " +
            "end";

    public static void main(String[] args) throws Exception {
        try (Jedis jedis = new Jedis("127.0.0.1", 6379)) {
            String lockKey = "lock:order:1001";
            String token = UUID.randomUUID().toString();

            // 这里用 PX 10s,示例更直观
            String ok = jedis.set(lockKey, token, "NX", "PX", 10_000);
            if (!"OK".equals(ok)) {
                System.out.println("lock failed");
                return;
            }

            ScheduledExecutorService renewPool = Executors.newSingleThreadScheduledExecutor();
            ScheduledFuture<?> future = renewPool.scheduleAtFixedRate(() -> {
                try (Jedis j = new Jedis("127.0.0.1", 6379)) {
                    Object r = j.eval(
                            RENEW_LUA,
                            Collections.singletonList(lockKey),
                            java.util.Arrays.asList(token, String.valueOf(10_000))
                    );
                    // r==1 表示续期成功,0 表示锁已不属于你
                    System.out.println("renew=" + r);
                }
            }, 3, 3, TimeUnit.SECONDS);

            try {
                // 模拟长业务
                Thread.sleep(20_000);
                System.out.println("business done");
            } finally {
                future.cancel(true);
                renewPool.shutdownNow();

                // 解锁也要 Lua 校验
                String unlockLua =
                        "if redis.call('get', KEYS[1]) == ARGV[1] then " +
                        "  return redis.call('del', KEYS[1]) " +
                        "else return 0 end";
                jedis.eval(unlockLua, Collections.singletonList(lockKey), Collections.singletonList(token));
            }
        }
    }
}

问题描述:业务可能超过 TTL,锁会过期被抢。

原因分析:TTL 不是“锁一定会持有这么久”,而是“最多活这么久”。遇到 GC、慢 SQL、外部接口抖动都可能撑爆 TTL。

解决方案:要么 TTL 足够大(且可接受),要么续期;续期必须校验 token。


5)我会再加一层保险:业务幂等 + 锁只负责“减少并发”

坦白讲,分布式锁在我心里定位一直是:降低并发冲突概率,不是“终极正确性来源”。

因为你再怎么写锁,还是可能遇到:

  • Redis 故障 / 网络分区
  • 进程 pause(STW)
  • 锁过期后被抢的极端时序

所以我现在做法是:锁之外再做一道幂等(比如订单状态机、唯一索引、去重表)。

下面给一个非常小但有效的幂等模板:用 MySQL 唯一键防重复(锁只是锦上添花)。

CREATE TABLE t_order_process_guard (
  id BIGINT PRIMARY KEY AUTO_INCREMENT,
  biz_key VARCHAR(64) NOT NULL,
  created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
  UNIQUE KEY uk_biz_key (biz_key)
);

Java 侧:插入成功才继续;冲突说明已经处理过。

import java.sql.*;

public class IdempotentWithUniqueKey {

    public static void main(String[] args) throws Exception {
        String url = "jdbc:mysql://127.0.0.1:3306/test?useSSL=false&serverTimezone=UTC";
        String user = "root";
        String pass = "root";

        String bizKey = "order:1001:deduct";

        try (Connection conn = DriverManager.getConnection(url, user, pass)) {
            conn.setAutoCommit(true);

            try (PreparedStatement ps = conn.prepareStatement(
                    "INSERT INTO t_order_process_guard(biz_key) VALUES (?)")) {
                ps.setString(1, bizKey);
                ps.executeUpdate();
                System.out.println("first time, continue business");
            } catch (SQLIntegrityConstraintViolationException dup) {
                System.out.println("duplicate bizKey, ignore");
            }
        }
    }
}

问题描述:锁不是 100% 可靠,极端情况下还是会重复执行。

原因分析:分布式系统里没有“绝对时序”。锁能降低概率,但挡不住所有故障模型。

解决方案:业务层幂等兜底(唯一键/状态机/去重表)。


写在最后:我现在怎么写 Redis 锁(以及我不再迷信它)

这次踩坑之后,我把“Redis 分布式锁”这件事的底线拉得很清楚:

  • 加锁SET key token NX EX/PX ttl
  • 解锁:必须 Lua 原子校验 token
  • 续期:只有在“业务可能超过 TTL 且并发敏感”时做;续期也必须校验 token
  • 终极保障:幂等一定要有,锁只负责减少冲突

如果你现在项目里还在用“直接 DEL 解锁”,真的建议抽 10 分钟改掉。

下一篇我可能会写:Redisson 的 watchdog 到底做了什么、它为什么比我手写线程池更稳(以及它也不是万能的)。你要是也踩过类似坑,评论区互相取暖一下。

Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐