昨天晚上十一点多,在公司楼下吹风,手里那杯美式已经凉到像自来水了。小李在群里又喊:分布式锁老丢,服务重启的时候有脏写。我当时就说,别搞那么花的,来个干净点的,几行代码就能跑,稳定先行,对吧。就是那种…嗯…“拿来即用”的小玩意儿。
先说人话版啊:我们要的锁,必须满足三件事——抢到要原子、过期要可续命、释放要认主不认人(就是谁抢到谁才能解,别人别想)。另外再加一味儿——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,业务核对时带上日期,跨天重启个基准就完了。等等电话响了…行我先去接,回头把监控图贴上来你们自己看吧。