Skip to content

后端 ​

每个后端都实现同一个 LeaseBackend SPI,并通过同一套 TCK,因此选举、锁和组在所有后端上的行为都相同。 它们的区别在于存储方式、在哪里测量时间,以及 fencing token 的持久性。

每个后端都遵循相同的规则:会话在其租约时长内未续约即过期;过期的会话永不复活;租约只在其会话存活期间属于该会话;租约的 token 大于零, 随每个新任期严格递增且永不重置;所有剩余时间都由后端的时钟测量,并以时长的形式返回。

JDBC (MySQL 8, PostgreSQL 14+) ​

模块 simba-jdbc,类 JdbcLeaseBackend,配合一个 Dialect(Dialect.MYSQL、Dialect.POSTGRESQL):

kotlin
val backend = JdbcLeaseBackend.mysql(dataSource)      // or JdbcLeaseBackend.postgres(dataSource)

表结构 ​

使用对应数据库的脚本创建这三张表(只需一次): init-simba-mysql.sql 或 init-simba-postgresql.sql (两者都在 classpath 的 me/ahoo/simba/jdbc/ 下)。MySQL 版本:

sql
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 会启动失败:

kotlin
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:

kotlin
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:

kotlin
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 在配置了凭据后使节点仅创建者可用);ACLProvider bean 会替换 ACL,ZKClientConfig bean 用于配置所有客户端 (见配置)。

在 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。

基于 Apache License 2.0 发布。