Skip to content

让你实现一个分布式单例对象,如何实现? ​

  • 标签:后端、系统设计、场景题

🏛 业务架构师 ​

先明确业务场景:"分布式单例"的真实诉求是——集群部署下,某个动作全局只执行一次。典型如定时对账任务只跑一份、全局序号生成、避免重复发送批量短信/重复扣款。这是成本问题(重复计算)甚至资损问题(重复打款)。

我会按可靠性要求分档给方案:

第一档:数据库唯一约束(我最推荐) 任务执行前插入一条抢占记录,唯一键冲突即抢占失败:

sql
INSERT INTO task_lock(task_name, run_date, owner) VALUES ('reconcile', '2026-10-24', 'node-1');

简单、绝对可靠、天然有审计记录,适合对账、打款类资损场景。业务量小到中等都够用,别瞧不起它。

第二档:Redis SET key value NX EX ttl 适合高并发抢占(如抢活动资格)。风险是超时后锁自动释放,可能两节点同时执行,所以它适合"重复执行代价可接受"的场景,且 value 存节点标识、Lua 脚本比对后再删除。

第三档:ZooKeeper/etcd 临时节点选主 常驻任务的长期 Leader 选举,主节点挂了自动接管,适合调度平台类基础设施。

架构师的关键提醒:所有分布式锁都只是"降低重复概率",资损类动作必须叠加业务幂等(唯一流水号)+ 事后对账兜底。把正确性建立在锁上是最脆弱的设计,把正确性建立在数据库约束和幂等上才是可审计的。这个思路在支付、结算系统里是铁律。

🏗 系统架构师 ​

先明确语义:这里的"分布式单例"指集群内同一时刻只有一个节点持有实例/执行权,本质是选主 + 租约问题,而不是 GoF 的单例模式。

方案一:Redis 租约锁(最常用)

lua
-- SET NX EX 原子抢主,value 放实例ID
SET leader:orderTask "{instanceId}" NX EX 10
-- 后台线程每 3s 用 Lua 校验 value 是自己才续期(防误删他人锁)

拿到锁的节点运行单例对象,租约 10 秒、续期 3 秒一次,留出时钟和 GC 停顿余量。宕机后锁最多 10 秒过期,其他节点接管,RTO 秒级。

方案二:数据库/注册中心抢占 利用数据库唯一约束插入一行"主节点记录",或 ZooKeeper 临时顺序节点(EPHEMERAL + 最小者为主),会话断开自动释放。ZK 方案推送及时但有羊群效应,要用顺序节点+watch 前驱。

方案三:云原生(2026 我的首选) Kubernetes 原生 Lease 对象做领导选举,或者干脆 Deployment 副本数设为 1——把"单例"下沉给平台,应用无侵入。

架构师必须回答的两个问题

  • 脑裂:网络分区时旧主可能还活着。要加 fencing token:每次抢主递增 epoch,下游/共享资源拒绝旧 epoch 的写请求。
  • 平稳切换:主失去租约后要立即停写、释放资源(优雅退位),配合 preStop 和双周期缓冲。

我的选型:无 K8s 用 Redis 租约 + fencing;有 K8s 直接用 Lease,故障切换交给平台。

💻 全栈开发专家 ​

「分布式单例」本质是:集群里N个实例,某个对象的初始化/执行只允许发生一次。我落过两种方案。

方案一:Redisson 分布式锁 + 双重检查(最常用)

java
private volatile boolean initialized;
private final RLock lock = redisson.getLock("init:lock");

public void init() {
    if (initialized) return;                 // 本地快路径
    if (!lock.tryLock()) return;             // 拿不到锁说明别的实例在初始化
    try {
        if (!initialized) {                  // 双重检查
            doInit();
            initialized = true;
        }
    } finally { lock.unlock(); }
}

要点:tryLock 拿不到就直接走已有结果,别自旋等锁;看门狗(watchdog)自动续期防止初始化耗时锁过期;初始化结果写 Redis,后启动的实例直接读结果不再初始化。

方案二:选主模式(ZooKeeper/Nacos) 临时顺序节点选主,只有 Leader 实例运行该对象(比如定时任务、缓存刷新器)。Redisson 也直接提供 RScheduledExecutorService 或 RedissonLock 的公平锁版:

java
RLock lock = redisson.getFairLock("leader");
if (lock.tryLock(0, TimeUnit.SECONDS)) { /* 我是 Leader,执行 */ }

踩过的坑

  • 只加分布式锁不做本地双重检查,每次请求都要打一次 Redis,高峰期 Redis 自己先扛不住。
  • 锁没设看门狗,初始化 40 秒锁 30 秒过期,两个实例都初始化了——加日志核对初始化次数才发现。
  • 初始化中途实例宕机,留下「半初始化」状态。所以初始化要幂等,最好带版本号,失败可重入。

我选型原则:简单场景 Redisson 锁,需要「持续只有一台干活」就用选主。