后端
每个后端都实现同一个 LeaseBackend SPI,并通过同一套 TCK,因此选举、锁和组在所有后端上的行为都相同。 它们的区别在于存储方式、在哪里测量时间,以及 fencing token 的持久性。
每个后端都遵循相同的规则:会话在其租约时长内未续约即过期;过期的会话永不复活;租约只在其会话存活期间属于该会话;租约的 token 大于零, 随每个新任期严格递增且永不重置;所有剩余时间都由后端的时钟测量,并以时长的形式返回。
JDBC (MySQL 8, PostgreSQL 14+)
模块 simba-jdbc,类 JdbcLeaseBackend,配合一个 Dialect(Dialect.MYSQL、Dialect.POSTGRESQL):
val backend = JdbcLeaseBackend.mysql(dataSource) // or JdbcLeaseBackend.postgres(dataSource)表结构
使用对应数据库的脚本创建这三张表(只需一次): init-simba-mysql.sql 或 init-simba-postgresql.sql (两者都在 classpath 的 me/ahoo/simba/jdbc/ 下)。MySQL 版本:
create table if not exists simba_session
(
session_id char(32) character set ascii collate ascii_bin not null primary key,
expires_at bigint not null comment 'database time, epoch ms',
metadata varchar(1024) not null default '',
index idx_session_expires (expires_at)
);
create table if not exists simba_lease
(
name varchar(128) character set ascii collate ascii_bin not null primary key,
session_id char(32) character set ascii collate ascii_bin not null default '' comment 'empty = free',
token bigint not null comment 'term number, never reset',
index idx_lease_session (session_id)
);
create table if not exists simba_member
(
group_name varchar(128) character set utf8mb4 collate utf8mb4_bin not null,
session_id char(32) character set ascii collate ascii_bin not null,
primary key (group_name, session_id),
index idx_member_session (session_id)
);| 表 | 每行对应 | 说明 |
|---|---|---|
simba_session | 会话 | expires_at 为数据库时间;每次续约一条 UPDATE 即可为会话续期,并由此为该节点的每个租约和组成员身份续期 |
simba_lease | 曾被获取过的租约名称 | 永不删除,因此 token 永不重置;释放会把 session_id 置为 '' 并保留 token |
simba_member | 组成员 | 仅在其会话存活期间有效 |
租约名称和组名都区分大小写(MySQL 上为二进制排序规则,PostgreSQL 上为 "C")。
工作原理
- 只有当
session_id所指会话的expires_at在未来时,租约才属于该会话。获取是一条 upsert:当行空闲或其会话已过期时占有该行,并把token加一。在 MySQL 上,token的赋值在SET中排在最前,因为 MySQL 按从左到右的顺序、使用已更新的值求值SET。 - 接管时以共享锁(
for share)读取持有者的会话行,因此在任何隔离级别下都与该会话的续约串行化:并发提交的续约要么胜出,要么在同一次续约 回复中看到租约已被接管。 - 每次时间比较都使用数据库自身的时钟(
now:UTC epoch 毫秒,在一条语句内不变,且不受连接时区影响),因此应用主机的时钟从不影响结果。 - 过期会话被惰性清理:过期会话的租约对下一个获取者空闲,发现自己会话已过期的续约会将其删除。其余部分(崩溃进程留下的会话)由
purgeExpired()删除:它在openSession或keepAlive之后运行,每个purgeInterval(默认一分钟)至多一次,每次至多 1000 个会话,沿idx_session_expires索引查找。
运维说明
- 释放不会通知任何人:正常停止后,候选者在下一次尝试(
renewInterval / 2加抖动)时接管。 - 每条语句都有超时;starter 将其设为会话的续约间隔(
simba.jdbc.query-timeout),超过之后引擎无论如何都会放弃该调用。 不使用 Spring 时,JdbcLeaseBackend默认 5 秒(DEFAULT_QUERY_TIMEOUT,即默认的续约间隔);修改续约间隔时,请把它作为queryTimeout传入。 - 为 Simba 提供一个不会被应用耗尽的连接池:等待连接超过续约间隔的续约会被视为失败。
- token 和会话过期时间的持久性取决于数据库提交的持久性。切换到异步复制的 MySQL 从库,或提升一个尚未收到最新 WAL 的 PostgreSQL 备库,都可能丢失最近的 token 自增和续约,与 Redis 主从切换相同:token 可能回退。要在故障切换中保持 fencing 严格,请使用同步复制(MySQL 半同步复制或 Group Replication,PostgreSQL 的
synchronous_commit加同步备库)。 - 租约用数据库服务器的墙钟测量。该时钟发生跳变(NTP step、虚拟机迁移)会让所有租约同时缩短或延长,这不同于
simba.session.drift所覆盖的速率漂移;见故障模式。
Redis 7
模块 simba-redis(与驱动无关,Lua 脚本位于 RedisScripting 端口之后)和 simba-spring-redis (SpringRedisScripting,Spring Data Redis 适配器)。SpringRedisScripting 需要 Spring Boot 默认的 Lettuce 客户端: 使用 Jedis 时订阅会阻塞调用线程,也无法使用 WAIT,因此 starter 遇到非 Lettuce 的 RedisConnectionFactory 会启动失败:
val backend = RedisLeaseBackend(SpringRedisScripting(redisConnectionFactory))键
| 键 | 类型 | 内容 |
|---|---|---|
simba:lease:{name} | 哈希,永不过期 | session、token、expires(Redis TIME);释放只清除 session,因此 token 永不重置 |
simba:session:{sid} | 哈希,PX = 租约时长 | 会话;一旦它消失,续约、获取和加入组都会失败 |
simba:session:{sid}:leases | 集合,同一槽 | 会话的租约 |
simba:session:{sid}:groups | 集合,同一槽 | 会话加入的组 |
simba:group:{group} | 有序集合 | 成员(会话 id),以 Redis 时间下的过期时刻为分数 |
释放会在租约键的频道(simba:lease:{name})上发布 released;这是唯一的广播,让候选者能够立即接管。所有时间判断都在脚本内使用 Redis TIME。
租约会自行过期:新任期的持续时间与其会话相同,续约通过会话的租约索引延长该会话持有的每个租约, 因此在续约途中获取的租约也会被延长。关闭会话时先删除会话,再释放其租约和组成员身份,最后删除索引, 因此失败后重复关闭会把剩余工作做完。
Cluster
在 Redis Cluster 上(RedisScripting.clustered,或 simba.redis.route-by-slot=true),每次脚本调用都限定在一个哈希槽内: 一次调用先访问会话所在的槽,然后为其余键的每个槽各运行一个脚本。后面这些脚本可能比第一个晚很久才到达 Redis (I/O 线程饥饿,或驱动在重连前一直扣着命令),这时会话可能已经死亡,而且这些节点的时钟可能与会话节点不同。 节点之间只传递时长,因此节点时钟之间的固定偏差不会产生影响:
- 获取和加入按租约节点自己的时钟授予一个完整的会话租期。在会话调用和租约调用之间运行的续约会延长会话,却延长不了会话尚未持有的租约,而任期是按这次续约的截止时刻采纳的;完整租期可以覆盖它。代价只涉及活性:如果会话在这期间死亡,授给它的租约最多比它多存活一个租期,在这段时间里占住这个名字(或留下一个过期的成员)。
- 续约的后续脚本带上会话还剩的时长:续约后的剩余时长,减去客户端从会话调用返回起测得的时间。它们所在的节点按自己的 now 加上这个时长设上限,超过就不延长,因此租约至少与会话一样久,比会话多出的时间不超过脚本执行前的延迟。
- 从发出会话调用起经过的时间达到会话剩余时长后,获取和加入不再发送任何脚本并失败。晚于这个时长才返回的获取会释放它开始的任期并失败,即使其他槽已经授予成功:续约会在尝试一个租期之后把丢失的租约从会话索引中移除,
owned就永远不会报告这样的任期。只有在这次释放失败或回复丢失时,租约才会一直不被报告,直到它自行过期。
主从切换后 token 回退
Redis 复制是异步的。当主节点在副本收到最新的 token 递增之前故障时,被提升的副本可能分配一个已经用过的 token,或更小的 token。 此时检查 token 的资源可能接受一个任期在时间上更新但 token 更小的节点的写入,或拒绝合法的持有者。客户端无法阻止这种情况: 确定性模拟会注入这类数据丢失,并确认 token 随之回退(premise:tokenReset),所以下面的缓解措施都在后端一侧。
- 设置
simba.redis.wait-replicas(构造参数waitReplicas):只有当这么多副本通过WAIT确认后,新任期才算数,否则获取失败。 这会缩小窗口,但不能消除它,并且在 Cluster 上不可用。 - 启用持久化(AOF),使单节点重启不会重置 token。
- 当 fencing 必须在故障切换中成立时,使用同步复制的 JDBC 或 Zookeeper(法定人数提交)。
- 租约用 Redis
TIME测量:Redis 主机的时钟跳变会让所有租约同时缩短或延长。
内存淘汰
租约 hash 永不过期,正是它的 token 字段保证了 token 递增。如果 Redis 在内存压力下淘汰了它(任何 allkeys-* 的 maxmemory-policy),该名称的下一次获取会从 token 1 重新开始:检查 token 的资源会拒绝新的持有者,或者让旧持有者更小的 token 重新显得有效。会话 hash 带有 TTL,因此 volatile-* 策略可能淘汰它们,使会话提前结束。
- 让 Simba 使用的 Redis 采用
maxmemory-policy noeviction(Redis 的默认值),这样实例写满时写入会失败而不是丢弃键; Simba 随后报告调用失败,任期在截止时刻结束。 - 或者确保
simba:*键永远不会被淘汰,例如使用一个有独立内存预算的专用实例。同一实例上的另一个逻辑库没有帮助: 淘汰策略和内存上限都是按实例设置的。
Zookeeper
模块 simba-zookeeper,类 ZookeeperLeaseBackend:
val backend = ZookeeperLeaseBackend("zk1:2181,zk2:2181,zk3:2181", root = "/simba")节点
| 路径 | 类型 | 内容 |
|---|---|---|
{root}/leases/{name} | 临时节点 | 会话 id;持有者是节点的 ephemeralOwner,token 是其 czxid |
{root}/groups/{group}/{sessionId} | 临时节点,位于容器节点之下 | 组成员 |
root 默认为 /simba。
工作原理
- 一个 Simba 会话就是一个 Zookeeper 会话:后端为每个会话打开一个专用的 Zookeeper 客户端(不使用共享的 Curator 客户端),并且从不把它重连到另一个会话。
SessionId是以 16 位十六进制数字表示的 Zookeeper 会话 id。 - Token 是租约节点的
czxid:由于 zxid 是全局的,它在任期之间严格递增,但不连续。 - 释放会删除该节点;候选者 watch 该节点并立即接管。组成员的变化同样会被 watch,包括尚不存在的组被创建。
- 名字从发出 create 的那一刻起就被视为"可能拥有":即使 create 已在服务端成功而回复丢失(连接断开、超时),下一次续约在核实节点持有者后 仍会报告该租约,由引擎释放。同一会话的释放逐个执行。
- Zookeeper 客户端会自行发送心跳,因此一个本地看门狗会关闭在租约时长内没有被续约的会话:停止续约的节点永远不会仅靠心跳保住自己的租约。
- 剩余时间为
min(leaseDuration, 协商后的会话超时)。客户端断开连接期间,续约和获取都会失败,因此本地截止时刻会耗尽;当 Zookeeper 报告会话过期时,会话为Expired。
运维说明
- 请求的会话超时为
leaseDuration;集群会将其限制在[minSessionTimeout, maxSessionTimeout]之内(默认为 2 到 20 个 tick)。 崩溃后的故障转移需要协商后的超时时长。 - 写入经法定人数提交,因此 leader 切换不会丢失 token;只有集群数据丢失或从更早的快照恢复时,token 才可能回退。 会话超时按服务器的流逝时间计算,因此集群主机的墙钟跳变不会缩短它们。
simba.zookeeper.connection-timeout限制打开会话所需的最长时间。- 组名遵循
LeaseName的规则(1–128 个[A-Za-z0-9._:-]字符,且不能是.或..),与所有后端一致。members先对组路径执行 sync,因此刚刚返回的 join 一定出现在视图中。
安全
默认情况下节点使用 OPEN_ACL_UNSAFE:能连到该集群的任何客户端都可以删除或抢先创建租约节点,也可以伪造组成员。在共享的集群上,请启用认证并收紧 ACL:
val backend = ZookeeperLeaseBackend(
"zk1:2181,zk2:2181,zk3:2181",
authInfo = listOf(AuthInfo("digest", "simba:secret".toByteArray())),
aclProvider = myAclProvider, // 例如对所有路径返回 ZooDefs.Ids.CREATOR_ALL_ACL
clientConfig = ZKClientConfig(), // TLS 或 SASL 设置,可选
)- 每个客户端(每个会话的客户端,以及
members读取所用的客户端)都会添加authInfo,并用clientConfig构建。 - 后端创建的每个节点(包括它创建的父节点)都使用
aclProvider给出的 ACL。已经存在的父节点保留原有 ACL;{root}/leases和{root}/groups/{group}的 ACL 决定谁能创建或删除租约和成员:请用后端的身份创建它们,或自行设置其 ACL。 - Spring Boot:
simba.zookeeper.auth.scheme/simba.zookeeper.auth.credentials和simba.zookeeper.acl(auto在配置了凭据后使节点仅创建者可用);ACLProviderbean 会替换 ACL,ZKClientConfigbean 用于配置所有客户端 (见配置)。
在 Spring Boot 中选择后端
当后端模块在 classpath 上、simba.enabled 和 simba.<backend>.enabled 不为 false、并且 simba.backend 未设置或指向它时, 对应后端的自动配置生效。除非应用自己定义了 LeaseBackend bean,否则它会定义该 bean。
当 classpath 上有多个后端模块时,请设置 simba.backend=jdbc|redis|zookeeper;否则启动会失败,simba.backend 指向一个未生效的后端时也会失败。 要使用 starter 不负责配置的后端(或你自己的 LeaseBackend),请自行定义 LeaseBackend bean。见 Spring Boot。