This is a viewer only at the moment see the article on how this works.
To update the preview hit Ctrl-Alt-R (or ⌘-Alt-R on Mac) or Enter to refresh. The Save icon lets you save the markdown file to disk
This is a preview from the server running through my markdig pipeline
Tuesday, 09 December 2025
La maggior parte dei sistemi degrada quando sovraccaricato. La memoria si riempie, le query rallentano, gli utenti si lamentano, i server si bloccano.
Alcuni insoliti ottenere meglio.
Questo articolo mostra come un Memoria comportamentale basata su LRU diventa auto-ottimizzante quando colpisce la capacità - e come questo modello alimenta il sistema di apprendimento nel mio Motore di rilevamento bot. Questa è anche la versione più piccola possibile di Architettura DiSE - l'evoluzione controllata attraverso la pressione delle risorse.
Se hai letto il mio articolo su CQRS ed Event Sourcing, riconoscerete alcuni dei modelli qui. Ma questo è CQRS spogliato fino all'osso - nessun negozio di eventi, nessuna proiezione, nessun Marten. Solo una cache di memoria, un lavoratore di background, e SQLite.
Prima di tuffarci, fammi definire un termine che userò in tutto: firma. Una firma è qualsiasi chiave stabile che rappresenta un modello di comportamento - un hash di IP + User-Agent, un'impronta digitale di combinazioni di intestazione, una classificazione del rivelatore di "questa richiesta assomiglia a X." La cache memorizza queste firme insieme ai pesi appresi che si evolvono nel tempo.
E se potessi costruire un sistema che:
E' esattamente quello che IMemoryCache con scadenza scorrevole ti dà - se capisci cosa stai costruendo.
LRU (Least Recently Used) cache sfratto voci che non sono state accessibili di recente. Quando la cache si riempie, le voci più fredde vengono gettati fuori per fare spazio per quelli caldi.
La maggior parte degli sviluppatori vede questo come una limitazione. "Oh no, la mia cache è piena, i dati vengono persi!"
Ma per i sistemi comportamentali, questa è una caratteristica.
// From WeightStore.cs - the bounded memory window
_cache = new MemoryCache(new MemoryCacheOptions
{
SizeLimit = _cacheSize, // e.g., 1000 entries
CompactionPercentage = 0.25 // Remove 25% when limit reached
});
Che SizeLimit non è solo un vincolo di memoria. pressione di selezioneDetermina quanto il sistema "ricorda" e lo costringe a concentrarsi su ciò che conta.
Combinato con scadenza scorrevole, si ottiene automaticamente dimenticando:
// From WeightStore.cs:254-259
private MemoryCacheEntryOptions GetCacheEntryOptions()
{
return new MemoryCacheEntryOptions()
.SetSlidingExpiration(_slidingExpiration) // 30 minutes
.SetSize(1); // Each entry counts as 1 toward size limit
}
Se una firma non è accessibile entro 30 minuti, è sfrattato. Non perché è sbagliato - perché non è più rilevante.
Questo crea dimenticanza naturale:
I blocklist statici sono stantii. La scadenza scorrevole mantiene la memoria fresca.
Ecco lo schema che lo fa funzionare. SqliteWeightStore classe:
/// <summary>
/// SQLite implementation of the weight store with sliding expiration memory cache.
/// Uses a CQRS-style pattern with write-behind:
/// - Reads: Hit memory cache first (fast path), fall back to SQLite on miss
/// - Writes: Update cache immediately, queue SQLite writes for background flush
/// Sliding expiration provides automatic LRU-like eviction behavior.
/// </summary>
public class SqliteWeightStore : IWeightStore, IAsyncDisposable
{
// Memory cache with sliding expiration - auto-evicts least recently used entries
private readonly MemoryCache _cache;
// Write-behind queue for batched SQLite persistence
private readonly ConcurrentDictionary<string, PendingWrite> _pendingWrites = new();
private readonly Timer _flushTimer;
private readonly TimeSpan _flushInterval = TimeSpan.FromMilliseconds(500);
Questo e' CQRS informale - ma senza la noiosa invalidazione della cache. Invece di invalidare le voci della cache dopo la scrittura, la cache è il modello di scrittura. SQLite è solo il libro mastro durevole.
flowchart LR
subgraph Cache["In-Memory Behaviour Store"]
A[Hot Signatures] --- B[Sliding Expiry]
end
subgraph DB["SQLite Ledger"]
C[(Durable Write-Behind)]
end
A --Periodic Flush--> C
B --Eviction--> D[Forgotten]
style Cache fill:none,stroke:#10b981,stroke-width:2px
style DB fill:none,stroke:#6366f1,stroke-width:2px
Intuizione chiave: legge e scrive andare alla memoria. Il database è alla fine coerente - e va bene.
Quando un rivelatore ha bisogno di un peso imparato, colpisce la cache:
// From WeightStore.cs:421-472
public async Task<double> GetWeightAsync(
string signatureType,
string signature,
CancellationToken ct = default)
{
var key = CacheKey(signatureType, signature);
// Check cache first (fast path - no DB access)
if (_cache.TryGetValue(key, out LearnedWeight? cached) && cached != null)
{
_metrics?.RecordCacheHit(signatureType);
return cached.Weight * cached.Confidence;
}
_metrics?.RecordCacheMiss(signatureType);
// Cache miss - load from DB
await EnsureInitializedAsync(ct);
await using var conn = new SqliteConnection(_connectionString);
await conn.OpenAsync(ct);
var sql = $@"
SELECT weight, confidence, observation_count, first_seen, last_seen
FROM {TableName}
WHERE signature_type = @type AND signature = @sig
";
// ... execute query ...
if (await reader.ReadAsync(ct))
{
// Cache the result for future reads
var learnedWeight = new LearnedWeight { /* ... */ };
_cache.Set(key, learnedWeight, GetCacheEntryOptions());
return weight * confidence;
}
return 0.0; // No learned weight exists
}
Percorsi caldi non ha mai colpito il database. La cache è la fonte di verità per leggere. SQLite è solo backup storage.
Quando il sistema impara qualcosa di nuovo, aggiorna immediatamente la cache e mette in coda il database scrive:
// From WeightStore.cs:551-582
public Task UpdateWeightAsync(
string signatureType,
string signature,
double weight,
double confidence,
int observationCount,
CancellationToken ct = default)
{
var key = CacheKey(signatureType, signature);
// Update cache immediately (source of truth for reads)
var learnedWeight = new LearnedWeight
{
SignatureType = signatureType,
Signature = signature,
Weight = weight,
Confidence = confidence,
ObservationCount = observationCount,
FirstSeen = DateTimeOffset.UtcNow,
LastSeen = DateTimeOffset.UtcNow
};
_cache.Set(key, learnedWeight, GetCacheEntryOptions());
// Queue for async SQLite persistence (write-behind)
QueueWrite(signatureType, signature, weight, confidence, observationCount);
return Task.CompletedTask;
}
Avviso: UpdateWeightAsync ritorna Task.CompletedTask - è essenzialmente sincrono. La scrittura è in coda, non eseguita. Questo significa:
Ogni 500m, in attesa di scrittura vengono lavati su SQLite in un unico lotto:
// From WeightStore.cs:274-357
public async Task FlushPendingWritesAsync(CancellationToken ct = default)
{
if (_pendingWrites.IsEmpty) return;
// Only one flush at a time
if (!await _flushLock.WaitAsync(0, ct)) return;
try
{
await EnsureInitializedAsync(ct);
// Snapshot and clear pending writes atomically
var writes = new List<PendingWrite>();
foreach (var key in _pendingWrites.Keys.ToList())
{
if (_pendingWrites.TryRemove(key, out var write))
{
writes.Add(write);
}
}
if (writes.Count == 0) return;
await using var conn = new SqliteConnection(_connectionString);
await conn.OpenAsync(ct);
await using var transaction = await conn.BeginTransactionAsync(ct);
try
{
var sql = $@"
INSERT INTO {TableName}
(signature_type, signature, weight, confidence,
observation_count, first_seen, last_seen)
VALUES (@type, @sig, @weight, @conf, @count, @now, @now)
ON CONFLICT(signature_type, signature) DO UPDATE SET
weight = @weight,
confidence = @conf,
observation_count = @count,
last_seen = @now
";
foreach (var write in writes)
{
await using var cmd = new SqliteCommand(sql, conn, transaction);
// ... add parameters and execute ...
}
await transaction.CommitAsync(ct);
_logger.LogDebug("Flushed {Count} pending writes in {Duration:F1}ms",
writes.Count, sw.ElapsedMilliseconds);
}
catch
{
await transaction.RollbackAsync(ct);
throw;
}
}
finally
{
_flushLock.Release();
}
}
Questo e' Event-sourcing-light. Ottieni:
Quando arriva una nuova osservazione, il sistema utilizza medie mobili esponenziali per aggiornare i pesi:
// From WeightStore.cs:584-635
public Task RecordObservationAsync(
string signatureType,
string signature,
bool wasBot,
double detectionConfidence,
CancellationToken ct = default)
{
var key = CacheKey(signatureType, signature);
// Calculate new weight using EMA in memory
var alpha = 0.1; // Learning rate
var weightDelta = wasBot ? detectionConfidence : -detectionConfidence;
double newWeight;
double newConfidence;
int newObservationCount;
if (_cache.TryGetValue(key, out LearnedWeight? existing) && existing != null)
{
// Apply EMA: new_weight = old_weight * (1-α) + delta * α
newWeight = existing.Weight * (1 - alpha) + weightDelta * alpha;
newConfidence = Math.Min(1.0, existing.Confidence + detectionConfidence * 0.01);
newObservationCount = existing.ObservationCount + 1;
}
else
{
// First observation
newWeight = weightDelta;
newConfidence = detectionConfidence;
newObservationCount = 1;
}
// Update cache immediately
var learnedWeight = new LearnedWeight
{
SignatureType = signatureType,
Signature = signature,
Weight = newWeight,
Confidence = newConfidence,
ObservationCount = newObservationCount,
FirstSeen = existing?.FirstSeen ?? DateTimeOffset.UtcNow,
LastSeen = DateTimeOffset.UtcNow
};
_cache.Set(key, learnedWeight, GetCacheEntryOptions());
// Queue for persistence
QueueWrite(signatureType, signature, newWeight, newConfidence, newObservationCount);
return Task.CompletedTask;
}
La formula EMA facilita l'apprendimento: new_weight = old_weight × (1 - α) + new_value × α
Con α = 0,1:
Ecco l'intuizione chiave che la maggior parte della gente manca.
Quando la cache si riempie:
Pensaci: se 50.000 firme uniche colpiscono il tuo bot detector, ma hai solo memoria per 10.000, quali firme contano?
I più caldi 10.000 - che rappresentano in genere il 99% del traffico effettivo.
Nella produzione, vedo circa 40.000 firme una tantum al giorno (i grattacieli che provano una volta, le sonde casuali, gli utenti legittimi che non ritornano) e forse 5 040.10.000 che si ripetono costantemente. Quei 5 040.10.000 sono dove vive il 99% del rischio. Le firme a coda lunga? Noise. Evicting loro non danneggia l'accuratezza di rilevamento - potrebbe anche migliorarlo riducendo i falsi positivi da modelli di bassa fiducia.
flowchart TB
subgraph Input["50,000 Unique Signatures"]
Hot[Hot Signatures\n~10,000]
Cold[Cold Signatures\n~40,000]
end
subgraph Cache["Bounded Cache (10,000)"]
Kept[Kept in Memory]
end
subgraph Evicted["Evicted"]
Lost[Forgotten\nNoise Traffic]
end
Hot --> Kept
Cold --> Lost
style Hot fill:none,stroke:#10b981,stroke-width:2px
style Cold fill:none,stroke:#94a3b8,stroke-width:2px
style Kept fill:none,stroke:#10b981,stroke-width:2px
style Lost fill:none,stroke:#ef4444,stroke-width:2px
Sovraccarico affilati la memoria comportamentale. Il sistema si auto-ottimizza.
Non e' magia, ci sono casi in cui la pressione dell'LRU funziona contro di te:
Cache troppo piccola: Se la tua cache contiene solo 100 voci ma hai 1.000 firme veramente importanti, lancerai costantemente e perderai modelli utili prima che accumulino abbastanza prove. Dimendi la tua cache per tenere comodamente il tuo "hot set."
Traffico uniforme: Se si sta eseguendo su un piccolo sistema interno dove quasi tutto è "caldo" (poche firme uniche, tutto ricorrente), overflow ti dà meno beneficio. La pressione di selezione non ha nulla da selezionare contro.
Problemi di avviamento a freddo: Un sistema appena implementato non ha pesi appresi. Tutto è ugualmente freddo. Le prime ore avranno tassi falsi positivi più alti fino a quando le firme calde si stabiliscono.
Il modello funziona meglio quando si dispone alta cardinalità con distribuzione power-law - un sacco di firme uniche, ma un piccolo sottoinsieme che domina il traffico.
La cache è la fonte della verità per leggere, ma il database è il registro durevole. Cosa succede quando si spostano?
Quando il database pesa decadimento, la cache deve seguire. DecayOldWeightsAsync il metodo gestisce entrambi:
// From WeightStore.cs:725-766
public async Task DecayOldWeightsAsync(TimeSpan maxAge, double decayFactor, CancellationToken ct = default)
{
await EnsureInitializedAsync(ct);
await using var conn = new SqliteConnection(_connectionString);
await conn.OpenAsync(ct);
var cutoff = DateTimeOffset.UtcNow.Subtract(maxAge).ToString("O");
// Decay old weights in the database
var sql = $@"
UPDATE {TableName}
SET weight = weight * @decay,
confidence = confidence * @decay
WHERE last_seen < @cutoff
";
await using var cmd = new SqliteCommand(sql, conn);
cmd.Parameters.AddWithValue("@decay", decayFactor);
cmd.Parameters.AddWithValue("@cutoff", cutoff);
var updated = await cmd.ExecuteNonQueryAsync(ct);
// Delete weights that have decayed below threshold
var deleteSql = $@"
DELETE FROM {TableName}
WHERE confidence < 0.01 OR (ABS(weight) < 0.01 AND observation_count < 5)
";
await using var deleteCmd = new SqliteCommand(deleteSql, conn);
var deleted = await deleteCmd.ExecuteNonQueryAsync(ct);
if (updated > 0 || deleted > 0)
{
_logger.LogInformation(
"Weight decay: {Updated} decayed, {Deleted} deleted",
updated, deleted);
// Compact cache to remove stale entries
_cache.Compact(0.25);
}
}
Dopo il decadimento dei registri del database, chiamiamo _cache.Compact(0.25) - questo forza il MemoryCache per sfrattare il 25% delle sue voci, dando la priorità al meno recentemente utilizzato. La prossima lettura ricarica i valori freschi dal database.
A volte è necessario invalidare un'intera categoria di voci della cache - per esempio, quando si riqualifica un rivelatore o quando i dati esterni cambiano:
// From WeightStore.cs:768-777
/// <summary>
/// Evicts all cached entries for a specific signature type (tag-based eviction).
/// </summary>
public void EvictByTag(string signatureType)
{
// MemoryCache doesn't natively support tag-based eviction, but we can compact
// For now, just compact - sliding expiration will handle stale entries
_cache.Compact(0.1);
_logger.LogDebug("Compacted cache for signature type: {SignatureType}", signatureType);
}
.NET's MemoryCache non ha lo sfratto nativo basato su tag come Redis, ma la compattazione raggiunge lo stesso effetto: forzare le voci stantie, lasciare leggere repopulate dal database.
L'intuizione chiave è che La perfetta sincronizzazione non è necessaria. Il sistema tollera la deriva perché:
Questa è l'eventuale coerenza fatta bene. La cache rimane "abbastanza vicina" al database senza richiedere complesse logiche di invalidazione.
flowchart TB
subgraph Sync["Cache-Database Synchronisation"]
D[Database Decay] --> C[Cache Compact]
E[Tag Eviction] --> C
S[Sliding Expiration] --> M[Cache Miss]
M --> R[Reload from DB]
end
style Sync fill:none,stroke:#6366f1,stroke-width:2px
Nessun inferno di nullità della cache. Nessun pub/sub complesso. Solo compattazione e naturale scadenza.
Nota: Se volevi solo il modello LRU + write-behind, puoi fermarti qui. Il resto di questo articolo mostra come applico le stesse idee alla piena reputazione del modello - macchine di stato, isteresi e decadimento del tempo. E' il "miglio supplementare" per quei sistemi adattivi di costruzione.
Per la reputazione del modello (tracciando se una firma è bot o umano nel tempo), si applicano gli stessi principi ma con ulteriore sofisticazione:
// From PatternReputation.cs:42-108
public record PatternReputation
{
public required string PatternId { get; init; }
public required string PatternType { get; init; }
public required string Pattern { get; init; }
/// <summary>Current bot probability [0,1]. 0 = human, 1 = bot, 0.5 = neutral</summary>
public double BotScore { get; init; } = 0.5;
/// <summary>Effective sample count - decays over time, increases with observations</summary>
public double Support { get; init; } = 0;
/// <summary>Current reputation state - determines fast-path behavior</summary>
public ReputationState State { get; init; } = ReputationState.Neutral;
// Computed properties
public double Confidence => Math.Min(1.0, Support / 100.0);
public bool CanTriggerFastAbort =>
State is ReputationState.ConfirmedBad or ReputationState.ManuallyBlocked;
public bool CanTriggerFastAllow =>
State is ReputationState.ConfirmedGood or ReputationState.ManuallyAllowed;
}
I modelli non girano direttamente da Neutral a ConfermatoBad. C'è isteresi per evitare sbalzi:
// From PatternReputation.cs:367-421 - simplified
public PatternReputation EvaluateStateChange(PatternReputation reputation)
{
if (reputation.IsManual)
return reputation;
var newState = reputation.State;
var score = reputation.BotScore;
var support = reputation.Support;
switch (reputation.State)
{
case ReputationState.Neutral:
// Can promote to Suspect or ConfirmedGood
if (score >= 0.6 && support >= 10)
newState = ReputationState.Suspect;
else if (score <= 0.1 && support >= 100)
newState = ReputationState.ConfirmedGood;
break;
case ReputationState.Suspect:
// Can promote to ConfirmedBad or demote to Neutral
if (score >= 0.9 && support >= 50)
newState = ReputationState.ConfirmedBad;
else if (score <= 0.4 || support < 10)
newState = ReputationState.Neutral;
break;
case ReputationState.ConfirmedBad:
// Can demote to Suspect (requires MORE evidence to forgive)
if (score <= 0.7 && support >= 100)
newState = ReputationState.Suspect;
break;
}
// ... log state change and return ...
}
Notate l'asimmetria: è più facile bloccarsi che sbloccare. ConfirmedBad → Suspect richiede 100 supporto, mentre Neutral → Suspect Ne servono solo 10. Questo è intenzionale - è più difficile perdonare che sospettare.
Quando i modelli si calmano, si declinano verso la neutralità:
// From PatternReputation.cs:334-361
public PatternReputation ApplyTimeDecay(PatternReputation reputation)
{
if (reputation.IsManual)
return reputation;
var hoursSinceLastSeen = (DateTimeOffset.UtcNow - reputation.LastSeen).TotalHours;
if (hoursSinceLastSeen < 1)
return reputation; // Too recent to decay
// Score decay toward prior (0.5 = neutral)
// new_score = old_score + (prior - old_score) × (1 - e^(-Δt/τ))
var scoreDecayFactor = 1 - Math.Exp(-hoursSinceLastSeen / _options.ScoreDecayTauHours);
var newScore = reputation.BotScore + (0.5 - reputation.BotScore) * scoreDecayFactor;
// Support decay
// new_support = old_support × e^(-Δt/τ)
var supportDecayFactor = Math.Exp(-hoursSinceLastSeen / _options.SupportDecayTauHours);
var newSupport = reputation.Support * supportDecayFactor;
return reputation with
{
BotScore = Math.Clamp(newScore, 0, 1),
Support = newSupport
};
}
Le costanti di tempo predefinite:
Ciò significa che un IP confermato-cattivo che va tranquillo per un mese alla fine ridiscenderà a Neutral. Non perché si è riformato - perché la sua evidenza è diventata stantia.
La ReputationMaintenanceService svolge tre compiti periodici:
// From ReputationMaintenanceService.cs:48-129
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
_logger.LogInformation("Reputation maintenance service starting");
// Load persisted reputations on startup
await _cache.LoadAsync(stoppingToken);
var decayInterval = TimeSpan.FromMinutes(60); // Hourly decay sweep
var gcInterval = TimeSpan.FromHours(24); // Daily garbage collection
var persistInterval = TimeSpan.FromMinutes(5); // Persist every 5 minutes
var lastDecay = DateTimeOffset.UtcNow;
var lastGc = DateTimeOffset.UtcNow;
var lastPersist = DateTimeOffset.UtcNow;
while (!stoppingToken.IsCancellationRequested)
{
await Task.Delay(TimeSpan.FromMinutes(1), stoppingToken);
var now = DateTimeOffset.UtcNow;
// Decay sweep: push stale scores toward neutral
if (now - lastDecay >= decayInterval)
{
await _cache.DecaySweepAsync(stoppingToken);
lastDecay = now;
}
// Garbage collection: remove old neutral patterns
if (now - lastGc >= gcInterval)
{
await _cache.GarbageCollectAsync(stoppingToken);
lastGc = now;
var stats = _cache.GetStats();
_logger.LogInformation(
"Reputation stats: {Total} patterns, {Bad} bad, {Suspect} suspect",
stats.TotalPatterns, stats.ConfirmedBadCount, stats.SuspectCount);
}
// Persistence: save to SQLite
if (now - lastPersist >= persistInterval)
{
await _cache.PersistAsync(stoppingToken);
lastPersist = now;
}
}
// Final persist on shutdown
await _cache.PersistAsync(CancellationToken.None);
}
Il raccoglitore di rifiuti rimuove i modelli che sono:
Questo impedisce al dizionario in-memory di crescere senza limiti, pur conservando preziosi modelli appresi.
Un sacco di sviluppatori reflexively raggiungere per PostgreSQL o Redis. Ma per questo modello, SQLite è ideale:
Lo schema è minimo:
CREATE TABLE IF NOT EXISTS learned_weights (
signature_type TEXT NOT NULL,
signature TEXT NOT NULL,
weight REAL NOT NULL,
confidence REAL NOT NULL,
observation_count INTEGER NOT NULL DEFAULT 1,
first_seen TEXT NOT NULL,
last_seen TEXT NOT NULL,
PRIMARY KEY (signature_type, signature)
);
CREATE INDEX IF NOT EXISTS idx_signature_type ON learned_weights(signature_type);
CREATE INDEX IF NOT EXISTS idx_confidence ON learned_weights(confidence);
CREATE INDEX IF NOT EXISTS idx_last_seen ON learned_weights(last_seen);
Se hai bisogno di una scala più grande, scambia su PostgreSQL. Se hai bisogno di HA o replica, scambia su Redis o una cache distribuita. L'architettura non cambia - solo la stringa di connessione. SQLite è l'impostazione predefinita per l'implementazione dei bordi, non una religione.
Se DiSE è il motore evolutivo completo, questo modello di cache è il mitocondrio - il pezzo più piccolo che si comporta ancora come evoluzione sotto costrizione.
Questo modello implementa i principi DiSE al livello più minimo:
Una cache completa non e' un fallimento. pressione evolutiva. Il sistema si auto-ottimizza: le firme calde rimangono, le firme fredde sfrattano, e la memoria comportamentale converge verso ciò che realmente conta.
Niente training ML, niente modelli esterni, solo architettura che si comporta come un sistema vivente.
L'intero schema si riduce a:
Il tuo piccolo negozio comportamentale agisce più come un sistema vivente che come CRUD. Ricorda ciò che conta, dimentica ciò che non fa, e migliora sotto pressione.
Architettura minimale → correttezza emergente. Overflow → messa a fuoco migliore. Pressione → stabilità.
Se vuoi vedere questo in azione, check out per lo piùlucid.botdetection - e la Serie di architettura DiSE per la filosofia più profonda.
© 2026 Scott Galloway — Unlicense — All content and source code on this site is free to use, copy, modify, and sell.