Більшість систем розпадаються під час перевантаження. Заповнення пам' яті, уповільнення запитів, скарги користувачів, аварійне завершення серверів.
Кілька незвичайних дістають краще.
Ця стаття показує, як Поведальна пам' ять, заснована на LRU стає самообороною, коли вона влучає в можливості - і як ця модель дає навчальну систему в моїй Рушій визначення botName. Це також найменша з можливих версій Архітектура DiSE - контролированная эволюция из-за давления на ресурсы.
Якщо ви прочитали мою статтю про CQRS і початок подійТут ви розпізнаєте деякі з цих шаблонів, але це CQRS, роздягнуто на кістку - без складу подій, без прогнозів, без Мартену, без кешу пам'яті, фонового працівника і SQLite.
Перш ніж ми зануримося, дозвольте мені визначити один термін, який я буду використовувати протягом: підпис. Підпис - це будь- який стабільний ключ, який відповідає шаблону поведінки - хеш IP + User- Agent, відбиток комбінацій заголовків, класифікація детектора " цей запит виглядає як X ." Кеш зберігає ці підписи разом з набутими значеннями ваги, які розвиваються з часом.
Що, якби ви могли побудувати систему, яка:
Це саме те, що IMemoryCache якщо ти розумієш, що ти будуєш.
За використання LRU (наостанок нещодавно використаних) записи виселяють, які ще не було відкрито. Після заповнення кешу найхолодніші записи буде викинуто, щоб зробити місце для гарячих.
Більшість розробників вважають це обмеженням. "О ні, мій кеш повний, дані втрачено!"
Але для систем поведінки це особливість.
// 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
});
Ось. SizeLimit це не просто обмеження на пам'ять. тиск виборуВін визначає, скільки "пам'ятається" система і змушує її зосереджуватися на тому, що є важливим.
Комбіновано з строком просування, ви автоматично забуваєте:
// 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
}
Если не получить доступ к подписи через 30 минут, его выселят, не потому что это неправильно, потому что это больше не важно.
Це створює природне забуття:
Статичні блок- списки стають застарілими. Слабкі терміни запам' ятовування зберігають пам' ять свіжою.
Ось шаблон, який робить це працює. SqliteWeightStore клас:
/// <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);
Це неформальний CQRS - але без стомливого кешу. Замість того, щоб робити записи кешу після запису, Кеш - це модель записуSQLite - це просто тривка книга.
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
Прозорість ключа: читати і записувати перейти до пам' ятіБаза даних врешті-решт послідовна - і це добре.
Якщо детектор потребує набутої ваги, він потрапляє у кеш:
// 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
}
Гарячі шляхи ніколи не влучити у базу данихСписунок - це лише резервне сховище.
Після того, як система дізнається про щось нове, вона негайно оновлює кеш і чергує запис бази даних до черги:
// 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;
}
Зауважте: UpdateWeightAsync return Task.CompletedTask - він, по суті, синхронний.
Кожні 500 мм, в черзі, запис буде перелито на SQLite в одній партії:
// 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();
}
}
Це access- Access- LightВи отримаєте:
Коли з' являється нове спостереження, система використовує експоненціальні середні значення для оновлення ваги:
// 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;
}
Формула EMA згладжує навчання: new_weight = old_weight × (1 - α) + new_value × α
З ⇩ = 0.1:
Ось ключове розуміння, яке пропускає більшість людей.
Під час заповнення кешу:
Подумайте про це: якщо 50 000 унікальних підписів торкнуться вашого детектора, але у вас тільки пам'ять на 10 000, які підписи мають значення?
Найгарніші 10 000 - який зазвичай представляє 99% реального дорожнього руху.
У постановці я бачу приблизно 40 000 підписів на день (скрипників, що намагаються один раз, випадкові зонди, законних користувачів, які ніколи не повертаються) і, можливо, 510 000 це постійно.
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
Переповнення гострення Поведение воспоминания, самооптимия системы.
Є випадки, коли тиск на LRU діє проти вас:
Кеш занадто малий: Якщо у вашому кеші міститься лише 100 записів, але у вас є 1000 справді важливих підписів, ви будете постійно відкладати і втрачати корисні шаблони, перш ніж вони набудуть достатньо доказів. Розмір вашого кешу з метою зручності утримування вашого " гарячого набору ."
Однорідний трафік: Якщо ви працюєте у крихітній внутрішній системі, у якій майже все " розпечено " (кількість унікальних підписів, всі підписи повторюються), переповнення надає вам меншу перевагу. Проти тиску вибору немає нічого вибору.
Проблема з Холодним запуском: Система, яку тільки- но було встановлено, не має набутої ваги. Все однаково холодно. Перші декілька годин будуть мати вищі хибні додатні ставки до того часу, доки гарячі підписи не встановлять самих себе.
Шаблон працює найкраще, якщо у вас є висока кардинальність з розподілом законодавства - багато унікальних підписів, але невелика підмножина, яка домінує у трафікі.
Кеш - це джерело правди для читання, але базою даних є тривка книга.
Під час розпаду ваги бази даних кеш має бути за ним слідом. DecayOldWeightsAsync Метод роботи з обома:
// 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);
}
}
Після розпаду бази даних, ми викликаємо _cache.Compact(0.25) - це змушує MemoryCache до виселення 25% записів цього запису, презентація найменш використаних нещодавно. Наступне читання призведе до перезавантаження нових значень з бази даних.
Іноді вам слід заборонити кешування цілої категорії записів, - наприклад, під час повторення детектора або зміни зовнішніх даних:
// 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 не має домашнього виселення, як Редіса, але стиснення досягає того ж ефекту: примусово видаляє застарілі записи, давайте повторно прочитати їх з бази даних.
Основне розуміння в тому, що досконала синхронізація не потрібна. Система дозволяє дрейфувати, тому що:
Ця послідовність виконується правильно. Кеш залишається " досить близькою " до бази даних без потреби у складній логіці ініціалізації.
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
Ніякого фіксації кешу, ніякого складного пабу/під. Просто складання і природній строк дії.
Примітка: Якщо вам потрібен лише шаблон LRU + write- being, ви можете зупинитися тут. Решта цієї статті показує, як я застосовую ті самі ідеї до повної якості - машини стану, гістерез, і часовий розпад. Це "extra mile " для тих, хто будує адаптивні системи.
Для репутації взірця (дослідження того, чи підпис є bott чи людським з плином часу), застосовуються ті самі принципи, але з додатковою витонченістю:
// 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;
}
Візерунки не перевертаються безпосередньо з нейтрального до Підтвердженого Бада. Існує гістерез, який запобігає змахуванню:
// 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 ...
}
Зверніть увагу на асиметрію: легше бути заблокованим, ніж заблокованим. ConfirmedBad → Suspect Для підтримки потрібно 100, а для підтримки Neutral → Suspect Це навмисно - пробачати важче, ніж підозрювати.
Коли взірці замовкають, вони розпадаються до нейтрального:
// 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
};
}
Сталі типового часу:
Це означає, що підтверджена IP-парат, яка мовчить протягом місяця, врешті-решт повернеться до нейтральності. Не тому, що вона виправилася, тому що її свідчення стали застарілими.
The ReputationMaintenanceService Запускає три періодичні завдання:
// 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);
}
Збірник сміття вилучає шаблони:
Це запобігає тому, що словник пам'яті росте необгрунтованим під час збереження цінних набутих візерунків.
Багато розробників одразу отримують доступ до PostgreSQL або Reredis. Але для такого зразка SQLite є ідеальним:
Схема є мінімальною:
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);
Якщо вам потрібен більший розмір, поміняйтеся на PostgreSQL. Якщо вам потрібна AN або відтворення, перемкніться на Redis або розподілений кеш. Архітектура не змінюється, - лише рядок з' єднання. SQLite є типовим для визначення меж, а не релігією.
Якщо ДІС це мітохондрія - найменша частина, яка все ще поводиться, як еволюція під обмеженням.
Цей зразок реалізує принципи DiSE на мінімальному рівні:
Повний кеш не є провалом. Еволюційний тиск... Система самооптимізації: гарячі підписи залишаються, холодні підписи виселяються, і поведінка пам'яті сходиться з тим, що насправді важливо.
Без тренування ML, без зовнішніх моделей, просто архітектура, яка поводиться як жива система.
Весь шаблон зводиться до:
Ваш маленький магазин працює швидше як живий, ніж CRUD, він пам'ятає, що має значення, забуває те, чого немає, і стає краще під тиском.
Мінімальна архітектура → експонентна коректність. Переповнення → краще зосередься. Тиск → стабільність.
Якщо ви хочете бачити це у дії, гляньте three locid. bottom definition - і Серія архітектур DiSE для глибшої філософії.
© 2026 Scott Galloway — Unlicense — All content and source code on this site is free to use, copy, modify, and sell.