分布式单例对象怎么实现?彻底讲透,别再一知半解!
大约 4 分钟
分布式单例对象怎么实现?彻底讲透,别再一知半解!
1. 什么是单例?什么是分布式单例?
我们先说说**单例模式(Singleton)**是干嘛的。
在 Java 里,我们希望一个类的对象只存在一份实例,整个程序都共享这个对象,这就是单例模式。我们可能写过这样的代码:
public class Singleton {
private static final Singleton INSTANCE = new Singleton();
public static Singleton getInstance() {
return INSTANCE;
}
}这是“单机单进程单例”。
问题来了
现在我们有个系统,部署在 多台服务器上,每台服务器一个 JVM 进程,此时每个 JVM 都创建了 Singleton.getInstance()。我们以为只创建了一个对象,其实有 N 个对象!
所以这时候要实现“全局唯一”,必须解决这个问题:
如何在“多 JVM、多机器”的情况下,保证系统级别只有一个单例对象?
2. 分布式单例的设计目标
我们希望达成这个目标:
| 场景 | 要求 |
|---|---|
| 多个进程部署在不同机器 | 所有进程之间只能有一个在同一时间内拥有单例对象 |
| 多个线程并发请求 | 谁先获取成功,谁用,别人等待或放弃 |
| 系统崩溃恢复 | 要能恢复对象状态或重新抢占 |
这个问题其实等价于:
分布式场景下的「互斥访问」+「状态同步」
3. 实现方案详解(推荐 Redis 实现 + 数据落地)
整体思路:三步走
- 用 Redis 的分布式锁,实现“谁先抢到锁,谁先用单例对象”
- 抢到锁的节点,从 Redis 中读取对象数据(或自己初始化),然后返回一个对象实例
- 使用完对象后:更新状态并释放锁
4. 详细实现步骤和代码(基于 Redis)
我们定义两个键:
singleton_lock: 用于加锁,防止多进程同时创建对象singleton_data: 存储对象数据,用于反序列化出实例
Step 1:尝试加锁
我们使用 Redis 的 SET key value NX EX 命令,它的意思是:
NX: 如果 key 不存在才 set(只允许第一个抢到)EX: 设置过期时间(避免锁死)- value 是唯一标识(UUID),释放时验证用
示例代码:
String lockKey = "singleton_lock";
String lockVal = UUID.randomUUID().toString();
String result = jedis.set(lockKey, lockVal, "NX", "EX", 30);
if (!"OK".equals(result)) {
return null; // 没抢到锁,放弃或重试
}Step 2:读取或构建单例对象
我们假设这个对象是个配置类:
public class Config {
private int maxThreads;
private boolean safeMode;
// getter / setter
}我们先看 Redis 是否已存:
String json = jedis.get("singleton_data");
Config cfg;
if (json != null) {
cfg = JSON.parseObject(json, Config.class);
} else {
cfg = new Config();
cfg.setMaxThreads(32);
cfg.setSafeMode(true);
}Step 3:释放锁 + 更新状态
Redis 锁释放时有个坑:
不能直接 jedis.del(lockKey),防止误删别人的锁!
正确做法:Lua 脚本检查 value 是否是自己的,再删
String lua = "if redis.call('get', KEYS[1]) == ARGV[1] then " +
"return redis.call('del', KEYS[1]) else return 0 end;";
jedis.eval(lua, Collections.singletonList(lockKey), Collections.singletonList(lockVal));最后,把用完的对象持久化:
jedis.set("singleton_data", JSON.toJSONString(cfg));总体流程图
Client A Client B
| |
| Redis SET NX | <-- 加锁
| 成功 |
| Redis GET | <-- 获取对象数据
| Redis SET | <-- 更新对象状态
| Lua 解锁 | <-- 安全释放锁
| |5. 优势总结
| 维度 | 优势 |
|---|---|
| 高并发 | Redis 原子锁可保证并发竞争 |
| 简单性 | 无需额外部署 ZK 或 etcd |
| 持久性 | 对象数据可落盘(JSON 格式) |
| 安全性 | 锁带唯一ID,防止误删 |
6. 注意事项
- 加锁必须带过期时间,避免死锁
- 解锁必须用 Lua 脚本,不能随便删 key
- 对象内容不能太大(避免 Redis 占内存)
- 若要强一致持久化,应定期将 Redis 落盘至数据库
工程里的关键补充点
锁续期与执行时长
- 任务执行时间可能超过锁过期时间,需要续期机制
- 建议把续期与业务线程解耦,避免阻塞
数据一致性边界
- 对象数据写回要有版本号或时间戳,避免旧数据覆盖新数据
- 序列化要兼容升级,避免结构变更导致反序列化失败
异常与恢复策略
- 发生异常要保证锁能释放,避免长时间占用
- 服务重启后要能恢复最近一次有效状态
可用性与降级
- Redis 异常时要有降级策略(只读或禁用创建)
- 对外暴露的接口需要可观测与限流
7. 补充方案(可选)
| 方案 | 场景 | 缺点 |
|---|---|---|
| 数据库表 + 行锁 | 小流量场景,直接 SELECT FOR UPDATE 锁表 | 性能低 |
| ZooKeeper 临时节点 | 企业项目高可用场景,天然分布式锁支持 | 运维复杂,写法重 |
| etcd CAS机制 | 云原生、K8s 相关服务中使用多 | 依赖 etcd |
