Lizenz-Heartbeats und Lease-Reaping: gleichzeitige Plätze zählen, wenn Clients abstürzen
Eine Floating-Lizenz verkauft N Plätze, die ein Team teilt, also muss der Server wissen, wie viele *gerade jetzt* in Gebrauch sind. Das ist einfach, bis ein Client auf die falsche Art stirbt – ein Absturz, eine verworfene VM, ein herausgerissenes Netzwerkkabel – und seinen Platz nie zurückgibt. Zählen Sie beim Checkout, und der Platz ist für immer verloren; Ihr Kunde zahlt für zehn und kann sechs ausführen. Die Lösung besteht darin, Checkout/Checkin nicht mehr als zusammengehöriges Paar zu behandeln und jeden Platz als Lease mit einer Lebensdauer (TTL) zu betrachten, die der Client per Heartbeat am Leben halten muss. Hier ist das Lease-und-Heartbeat-Protokoll in Keyright: das signierte Lease mit kurzer TTL, die Erneuerungsschleife auf dem Client, der Reaper, der tote Plätze auf dem Server zurückfordert, und die Uhren- und Gnadenregeln, die die Zählung ehrlich halten, ohne ein instabiles Netzwerk zu bestrafen.
Eine Floating-Lizenz (gleichzeitige Lizenz) verkauft eine Anzahl von Plätzen – sagen wir zehn –, die ein Team teilt. Jeder kann beim Start der App einen Platz greifen, und der elften Person, die es versucht, wird gesagt, sie solle warten. Das gesamte Modell ruht auf einer Zahl auf der Serverseite: Wie viele Plätze sind gerade jetzt in Gebrauch? Irren Sie sich bei dieser Zahl zugunsten des Kunden, verschenken Sie Plätze; irren Sie sich zu Ihren Gunsten, sperren Sie Leute aus, die bezahlt haben.
Die naive Implementierung irrt sich fast sofort. Sie erhöhen einen Zähler beim Checkout und verringern ihn beim Checkin, und es funktioniert wunderbar in einer Demo, in der jede App sauber beendet. Dann passiert der erste echte Absturz – ein StackOverflowException, der den Prozess ohne Abwicklung niederstreckt, eine VM, die das Ops-Team pausiert und verwirft, ein Entwickler, der den Deckel zuklappt und nach Hause fährt – und dieser Platz wird nie zurückgegeben. Der Zähler sinkt nie wieder. Tun Sie das ein paar Dutzend Mal über ein Quartal, und eine Zehn-Platz-Lizenz läuft still auf zwei nutzbare Plätze auf, und das Support-Ticket lautet „wir haben zehn gekauft, warum können nur zwei von uns es ausführen?”.
Dieser Beitrag handelt von der Lösung: hören Sie auf, Checkout und Checkin als verlässliches Paar zu behandeln, und behandeln Sie jeden Platz als Lease mit einer Lebensdauer (TTL), das der Client per Heartbeat am Leben halten muss. Ein Platz kommt zurück, wenn der Client ihn freigibt oder wenn sein Lease abläuft – je nachdem, was zuerst eintritt. Der Ablauf ist es, der die Zählung selbstheilend macht, wenn ein Client auf die falsche Art stirbt.
Das Lease ist ein signierter Claim mit einem Ablauf
Ein Keyright-Lease ist kein Boolescher Wert „du hast einen Platz”. Es ist eine kleine Menge von Claims, die der ausstellende Dienst signiert – dasselbe Signaturschema, das jedes andere Keyright-Token schützt –, sodass der Client es lesen, zwischenspeichern und beweisen, aber nicht ändern kann. Die für die Gleichzeitigkeit wichtigen Felder sind die Lizenz-ID, eine vom Server zugewiesene Platz-ID, der Inhaber (damit eine UI zeigen kann, wer die anderen neun Plätze hat) und zwei Zeitstempel: wann das Lease ausgestellt wurde und wann es abläuft.
public sealed record SeatLease
{
public required string LicenseId { get; init; }
public required string SeatId { get; init; } // server-assigned, unique per live seat
public required string Holder { get; init; } // user or machine label, for display
public required DateTimeOffset IssuedAt { get; init; }
public required DateTimeOffset ExpiresAt { get; init; } // IssuedAt + policy.Ttl
// Signed by the issuing service; verified on the client against the embedded public key.
public required string Signature { get; init; }
public bool IsLive(DateTimeOffset now, TimeSpan skew) => now <= ExpiresAt + skew;
}
Der Client berechnet ExpiresAt nie selbst und vertraut nie seiner eigenen Uhr, um es zu verlängern – der Ablauf ist das, was der Server signiert hat. Die einzige lokale Uhrennutzung ist die ehrliche Richtung: zu entscheiden, dass das Lease verfallen ist, damit der Client aufhört, darauf zu handeln. Wir kommen auf den Uhrenversatz zurück, denn das ist die eine Stelle, an der dieses Design Sie beißen kann.
Checkout: gib nur dann einen Platz aus, wenn einer frei ist
Checkout ist die eine Operation, die pro Lizenz serialisiert werden muss, denn dort können zwei Clients um den letzten Platz konkurrieren. Der Server zählt die noch lebenden Leases, verweigert, wenn der Pool voll ist, und prägt andernfalls ein frisches Lease mit einer neuen Platz-ID und gibt es signiert zurück.
public sealed class SeatService
{
private readonly ILeaseStore _store; // persistence is an implementation detail
private readonly ILeaseSigner _signer; // holds the private key; server-only
private readonly TimeProvider _clock;
public async Task<CheckoutResult> CheckoutAsync(
string licenseId, string holder, SeatPolicy policy, CancellationToken ct)
{
// Serialize per license so two callers cannot both see "one seat free".
await using var _ = await _store.LockLicenseAsync(licenseId, ct);
var now = _clock.GetUtcNow();
var live = await _store.CountLiveLeasesAsync(licenseId, now, policy.Skew, ct);
if (live >= policy.SeatCount)
return CheckoutResult.NoSeatsAvailable(policy.SeatCount);
var lease = new SeatLease
{
LicenseId = licenseId,
SeatId = Guid.NewGuid().ToString("N"),
Holder = holder,
IssuedAt = now,
ExpiresAt = now + policy.Ttl,
Signature = "" // filled by the signer below
};
var signed = _signer.Sign(lease);
await _store.UpsertAsync(signed, ct);
return CheckoutResult.Granted(signed);
}
}
Zwei Details entscheiden, ob die Zählung ehrlich bleibt. Erstens muss CountLiveLeasesAsync nach Ablauf zählen, nicht nach einem Statusflag: Ein Lease ist lebendig, wenn now <= ExpiresAt + skew, Punkt. Halten Sie eine separate „aktiv”-Spalte und vergessen Sie, sie zu löschen, sind Sie zurück beim Problem des verlorenen Zählers. Zweitens ist die Sperre pro Lizenz zwingend. Ohne sie lesen zwei Clients, die im selben Millisekundenmoment den zehnten Platz auschecken, beide live == 9, bestehen beide die Prüfung, und Sie haben elf Plätze draußen gegen eine Zehn-Platz-Lizenz. Der Sperrumfang ist eine Lizenz, für Mikrosekunden gehalten, also serialisiert sie nicht Ihren ganzen Dienst.
Heartbeat: der Client erneuert sein eigenes Lease
Sobald ein Client ein Lease hält, ist seine einzige Aufgabe, vor dem Ablauf zurückzukommen und den Server zu bitten, den Ablauf nach vorn zu schieben. Der Server prüft erneut, dass der Platz noch existiert (er könnte widerrufen oder gereapt worden sein) und stellt, falls ja, ein frisches Lease für dieselbe Platz-ID mit späterem Ablauf aus.
public async Task<HeartbeatResult> HeartbeatAsync(
string licenseId, string seatId, SeatPolicy policy, CancellationToken ct)
{
await using var _ = await _store.LockLicenseAsync(licenseId, ct);
var now = _clock.GetUtcNow();
var existing = await _store.FindAsync(licenseId, seatId, ct);
// Seat was revoked or already reaped — the client must check out again.
if (existing is null || !existing.IsLive(now, policy.Skew))
return HeartbeatResult.SeatLost();
var renewed = _signer.Sign(existing with
{
IssuedAt = now,
ExpiresAt = now + policy.Ttl
});
await _store.UpsertAsync(renewed, ct);
return HeartbeatResult.Renewed(renewed);
}
Beachten Sie, was der Heartbeat nicht tut: Er erhöht nie die Platzzählung. Er erneuert nur einen Platz, den der Client bereits hält, also gibt es kein Rennen, das gegen die Poolgröße zu serialisieren wäre – die Sperre dient hier nur dazu, zu verhindern, dass eine Erneuerung mit einem Reap derselben Zeile kollidiert. Ein Heartbeat für einen bereits gereapten Platz liefert SeatLost zurück, und die korrekte Antwort des Clients ist, Checkout erneut auszuführen, was ihm entweder einen frischen Platz verschafft oder ihm sagt, der Pool sei voll.
Auf dem Client ist der Heartbeat eine Hintergrundschleife, die bei einem Bruchteil der TTL feuert, damit eine einzelne verlorene Anfrage nicht fatal ist. Ein Drittel der TTL gibt Ihnen zwei oder drei Versuche, bevor das Lease verfällt. Fügen Sie etwas Jitter hinzu, damit tausend Clients, die alle nach einem Deployment gestartet sind, nicht im Gleichschritt schlagen.
public sealed class LeaseKeeper : BackgroundService
{
private readonly KeyrightClient _client;
private readonly LeaseState _state; // holds the current signed lease for enforcement
private readonly TimeProvider _clock;
protected override async Task ExecuteAsync(CancellationToken stop)
{
while (!stop.IsCancellationRequested)
{
var lease = _state.Current;
// Beat at ~1/3 TTL, with jitter, measured from this lease's own window.
var window = lease.ExpiresAt - lease.IssuedAt;
var baseDelay = window / 3;
var jitter = TimeSpan.FromMilliseconds(Random.Shared.Next(0, 2_000));
await Task.Delay(baseDelay + jitter, stop);
try
{
var result = await _client.HeartbeatAsync(lease.LicenseId, lease.SeatId, stop);
if (result.Renewed)
_state.Replace(result.Lease); // new expiry, enforcement continues
else
await ReacquireOrDegradeAsync(stop); // SeatLost: try checkout again
}
catch (HttpRequestException)
{
// Transient: do nothing. The lease is still valid until ExpiresAt; we will
// retry on the next tick. Only a *lapsed* lease should degrade the app.
}
}
}
}
Das catch ist der ganze Sinn der TTL. Ein fehlgeschlagener Heartbeat ist keine Verdrängung: Das Lease, das der Client bereits hält, ist bis zu seinem signierten ExpiresAt gültig, also ist ein dreißigsekündiger Netzaussetzer unsichtbar. Der Client degradiert (schreibgeschützter Modus, ein „Platz verloren”-Banner, was auch immer Ihr Produkt tut) nur, wenn das Lease, das er hält, tatsächlich verfallen ist und eine Neu-Akquise fehlgeschlagen ist. Diese Trennung – vorübergehender Fehler gegen echten Ablauf – ist es, was verhindert, dass ein instabiles Café-WLAN einen zahlenden Nutzer mitten in einer Bearbeitung hinauswirft.
Der Reaper: fordert zurück, was die Clients nicht zurückgaben
Der Lease-Ablauf ist notwendig, aber nicht hinreichend. Ein abgestürzter Client hört auf zu schlagen, und sein Lease liest sich für jeden, der es prüft, als abgelaufen – aber nichts prüft es, bis der nächste Checkout geschieht. Ist der Pool ruhig, kann ein toter Platz lange im Speicher liegen und besetzt aussehen, und CountLiveLeasesAsync schließt ihn bereits aus (es zählt nach Ablauf), also ist die Korrektheit gewahrt. Was Sie ohne Reaper verlieren, ist Ordnung und Beobachtbarkeit: veraltete Zeilen häufen sich an, und ein Dashboard, das „aktuelle Inhaber” auflistet, zeigt Geister.
Der Reaper ist ein periodischer Durchlauf, der Leases löscht, deren Ablauf (plus Versatz, plus eine optionale Gnade) weit in der Vergangenheit liegt.
public sealed class LeaseReaper(ILeaseStore store, TimeProvider clock) : BackgroundService
{
private static readonly TimeSpan SweepInterval = TimeSpan.FromSeconds(30);
protected override async Task ExecuteAsync(CancellationToken stop)
{
while (!stop.IsCancellationRequested)
{
var now = clock.GetUtcNow();
// Reap only leases that are past expiry by a margin, so a client whose heartbeat
// is a few seconds late is never reaped out from under itself.
var cutoff = now - LeasePolicy.ReapGrace; // e.g. expiry + 15s
var reaped = await store.DeleteExpiredAsync(before: cutoff, stop);
if (reaped > 0)
Log.SeatsReclaimed(reaped);
await Task.Delay(SweepInterval, stop);
}
}
}
Der Reaper und CountLiveLeasesAsync müssen sich in der Arithmetik einig sein, sonst haben Sie den unangenehmsten Fehler dieses ganzen Designs: einen Platz, den der Zähler als frei behandelt, den der Reaper aber noch nicht gelöscht hat, oder umgekehrt. Halten Sie die Regel an einer Stelle – ein Lease ist lebendig genau dann, wenn now <= ExpiresAt + skew – und lassen Sie sowohl den Zähler als auch den Reaper dasselbe Prädikat aufrufen. Der ReapGrace des Reapers ist rein ein Sicherheitsabstand auf das Löschen; er muss größer sein als der Versatz, den der Zähler erlaubt, damit der Reaper nie eine Zeile löscht, die der Zähler noch zählen würde. Löschen Sie zu eifrig, findet ein Client, dessen Heartbeat drei Sekunden zu spät kommt, seinen Platz verschwunden und muss ohne Grund erneut auschecken.
Der Uhrenversatz ist das Einzige, was Sie beißen wird
Alle Zeitstempel hier gehören dem Server. Der Client liest ExpiresAt, um zu wissen, wann er aufhören soll, aber er vergleicht es mit seiner eigenen Uhr, und Client-Uhren sind falsch – manchmal um Minuten, gelegentlich um Stunden, und ein entschlossener Nutzer kann seine auf alles Mögliche stellen. Zwei Fehlermodi folgen.
Geht die Uhr des Clients nach (hinter dem Server), hält er das Lease noch für lebendig, nachdem der Server es als abgelaufen betrachtet. Harmlos für die Zählung – der Server reapt den Platz planmäßig, egal was der Client glaubt –, aber es bedeutet, dass ein Client kurz auf einem Lease handeln kann, das der Server bereits zurückgefordert hat. Halten Sie das Durchsetzungsfenster eng, und das ist ein Sub-TTL-Effekt, den der nächste Heartbeat korrigiert.
Geht die Uhr des Clients vor (vor dem Server), hält er das Lease zu früh für abgelaufen und degradiert oder akquiriert zu früh neu. Ärgerlich, aber kein Lizenzleck: Der Nutzer verliert nichts, wofür er bezahlt hat; er erzeugt nur einen zusätzlichen Checkout.
Die Versatztoleranz (policy.Skew) existiert, um die normalen paar Sekunden Drift zu absorbieren, damit keine Seite flattert. Was sie nicht tun darf, ist zu einer Hintertür zu werden: Lassen Sie nie zu, dass die Uhr des Clients ein Lease verlängert. Der Ablauf wird vom Server signiert, der Server reapt auf seiner eigenen Uhr, und die Uhr des Clients entscheidet nur, einen Platz früher aufzugeben. Diese Asymmetrie – der Client kann früh freigeben, nur der Server kann verlängern – ist es, die „stelle deine Uhr zurück” daran hindert, ein Platz-Horten-Exploit zu sein. Es ist dieselbe Disziplin, die das Zurückstellen der Probe-Uhr besiegt, auf die Gleichzeitigkeit angewandt.
Was Sie tatsächlich ausliefern
Die Teile sind klein, und der Großteil der Korrektheit lebt in zwei Regeln, die Sie in je einem Satz formulieren können. Ein Platz ist lebendig genau dann, wenn now <= ExpiresAt + skew, und sowohl der Zähler als auch der Reaper gehorchen diesem einen Prädikat. Der Client darf einen Platz früh freigeben, aber nie einen verlängern – nur die Signatur des Servers tut das. Bekommen Sie diese zwei richtig, und der Rest ist Installation: ein signierter Lease-Datensatz, ein Checkout, der pro Lizenz sperrt und nach Ablauf zählt, ein Heartbeat, der erneuert, ohne die Zählung zu berühren, ein Hintergrundwächter auf dem Client, der eine verlorene Anfrage von einem verfallenen Lease unterscheidet, und ein Reaper, der auf einem Abstand aufräumt. Der Lohn ist eine Zählung gleichzeitiger Plätze, die sich selbst heilt: Ein Kunde, der zehn Plätze kauft, kann immer zehn ausführen, ein abgestürzter Client kostet ihn höchstens eine TTL lang einen Platz, und niemand muss ein Ticket öffnen, weil die Lizenzierung stillschweigend seine Plätze aufgefressen hat.
Nebula.NET testen
Härten Sie Ihren .NET-Code in wenigen Minuten — starten Sie mit der kostenlosen Edition.