Sparkdo.DistributedLocking
3.0.0-preview.1
dotnet add package Sparkdo.DistributedLocking --version 3.0.0-preview.1
NuGet\Install-Package Sparkdo.DistributedLocking -Version 3.0.0-preview.1
<PackageReference Include="Sparkdo.DistributedLocking" Version="3.0.0-preview.1" />
<PackageVersion Include="Sparkdo.DistributedLocking" Version="3.0.0-preview.1" />
<PackageReference Include="Sparkdo.DistributedLocking" />
paket add Sparkdo.DistributedLocking --version 3.0.0-preview.1
#r "nuget: Sparkdo.DistributedLocking, 3.0.0-preview.1"
#:package Sparkdo.DistributedLocking@3.0.0-preview.1
#addin nuget:?package=Sparkdo.DistributedLocking&version=3.0.0-preview.1&prerelease
#tool nuget:?package=Sparkdo.DistributedLocking&version=3.0.0-preview.1&prerelease
Sparkdo.DistributedLocking
Sparkdo.DistributedLocking 是分布式锁契约的进程内 provider 与 DI 组合包:每键一个信号量排队、可注入 TimeProvider 的租约与自动续租、默认键规范化器,以及 AddSparkdoDistributedLocking() 一行接线。它传递引入 Sparkdo.DistributedLocking.Abstractions。
单机多任务互斥开箱可用;跨进程/跨机器互斥请改用 Sparkdo.DistributedLocking.Redis(本包 Provider.IsProcessLocal == true,业务侧可据此拒绝)。
快速开始
using Microsoft.Extensions.DependencyInjection;
using Sparkdo.DistributedLocking;
var services = new ServiceCollection();
services.AddSparkdoDistributedLocking(options => options.KeyPrefix = "tenant-a:");
var provider = services.BuildServiceProvider();
var distributedLock = provider.GetRequiredService<IDistributedLock>();
await using var handle = await distributedLock.TryAcquireAsync(
"orders:42",
new DistributedLockAcquisitionOptions
{
WaitTimeout = TimeSpan.FromSeconds(5), // 最多等 5 秒,等不到返回 null
LeaseDuration = TimeSpan.FromMinutes(1), // 租约 1 分钟
AutoRenewalInterval = TimeSpan.FromSeconds(20), // 每 20 秒自动续租(必须 < 租约)
});
if (handle is null)
{
return; // 锁被占用:降级、跳过或换键,不是异常
}
// 持锁执行。租约到期或续租失败时 LostToken 被取消,受保护的工作必须据此停止。
await DoWorkAsync(handle.LostToken);
if (handle.LostToken.IsCancellationRequested)
{
// 已经失锁:本次结果不可信,按业务规则重试或放弃
}
不走 DI 时可直接构造(与容器内注册的实例同型):
var normalizer = new DefaultDistributedLockKeyNormalizer(
Microsoft.Extensions.Options.Options.Create(new DistributedLockingOptions { KeyPrefix = "tenant-a:" }));
IDistributedLock distributedLock = new InMemoryDistributedLock(normalizer); // 系统时钟
IDistributedLock testable = new InMemoryDistributedLock(normalizer, myFakeTimeProvider); // 注入时钟
AddSparkdoDistributedLocking 注册了什么
services.AddSparkdoDistributedLocking(); // 纯接线
services.AddSparkdoDistributedLocking(options => ...); // 接线 + 追加默认 named options 配置
| 服务 | 默认实现 | 注册语义 |
|---|---|---|
DistributedLockingOptions |
AddOptions<DistributedLockingOptions>() |
配置委托叠加,按注册顺序依次应用,同一字段后注册者胜出 |
TimeProvider |
TimeProvider.System |
TryAdd singleton,宿主先注册的时钟实现优先 |
IDistributedLockKeyNormalizer |
DefaultDistributedLockKeyNormalizer |
TryAdd singleton,宿主先注册的规范化器优先 |
IDistributedLock |
InMemoryDistributedLock(工厂,注入上面两项) |
TryAdd singleton,宿主先注册的 IDistributedLock 优先 |
因为全部是 TryAdd:重复调用 AddSparkdoDistributedLocking() 幂等(容器里始终只有一个 IDistributedLock),宿主自己注册的实现永不被覆盖。要换成 Redis provider 用的不是「后注册覆盖」,而是 AddSparkdoRedisDistributedLock() 的显式 RemoveAll。
InMemoryDistributedLock 单实例服务全部锁键,必须是 singleton;把它注册成 scoped/transient 会让每个作用域各持一套信号量,互斥立刻失效。
进程内 provider 的逐项行为
| 方面 | 行为 |
|---|---|
| 互斥单位 | 规范化锁键(KeyPrefix + name),每键一个 SemaphoreSlim(1, 1);不同键永不相互阻塞 |
| 单次尝试 | WaitTimeout = TimeSpan.Zero(默认):立即返回成功或 null,不进入等待队列 |
| 限时/无限等待 | 正数时长或 Timeout.InfiniteTimeSpan 进入信号量等待队列,释放后由等待者接管;不是公平锁,也不承诺唤醒顺序 |
| 等待中取消 | OperationCanceledException,等待者的租约不生效,既有持有者完全不受影响 |
| 租约 | 由注入的 TimeProvider.CreateTimer 调度;到期即进入「已失效」终态:IsLost=true、LostToken 取消、让出信号量供他人获取 |
| 自动续租 | AutoRenewalInterval 到点把到期时间推进一个 LeaseDuration,FencingToken 不变;纯本地计时,没有任何后端确认(这正是它不能跨进程的原因) |
| 手动续租 | handle.TryRenewAsync(leaseDuration?);句柄已终态或本地已过期时返回 Lost(),不抛 |
| fencing token | 实例内 Interlocked 递增:同键严格递增,但与其他键共用计数器所以会跳号;进程重启从 1 重新开始,只在本进程内有意义 |
| 释放 | DisposeAsync 幂等、总是成功、不抛异常(无网络);重复调用不会二次让出槽位 |
| 键的内存占用 | 条目带引用计数,归零即从字典移除,常驻内存不随用过的键数增长;信号量刻意不 Dispose——等待者可能正阻塞在 WaitAsync 上,提前释放会把它们变成 ObjectDisposedException,条目随字典移除整体交给 GC |
| 超长租约 | LeaseDuration/AutoRenewalInterval 超过定时器可实现上限(4294967294 ms ≈ 49.7 天)时,dueTime 被截断到上限,到期判定按 ExpiresAt 重排;获取不会抛,也不会留下无人认领的持锁 |
| 句柄构造失败 | 已经拿到信号量之后才构造/启动句柄,所以这一步的任何抛出(例如 LeaseDuration = TimeSpan.MaxValue 让到期时间溢出而抛 ArgumentOutOfRangeException)都会先归还持有权再原样抛出——同键不会因此永久无人认领 |
用法要点
忘记释放的两种后果,取决于是否设置 AutoRenewalInterval:
| 配置 | 泄漏句柄的行为 | 结论 |
|---|---|---|
| 不自动续租(默认) | 租约到点自动让出槽位,最坏阻塞一个 LeaseDuration |
靠租约兜底即可 |
| 设置自动续租 | 句柄永久续租,同键在本进程剩余生命周期内再也拿不到锁 | 必须 await using,或给持锁工作本身加超时 |
前缀只作用于键,不作用于名字:配了 KeyPrefix = "tenant-a:" 后,TryAcquireAsync("orders") 与 TryAcquireAsync("tenant-a:orders") 落在两个不同键上,互相不阻塞(实测两者都能拿到);handle.Name 始终是传入的原始名。多租户/多应用共用一套 provider 时,前缀是唯一隔离入口,但同一进程内的前缀只能有一份(默认 IDistributedLockKeyNormalizer 只读 DistributedLockingOptions.KeyPrefix)。
测试可直接推进虚拟时钟,不需要真实等待:
var clock = new FakeTimeProvider(DateTimeOffset.UnixEpoch); // 任何 TimeProvider 实现
var distributedLock = new InMemoryDistributedLock(normalizer, clock);
var handle = await distributedLock.TryAcquireAsync("orders",
new DistributedLockAcquisitionOptions { LeaseDuration = TimeSpan.FromSeconds(10) });
clock.Advance(TimeSpan.FromSeconds(10));
handle!.IsLost.ShouldBeTrue(); // 到期即失锁
var next = await distributedLock.TryAcquireAsync("orders"); // 非 null:槽位已让出
同一个 TimeProvider 也决定了 TryRenewAsync 的本地预检,所以「到期后续租报 Lost」这类用例完全确定性。
选项
| 选项 | 默认 | 说明 |
|---|---|---|
DistributedLockingOptions.KeyPrefix |
string.Empty(不加前缀) |
追加在每个规范化锁键之前;多租户或多应用共用后端时用它隔离同名锁。空白锁名在规范化处抛 ArgumentException |
KeyPrefix 在 DefaultDistributedLockKeyNormalizer 构造时读取一次并冻结;宿主用 IOptionsMonitor 热更新前缀不会影响已注册的 singleton provider——需要动态前缀请实现自己的 IDistributedLockKeyNormalizer。
替换与扩展
| 想做的事 | 做法 |
|---|---|
| 升级为跨进程锁 | services.AddSparkdoRedisDistributedLock()(它对 IDistributedLock 先 RemoveAll 再注册,显式覆盖本包默认) |
| 用自研/第三方锁 | 在调用 AddSparkdoDistributedLocking() 之前注册 IDistributedLock 实现(TryAdd 不会覆盖) |
| 改键布局 | 先注册自定义 IDistributedLockKeyNormalizer(须是确定性纯映射) |
| 测租约/续租 | 先注册自定义 TimeProvider(虚拟时钟),两个 provider 都吃它 |
| 不用容器 | new InMemoryDistributedLock(normalizer[, timeProvider]) |
| 同时要进程内和跨进程两把锁体系 | 一个走 AddSparkdoRedisDistributedLock(),另一个手工注册为具体类型并在业务侧按类型注入(IDistributedLock 单服务只解析出一个) |
边界与非目标
- 只在当前进程内互斥:
Provider.IsProcessLocal == true,多实例部署必须换 Redis provider; - 进程退出即丢失全部持锁状态,没有持久化、没有恢复、没有崩溃后的租约接管;
- 不可重入:同键第二次获取只会排队或超时,句柄上也没有「再加一层」的 API;
- 不区分持有者身份:同进程任何代码路径都能在持锁者释放后拿到锁,不提供线程/请求归属校验;
- 不保证等待公平性与唤醒顺序,也不提供优先级继承;
ExpiresAt与续租全部来自本地时钟,没有服务端权威。
异常一览
| 异常 | 触发点 |
|---|---|
ArgumentException |
锁名为 null/空/全空白(获取入口与规范化器都拦截) |
ArgumentNullException |
options 为 null;构造参数 keyNormalizer/timeProvider 为 null |
ArgumentOutOfRangeException |
WaitTimeout 为除 Timeout.InfiniteTimeSpan 外的负值;LeaseDuration <= 0;AutoRenewalInterval <= 0 或 >= LeaseDuration;TryRenewAsync 的 leaseDuration <= 0;LeaseDuration 大到 UtcNow + LeaseDuration 溢出(这一种发生在已拿到锁之后,provider 会先归还持有权再抛) |
OperationCanceledException |
等待中被取消;TryRenewAsync 传入已触发的 token |
获取失败(锁被占用)返回 null,不是异常。
安装
dotnet add package Sparkdo.DistributedLocking
目标框架 net10.0;net11.0,IsAotCompatible + trimming 兼容;包内无运行时扫描、无反射序列化。
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net10.0 is compatible. net10.0-android was computed. net10.0-browser was computed. net10.0-ios was computed. net10.0-maccatalyst was computed. net10.0-macos was computed. net10.0-tvos was computed. net10.0-windows was computed. net11.0 is compatible. |
-
net10.0
- Microsoft.Extensions.DependencyInjection.Abstractions (>= 10.0.8)
- Microsoft.Extensions.Options (>= 10.0.8)
- Microsoft.Extensions.Primitives (>= 10.0.8)
- Sparkdo.DistributedLocking.Abstractions (>= 3.0.0-preview.1)
-
net11.0
- Sparkdo.DistributedLocking.Abstractions (>= 3.0.0-preview.1)
NuGet packages (2)
Showing the top 2 NuGet packages that depend on Sparkdo.DistributedLocking:
| Package | Downloads |
|---|---|
|
Sparkdo.DistributedLocking.StackExchangeRedis
Sparkdo 分布式锁 Redis 实现库,基于 StackExchange.Redis 和 Medallion.Threading.Redis 提供分布式锁功能的 Redis 实现 |
|
|
Sparkdo.DistributedLocking.Redis
基于 Redis 单实例原子命令的跨进程分布式锁 provider。 |
GitHub repositories
This package is not used by any popular GitHub repositories.
| Version | Downloads | Last Updated |
|---|---|---|
| 3.0.0-preview.1 | 63 | 10/30/2026 |
| 2.0.0-preview.2 | 104 | 7/20/2026 |
| 2.0.0-preview.1 | 92 | 7/18/2026 |
| 1.0.4-preview.3 | 93 | 6/8/2026 |
| 1.0.4-preview.2 | 89 | 6/7/2026 |
| 1.0.4-preview.1 | 90 | 6/7/2026 |
| 1.0.3 | 197 | 6/6/2026 |
| 1.0.3-preview.1 | 87 | 5/17/2026 |
| 1.0.2 | 159 | 4/27/2026 |
| 1.0.2-preview.5 | 90 | 4/15/2026 |
| 1.0.2-preview.4 | 121 | 2/8/2026 |
| 1.0.2-preview.3 | 108 | 2/1/2026 |
| 1.0.2-preview.2 | 103 | 1/31/2026 |
| 1.0.2-preview.1 | 220 | 12/4/2025 |
| 1.0.1 | 299 | 11/27/2025 |
| 1.0.0 | 299 | 11/25/2025 |
| 1.0.0-preview.5 | 175 | 10/24/2025 |