Aprender LRUs - Cuando superar la capacidad mejora su sistema (Español (Spanish))

Aprender LRUs - Cuando superar la capacidad mejora su sistema

Tuesday, 09 December 2025

//

20 minute read

La mayoría de los sistemas se degradan cuando se sobrecargan. La memoria se llena, las consultas se ralentizan, los usuarios se quejan, los servidores se bloquean.

Unas pocas inusuales consiguen mejor.

Este artículo muestra cómo un Memoria de comportamiento basada en LRU se auto-optimiza cuando golpea la capacidad - y cómo este patrón potencia el sistema de aprendizaje en mi Motor de detección de bots. Esta es también la versión más pequeña posible de Arquitectura DiSE - la evolución controlada a través de la presión de los recursos.

Si has leído mi artículo sobre CQRS y Abastecimiento de EventosPero esto es CQRS despojado hasta el hueso - sin tienda de eventos, sin proyecciones, sin Marten. Sólo un caché de memoria, un trabajador de fondo, y SQLite.

La idea básica - Memoria conductual sobre un presupuesto

Antes de sumergirnos, permítanme definir un término que usaré en todo: firma. Una firma es cualquier clave estable que representa un patrón de comportamiento - un hash de IP + User-Agent, una huella dactilar de combinaciones de encabezados, la clasificación de un detector de "esta petición se parece a X". La caché almacena estas firmas junto con los pesos aprendidos que evolucionan con el tiempo.

¿Y si pudieras construir un sistema que:

  • Responde en menos de 1 ms
  • Nunca bloquea en las escrituras de la base de datos
  • Auto-optimización bajo presión
  • Olvida lo que no importa
  • Recuerda lo que hace

Eso es exactamente lo que IMemoryCache con la expiración deslizante le da - si usted entiende lo que está construyendo.

Lo que un caché de LRU realmente hace

LRU (menos usado recientemente) desaloja entradas que no han sido accedidas recientemente. Cuando la caché se llena, las entradas más frías son arrojadas para hacer espacio para las más calientes.

La mayoría de los desarrolladores ven esto como una limitación. "¡Oh, no, mi caché está lleno, los datos se están perdiendo!"

Pero para los sistemas de comportamiento, esto es una característica.

// 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
});

Que SizeLimit No es sólo una restricción de la memoria. presión de selección. Determina cuánto "recuerda" el sistema y lo obliga a centrarse en lo que importa.

Caducidad deslizante - El reloj que olvida

Combinado con la expiración deslizante, se obtiene el olvido automático:

// 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
}

Si no se accede a una firma en 30 minutos, se la desaloja, no porque esté mal, porque ya no es relevante.

Esto crea olvido natural:

  • Se reasignan IPs
  • Los bots rotan las firmas
  • Desplazamiento de las pautas de tráfico
  • Los ataques de ayer no son de hoy.

Los bloqueadores estáticos se vuelven rancios. La caducidad deslizante mantiene la memoria fresca.

El patrón IMemoryCache - Pequeño CQRS sin decir CQRS

Aquí está el patrón que hace que funcione. SqliteWeightStore clase:

/// <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);

Esto es CQRS informal En lugar de invalidar las entradas de caché después de escribir, la caché ES el modelo de escrituraSQLite es sólo el libro mayor duradero.

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

Perspicacia clave: leer y escribir ir a la memoriaLa base de datos es finalmente consistente - y eso está bien.

El camino de lectura - caché primero, siempre

Cuando un detector necesita un peso aprendido, golpea la caché:

// 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
}

Caminos calientes nunca ir a la base de datos. La caché es la fuente de la verdad para las lecturas. SQLite es sólo almacenamiento de copia de seguridad.

El camino de la escritura - caché inmediatamente, persistir más tarde

Cuando el sistema aprende algo nuevo, actualiza la caché inmediatamente y hace colas para escribir la base de datos:

// 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;
}

Aviso: UpdateWeightAsync devuelve Task.CompletedTask La escritura está en cola, no ejecutada.

  • Latencia de escritura sub-millisegundo
  • Sin bloqueo de la E/S
  • Las escrituras están coalescadas (últimas victorias de escritura)

El fondo de flusher - 500ms de magia aburrida

Cada 500ms, las escrituras pendientes son descargadas a SQLite en un solo lote:

// 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();
    }
}

Esto es evento-sourcing-light. Obtienes:

  • E/S eficiente
  • Coherencia de las transacciones
  • Actualizaciones coalescadas (si la misma firma se actualiza 10 veces en 500ms, sólo se escribe el valor final)
  • SQLite está perfectamente contento con este patrón de acceso

Actualizaciones de EMA - Aprendizaje con promedios móviles exponenciales

Cuando llega una nueva observación, el sistema utiliza promedios móviles exponenciales para actualizar los pesos:

// 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 fórmula EMA mejora el aprendizaje: new_weight = old_weight × (1 - α) + new_value × α

Con α = 0,1:

  • Nuevas pruebas contribuyen al 10%
  • La evidencia histórica aporta el 90%
  • Esto evita oscilaciones salvajes de observaciones individuales

Por qué el desbordamiento hace que el sistema Mejor

Aquí está la perspicacia clave que la mayoría de la gente echa de menos.

Cuando la caché se llena:

  • Se desprenden firmas de baja frecuencia
  • Sólo las firmas calientes (frecuentemente accesibles) permanecen en la memoria
  • La base de datos se retrasa en un ciclo de descarga - y eso está bien
  • El sistema focos bajo presión

Piénsalo: si 50.000 firmas únicas golpean tu detector de bots, pero solo tienes memoria por 10.000, ¿qué firmas importan?

Los 10.000 más calientes - que normalmente representan el 99% del tráfico real.

En la producción, veo aproximadamente 40.000 firmas únicas por día (rascadores que intentan una vez, sondas aleatorias, usuarios legítimos que nunca regresan) y quizás 5 a 10.000 que recurren constantemente. Esos 5 a 10.000 son donde vive el 99% del riesgo. ¿Las firmas de cola larga? Ruido. Desalojarlos no perjudica la precisión de detección - incluso podría mejorar al reducir los falsos positivos de los patrones de baja confianza.

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

Desbordamiento afiladas la memoria conductual, el sistema se auto-optimiza.

Cuando el desbordamiento no ayuda

Hay casos en los que la presión de LRU funciona en tu contra:

  • Caché demasiado pequeño: Si tu caché solo contiene 100 entradas pero tienes 1.000 firmas genuinamente importantes, vas a batir constantemente y perderás patrones útiles antes de que acumulen suficiente evidencia. Tamaño tu caché para sostener cómodamente tu "set caliente".

  • Tráfico uniforme: Si usted está ejecutando en un pequeño sistema interno donde casi todo está "caliente" (pocas firmas únicas, todas recurrentes), el desbordamiento le da menos beneficio. La presión de selección no tiene nada contra qué seleccionar.

  • Problema de arranque en frío: Un sistema recién implementado no tiene pesas aprendidas. Todo está igual de frío. Las primeras horas tendrán tasas de falsos positivos más altas hasta que las firmas calientes se establezcan.

El patrón funciona mejor cuando tienes alta cardinalidad con distribución de la ley de poder - un montón de firmas únicas, pero un pequeño subconjunto que domina el tráfico.

Mantener caché y base de datos en sincronización - Invalidación basada en etiquetas

La caché es la fuente de la verdad para las lecturas, pero la base de datos es el libro mayor duradero.

Sincronización de la descomposición

Cuando los pesos de la base de datos decaen, la caché tiene que seguir. DecayOldWeightsAsync método maneja ambos:

// 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);
    }
}

Después de decaer los registros de la base de datos, llamamos _cache.Compact(0.25) - esto obliga a la MemoryCache para desalojar el 25% de sus entradas, dando prioridad a las menos utilizadas recientemente. La siguiente lectura recargará los valores frescos de la base de datos.

Evasión basada en etiquetas

A veces es necesario invalidar toda una categoría de entradas en caché - por ejemplo, cuando se readiestra un detector o cuando los datos externos cambian:

// 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 no tiene desalojo basado en etiquetas nativas como Redis, pero la compactación logra el mismo efecto: forzar entradas rancios, dejar leer repoblar desde la base de datos.

La estrategia de sincronización

La perspicacia clave es que perfecta sincronización no es necesario. El sistema tolera la deriva porque:

  1. Falla caché recarga desde DB - si se desaloja una entrada, la siguiente lectura obtiene datos nuevos
  2. Expiración deslizante maneja rancio - entradas a las que no se accede en un plazo de 30 minutos
  3. Renovación de las fuerzas de compactación - compactación periódica empuja hacia fuera entradas antiguas
  4. Actualizaciones de las coalescencias detrás de la escritura - múltiples actualizaciones rápidas se convierten en una escritura DB

Esta es la consistencia eventual hecha bien. La caché permanece "lo suficientemente cerca" de la base de datos sin requerir lógica de invalidación compleja.

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

No hay infierno de invalidación de caché. No hay pub/sub complejo. Sólo compactación y caducidad natural.

Más profundo: el sistema de reputación

Nota: El resto de este artículo muestra cómo aplico las mismas ideas a la reputación completa del patrón - máquinas de estado, histéresis, y decaimiento del tiempo. Es la "milla extra" para aquellos que construyen sistemas adaptativos.

Histéresis y decaimiento

Para la reputación del patrón (seguimiento de si una firma es bot o humana a lo largo del tiempo), se aplican los mismos principios, pero con sofisticación adicional:

// 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;
}

Transiciones estatales con histéresis

Los patrones no pasan directamente de Neutral a ConfirmedBad. Hay histéresis para evitar aleteos:

// 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 ...
}

Nótese la asimetría: es más fácil bloquearse que desbloquearse. ConfirmedBad → Suspect necesita 100 apoyo, mientras que Neutral → Suspect Sólo necesita 10. Esto es intencional - es más difícil de perdonar que sospechar.

La decadencia del tiempo - el olvido exponencial

Cuando los patrones se callan, decaen hacia neutrales:

// 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
    };
}

Las constantes de tiempo predeterminadas:

  • Caída de la puntuación: 168 horas (7 días) - las puntuaciones mueven 63% hacia neutral después de una semana de inactividad
  • Decaimiento del soporte: 336 horas (14 días) - la confianza disminuye 63% después de dos semanas

Esto significa que una IP confirmada-mala que se queda en silencio durante un mes finalmente caerá de nuevo a Neutral. No porque se reformó - porque su evidencia se volvió rancio.

El Servicio de Mantenimiento de Antecedentes

Los ReputationMaintenanceService lleva a cabo tres tareas periódicas:

// 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);
}

El recolector de basura elimina patrones que son:

  • Más de 90 días
  • Soporte ≤ 1,0
  • En estado neutral

Esto impide que el diccionario in-memory crezca sin límites, preservando al mismo tiempo valiosos patrones aprendidos.

SQLite no es una broma - Es perfecto aquí

Muchos desarrolladores llegan reflexivamente a PostgreSQL o Redis. Pero para este patrón, SQLite es ideal:

  1. Detrás de la escritura elimina los cuellos de botella - La limitación de un solo escritor de SQLite no importa cuando se escribe por lotes
  2. Almacenamiento local - sin latencia de la red, sin piscinas de conexión
  3. Configuración cero - sólo una ruta de archivo
  4. Perfecto para el despliegue de borde - corre en un Raspberry Pi
  5. Portátil - la base de datos es sólo un archivo que se puede copiar alrededor

El esquema es mínimo:

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);

Si necesita una escala más grande, cambie a PostgreSQL. Si necesita HA o replicación, cambie a Redis o una caché distribuida. La arquitectura no cambia - sólo la cadena de conexión. SQLite es el valor predeterminado para la implementación de borde, no una religión.

El vínculo DiSE - El fracaso como evolución

Si DiSE es el motor evolutivo completo, este patrón de caché es la mitocondria - la pieza más pequeña que todavía se comporta como la evolución bajo restricción.

Este patrón implementa los principios de DiSE en el nivel más mínimo:

  • Limitación de recursos → Presión de selección
  • Desahucio de la LRU → Selección natural (los sobrevivientes son los más aptos)
  • Decaimiento del tiempo → Olvidar permite la adaptación
  • Actualizaciones de la EMA → Mutación a través de la observación
  • Histéresis → Estabilidad a través de la resistencia al cambio

Un caché completo no es un fracaso. presión evolutiva. El sistema se auto-optimiza: las firmas calientes permanecen, las firmas frías desalojan, y la memoria del comportamiento converge hacia lo que realmente importa.

No hay entrenamiento de ML, no hay modelos externos, sólo arquitectura que se comporta como un sistema vivo.

Conclusión - Estructuras simples, comportamiento emergente

Todo el patrón se reduce a:

  1. Cache es la fuente de la verdad para leer - acceso a submilisegundos
  2. Escribe la caché de actualización inmediatamente, persistir más tarde - sin bloqueo de E/S
  3. Expiración deslizante proporciona LRU automático - incorporado en .NET
  4. El tamaño limitado crea presión de selección - El desbordamiento agudiza el foco
  5. El color de fondo mantiene SQLite sincronizado - la consistencia final está bien
  6. La decadencia del tiempo permite olvidar - desaparece la evidencia rancio

Su pequeña tienda de comportamiento actúa más como un sistema vivo que CRUD. Recuerda lo que importa, olvida lo que no, y mejora bajo presión.

Arquitectura mínima → corrección emergente. Desbordamiento → mejor enfoque. Presión → estabilidad.

Si quieres ver esto en acción, echa un vistazo mayormente lucid.botdetection - y el Serie de arquitectura DiSE para la filosofía más profunda.

Vínculos

Finding related posts...
logo

© 2026 Scott Galloway — Unlicense — All content and source code on this site is free to use, copy, modify, and sell.