首页
学习
活动
专区
圈层
工具
发布

全新的分布式锁,几行代码搞定,简单且强大

昨天晚上十一点多,在公司楼下吹风,手里那杯美式已经凉到像自来水了。小李在群里又喊:分布式锁老丢,服务重启的时候有脏写。我当时就说,别搞那么花的,来个干净点的,几行代码就能跑,稳定先行,对吧。就是那种…嗯…“拿来即用”的小玩意儿。

先说人话版啊:我们要的锁,必须满足三件事——抢到要原子、过期要可续命、释放要认主不认人(就是谁抢到谁才能解,别人别想)。另外再加一味儿——fencing token(隔离令牌),避免“僵尸实例”回魂把数据改坏。听着多,但真不复杂,我昨晚用 Java 加个轻量的 Redis,就撸出来了。等等我回下消息…好了接着说。

你们脑补下场景:多个实例并发写库存,大家都去抢一个 key,比如lock:stock:sku123。谁抢到谁写,超时自动释放,写完主动释放。期间如果网络抖一下,或者 GC 卡个两秒,锁也不能乱。思路就三步:SET NX PX原子加锁、定时“续命”、Lua 脚本核对持有者再解锁。然后 fencing token 用一个递增计数器,谁拿锁谁拿到一个更大的号,后端落库或 MQ 消费都校验这个号,小号的直接丢弃,安全感上来了是不是。

我先把最小可用代码放这,别嫌丑,能跑就行,名字可能打错别纠结:

// 依赖:redis.client:jedis 或者 io.lettuce:lettuce 任意一个都行,这里用 Jedis 举例

import redis.clients.jedis.Jedis;

import java.nio.charset.StandardCharsets;

import java.time.Duration;

import java.util.Objects;

import java.util.UUID;

import java.util.concurrent.*;

import java.util.concurrent.atomic.AtomicBoolean;

public class TinyDistLock {

  private final Jedis jedis;

  private final String lockKey;

  private final String ownerId; // 每个实例唯一ID,避免线程与进程混淆

  private final Duration ttl;

  private final ScheduledExecutorService renewPool = Executors.newSingleThreadScheduledExecutor();

  private final AtomicBoolean locked = new AtomicBoolean(false);

  private volatile String token; // fencing token

  // 脑袋一热起的名…随便

  public TinyDistLock(Jedis jedis, String lockKey, Duration ttl, String instanceId) {

      this.jedis = jedis;

      this.lockKey = lockKey;

      this.ttl = ttl;

      this.ownerId = instanceId + ":" + UUID.randomUUID(); // 进程+随机,双保险

  }

  // Redis里的两个key:锁本体 和 令牌计数器

  private String counterKey() { return lockKey + ":ctr"; }

  public boolean tryLock() {

      // 1) 先抢锁:SET key value NX PX ttl

      String ok = jedis.set(lockKey, ownerId, redis.clients.jedis.params.SetParams.setParams().nx().px(ttl.toMillis()));

      if (!"OK".equals(ok)) return false;

      // 2) 抢到后拿 fencing token(自增数字,越新越大)

      long next = jedis.incr(counterKey());

      token = String.valueOf(next);

      // 3) 开续命:不到期就续,避免抖动

      startRenew();

      locked.set(true);

      return true;

  }

  public String fencingToken() {

      return token;

  }

  // 释放锁:必须校验 ownerId 一致,防止误删

  public boolean unlock() {

      String lua =

              "if redis.call('get', KEYS[1]) == ARGV[1] then " +

              "  return redis.call('del', KEYS[1]) " +

              "else return 0 end";

      Object r = jedis.eval(lua, 1, lockKey, ownerId);

      stopRenew();

      locked.set(false);

      return Objects.equals(1L, r);

  }

  private void startRenew() {

      // 提前一半时间续约,粗暴但好使

      long periodMs = Math.max(200, ttl.toMillis() / 2);

      renewPool.scheduleAtFixedRate(() -> {

          if (!locked.get()) return;

          try {

              String lua =

                      "if redis.call('get', KEYS[1]) == ARGV[1] then " +

                      "  return redis.call('pexpire', KEYS[1], ARGV[2]) " +

                      "else return 0 end";

              jedis.eval(lua, 1, lockKey, ownerId, String.valueOf(ttl.toMillis()));

          } catch (Exception ignore) {

              // 网络抖了就抖了,下一轮再续,别大惊小怪

          }

      }, periodMs, periodMs, TimeUnit.MILLISECONDS);

  }

  private void stopRenew() {

      renewPool.shutdownNow();

  }

}

我知道你们会问:就这?别急,最关键是“怎么用”。实际业务里,我们把 fencing token 一路带下去,落库/发消息/调下游,都校验这个号是不是“全场最大”。小于等于当前已处理的 token 的,一律拒收。这招干掉了“锁过期后老实例晚到的请求”。

// 伪代码:下游写库时校验 fencing token

public class StockRepository {

  // 存一个当前处理到的最大token,正常应该是存在DB或表字段里,这里简化下

  private volatile long maxSeenToken = 0;

  public synchronized boolean updateStockWithFence(String sku, int delta, long fencingToken) {

      if (fencingToken <= maxSeenToken) {

          return false; // 旧世界的包裹,扔掉

      }

      // … 真正扣减库存 …

      maxSeenToken = fencingToken;

      return true;

  }

}

顺手一个使用示例,别嫌罗嗦,我边打字边想词儿:

Jedis jedis = new Jedis("127.0.0.1", 6379);

TinyDistLock lock = new TinyDistLock(jedis, "lock:stock:sku123", Duration.ofSeconds(10), "svc-a");

if (lock.tryLock()) {

  try {

      long fence = Long.parseLong(lock.fencingToken());

      boolean ok = repo.updateStockWithFence("sku123", -1, fence);

      if (!ok) {

          // 打个日志就行,说明你这次虽然拿了锁,但被更“新”的写给盖过去了

      }

  } finally {

      lock.unlock(); // 别忘了

  }

} else {

  // 没抢到,随便退避个50~150ms再试,别锤Redis

}

有人问续命不成功咋办?就大概率是网络抽风或者 Redis 抖了一下,下一轮能续上就行。真续不上,锁会过期被别人拿走,那也没事,因为你们的 fencing token 校验在后面把“旧请求”挡了。对吧,这就是双保险,我反正昨晚在电梯口又改了两行就顺了。

还有几个坑我得碎碎念下,省得你们二次踩:

线程内可重入?要的话加个ThreadLocal<Integer>计数,tryLock()成功就+1,unlock()-1到 0 再真正解锁。记得 ownerId 还是得唯一,别把线程号混进去,进程级就够了。

进程崩了怎么办?锁自动过期;但崩的那一瞬间可能没来得及解锁,所以 fencing token 是关键兜底。

时钟不准?这个方案不吃系统时钟,只用 Redis 的 TTL,续命通过PEXPIRE,放心。

跨机房延迟大?把 TTL 设置长一点,比如 10s~30s,然后续命周期取一半,别抠太死。业务强一致就别跨太远。

有人又说:为啥不用现成的 Redisson?用也行,成熟,功能多。只是有时候你就想看得见摸得着,几行代码把核心逻辑拿住,问题一来心里不慌。我就属于那种…嗯…手痒型,能自己拧两下就不去找大扳手。

哦对,说漏了一个细节:counterKey的递增最好设置个上限或用BIGINT,虽然一般够你用一辈子。极端情况下你可以按天分桶,比如lockKey:ctr:2025-09-23,业务核对时带上日期,跨天重启个基准就完了。等等电话响了…行我先去接,回头把监控图贴上来你们自己看吧。

  • 发表于:
  • 原文链接https://page.om.qq.com/page/OUO7QJtRlOlpMuLepHvTp7Xg0
  • 腾讯「腾讯云开发者社区」是腾讯内容开放平台帐号(企鹅号)传播渠道之一,根据《腾讯内容开放平台服务协议》转载发布内容。
  • 如有侵权,请联系 cloudcommunity@tencent.com 删除。
领券