Comments 26
Не понял пункта про зависимости. В проектах число зависимостей зачастую как у соседа в Мурино. Неужели наличие ещё одной критичнее, чем прожорливость и скорость?
А вообще, было бы любопытно глянуть на перфоманс кэшей без упора в железо. Чисто ради интереса
Про зависимости - проще администрировать один постгрес, чем постгрес и редис (настраивать, следить за обновлениями и уязвимостями и т.д.). Редис и постгрес более похожи, чем скажем постгрес и рэббит - можно очередь через БД реализовать и отказаться от рэббита, но это намного сложнее, чем отказаться от редиса и реализовать его функции в постгресе
Даниил, я бы раскрыл со стороны технологического ландшафта. Если у нас есть PostgreSQL и мы умеем её готовить, то доп сущность/СУБД привносит дополнительные издержки на поддержку/нужду в наработке экспертизы.
Автор исходил из фокуса использования кэша для условно небольших проектов. Где PostgreSQL вполне может хватить. Тем более, если он уже есть.
Про перформанс кэшэй без упора в железо - имеется ввиду как-то абстрагироваться от тестовых стендов?
Можно и в приложении кешировать L1 write back cache, то есть на каждый инстанс свой. Пишешь в потокобезопасный вариант хешмапы и отгребаешь в БД его изменения батчами, что быстрее чем по одному. И быстрее чем редис: никаких сетевых запросов.
Минус: при рестарте сервиса кеш сбрасывается. Больше памяти, потому что на каждый инстанс свой кеш.
Второй минус при падении сервиса потеря данных что не успели записаться, нужно мягкое гашение сервиса. Если критично, то можно писать свой WAL fallback: критичные данные дублировать в append-only лог перед батчингом. Тоже надёжно и очень быстро.
Но если жесткое IO и годный балансер чтобы не утопить свеже стартовавший (или вообще один инстанс) то штука хорошая
Для балансера:
- Readiness probe с задержкой после старта
- Slow start в nginx/envoy — постепенно увеличивать трафик
- Или прогрев кеша перед переключением в ready
В C# удобно через IHostedService + Channel для батчинга. В Rust — tokio::sync::mpsc + graceful shutdown через токены
Вот с редис
// Domain models
public record User(int Id, string Name, DateTime CreatedAt);
public record Product(int Id, string Name, decimal Price);
public record Order(int Id, int UserId, DateTime OrderDate, decimal Total);
// Base generic repository
public interface IRepository<T> where T : class
{
Task<T?> GetById(int id);
Task<T> Create(T entity);
Task Update(T entity);
Task Delete(int id);
}
// Generic SQL implementation
public class SqlRepository<T> : IRepository<T> where T : class
{
private readonly IDbConnection _db;
private readonly string _tableName;
public SqlRepository(IDbConnection db)
{
_db = db;
_tableName = typeof(T).Name + "s"; // User -> Users
}
public async Task<T?> GetById(int id) =>
await _db.QuerySingleOrDefaultAsync<T>(
$"SELECT * FROM {_tableName} WHERE Id = @id",
new { id });
public async Task<T> Create(T entity) =>
throw new NotImplementedException(); // специфично для каждой таблицы
public async Task Update(T entity) =>
throw new NotImplementedException(); // специфично для каждой таблицы
public async Task Delete(int id) =>
await _db.ExecuteAsync($"DELETE FROM {_tableName} WHERE Id = @id", new { id });
}
// Universal cache decorator
public class CachedRepository<T> : IRepository<T> where T : class
{
private readonly IRepository<T> _inner;
private readonly IDatabase _cache;
private readonly string _keyPrefix;
public CachedRepository(IRepository<T> inner, IConnectionMultiplexer redis)
{
_inner = inner;
_cache = redis.GetDatabase();
_keyPrefix = typeof(T).Name.ToLower();
}
public async Task<T?> GetById(int id)
{
var key = $"{_keyPrefix}:{id}";
var cached = await _cache.StringGetAsync(key);
if (cached.HasValue)
return JsonSerializer.Deserialize<T>(cached!);
var entity = await _inner.GetById(id);
if (entity != null)
await _cache.StringSetAsync(key, JsonSerializer.Serialize(entity), TimeSpan.FromMinutes(5));
return entity;
}
public async Task<T> Create(T entity)
{
entity = await _inner.Create(entity);
await InvalidateCache(GetId(entity));
return entity;
}
public async Task Update(T entity)
{
await _inner.Update(entity);
await InvalidateCache(GetId(entity));
}
public async Task Delete(int id)
{
await _inner.Delete(id);
await InvalidateCache(id);
}
private async Task InvalidateCache(int id) =>
await _cache.KeyDeleteAsync($"{_keyPrefix}:{id}");
private int GetId(T entity) =>
(int)entity.GetType().GetProperty("Id")!.GetValue(entity)!;
}
// Specific repositories (только для специфичной логики)
public interface IUserRepository : IRepository<User>
{
Task<User?> GetByName(string name);
}
public class SqlUserRepository : SqlRepository<User>, IUserRepository
{
public SqlUserRepository(IDbConnection db) : base(db) { }
public async Task<User?> GetByName(string name) =>
await _db.QuerySingleOrDefaultAsync<User>(
"SELECT * FROM Users WHERE Name = @name",
new { name });
}
// Кеширующий декоратор для User с дополнительным методом
public class CachedUserRepository : CachedRepository<User>, IUserRepository
{
private readonly IUserRepository _inner;
private readonly IDatabase _cache;
public CachedUserRepository(IUserRepository inner, IConnectionMultiplexer redis)
: base(inner, redis)
{
_inner = inner;
_cache = redis.GetDatabase();
}
public async Task<User?> GetByName(string name)
{
var key = $"user:name:{name}";
var cached = await _cache.StringGetAsync(key);
if (cached.HasValue)
return JsonSerializer.Deserialize<User>(cached!);
var user = await _inner.GetByName(name);
if (user != null)
await _cache.StringSetAsync(key, JsonSerializer.Serialize(user), TimeSpan.FromMinutes(5));
return user;
}
}
// DI Setup - автоматическая регистрация
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddSingleton<IConnectionMultiplexer>(
ConnectionMultiplexer.Connect("localhost:6379"));
builder.Services.AddScoped<IDbConnection>(_ =>
new NpgsqlConnection("Host=localhost;Database=mydb"));
// Автоматическая регистрация всех репозиториев с кешированием
builder.Services.Scan(scan => scan
.FromAssemblyOf<SqlUserRepository>()
.AddClasses(classes => classes.AssignableTo(typeof(IRepository<>)))
.AsImplementedInterfaces()
.WithScopedLifetime()
.Decorate(typeof(IRepository<>), typeof(CachedRepository<>)));
// Специфичные репозитории
builder.Services.AddScoped<SqlUserRepository>();
builder.Services.AddScoped<IUserRepository>(sp =>
new CachedUserRepository(
sp.GetRequiredService<SqlUserRepository>(),
sp.GetRequiredService<IConnectionMultiplexer>()));
var app = builder.Build();
// Endpoints
app.MapGet("/user/{id:int}", async (int id, IRepository<User> repo) =>
await repo.GetById(id));
app.MapGet("/product/{id:int}", async (int id, IRepository<Product> repo) =>
await repo.GetById(id));
app.MapGet("/order/{id:int}", async (int id, IRepository<Order> repo) =>
await repo.GetById(id));
app.Run();
Вот со своим кешированием
// Domain models
public record User(int Id, string Name, DateTime CreatedAt);
public record Product(int Id, string Name, decimal Price);
public record Order(int Id, int UserId, DateTime OrderDate, decimal Total);
// Repository interface
public interface IRepository<T> where T : class
{
Task<T?> GetById(int id);
Task<T> Save(T entity);
Task Delete(int id);
}
// SQL backend
public class SqlRepository<T> : IRepository<T> where T : class
{
private readonly IDbConnection _db;
private readonly string _table;
public SqlRepository(IDbConnection db)
{
_db = db;
_table = typeof(T).Name + "s";
}
public async Task<T?> GetById(int id) =>
await _db.QuerySingleOrDefaultAsync<T>(
$"SELECT * FROM {_table} WHERE Id = @id", new { id });
public async Task<T> Save(T entity)
{
var id = GetId(entity);
var props = typeof(T).GetProperties()
.Where(p => p.Name != "Id")
.Select(p => $"{p.Name} = @{p.Name}");
var sql = $"UPDATE {_table} SET {string.Join(", ", props)} WHERE Id = @Id";
await _db.ExecuteAsync(sql, entity);
return entity;
}
public async Task Delete(int id) =>
await _db.ExecuteAsync($"DELETE FROM {_table} WHERE Id = @id", new { id });
private int GetId(T entity) =>
(int)entity.GetType().GetProperty("Id")!.GetValue(entity)!;
}
// Cached decorator
public class CachedRepository<T> : IRepository<T> where T : class
{
private readonly IRepository<T> _backend;
private readonly IMemoryCache _cache;
private readonly string _keyPrefix;
private readonly TimeSpan _expiration = TimeSpan.FromMinutes(5);
public CachedRepository(IRepository<T> backend, IMemoryCache cache)
{
_backend = backend;
_cache = cache;
_keyPrefix = typeof(T).Name.ToLower();
}
public async Task<T?> GetById(int id)
{
var key = $"{_keyPrefix}:{id}";
return await _cache.GetOrCreateAsync(key, async entry =>
{
entry.AbsoluteExpirationRelativeToNow = _expiration;
return await _backend.GetById(id);
});
}
public async Task<T> Save(T entity)
{
entity = await _backend.Save(entity);
var id = GetId(entity);
var key = $"{_keyPrefix}:{id}";
_cache.Set(key, entity, _expiration);
return entity;
}
public async Task Delete(int id)
{
await _backend.Delete(id);
var key = $"{_keyPrefix}:{id}";
_cache.Remove(key);
}
private int GetId(T entity) =>
(int)entity.GetType().GetProperty("Id")!.GetValue(entity)!;
}
// Program.cs
var builder = WebApplication.CreateBuilder(args);
// Memory cache
builder.Services.AddMemoryCache(options =>
{
options.SizeLimit = 10000; // Max 10k entries
});
// DB connection
builder.Services.AddScoped<IDbConnection>(_ =>
new NpgsqlConnection(builder.Configuration.GetConnectionString("Default")));
// Repositories with caching
builder.Services.AddScoped<SqlRepository<User>>();
builder.Services.AddScoped<IRepository<User>>(sp =>
new CachedRepository<User>(
sp.GetRequiredService<SqlRepository<User>>(),
sp.GetRequiredService<IMemoryCache>()));
builder.Services.AddScoped<SqlRepository<Product>>();
builder.Services.AddScoped<IRepository<Product>>(sp =>
new CachedRepository<Product>(
sp.GetRequiredService<SqlRepository<Product>>(),
sp.GetRequiredService<IMemoryCache>()));
builder.Services.AddScoped<SqlRepository<Order>>();
builder.Services.AddScoped<IRepository<Order>>(sp =>
new CachedRepository<Order>(
sp.GetRequiredService<SqlRepository<Order>>(),
sp.GetRequiredService<IMemoryCache>()));
var app = builder.Build();
// Endpoints
app.MapGet("/users/{id:int}", async (int id, IRepository<User> repo) =>
await repo.GetById(id) is { } user ? Results.Ok(user) : Results.NotFound());
app.MapPut("/users/{id:int}", async (int id, User user, IRepository<User> repo) =>
{
if (id != user.Id) return Results.BadRequest();
return Results.Ok(await repo.Save(user));
});
app.MapDelete("/users/{id:int}", async (int id, IRepository<User> repo) =>
{
await repo.Delete(id);
return Results.NoContent();
});
// Batch endpoint
app.MapPost("/users/batch", async (List<User> users, IRepository<User> repo) =>
{
var tasks = users.Select(u => repo.Save(u));
return Results.Ok(await Task.WhenAll(tasks));
});
app.MapGet("/products/{id:int}", async (int id, IRepository<Product> repo) =>
await repo.GetById(id));
app.MapGet("/orders/{id:int}", async (int id, IRepository<Order> repo) =>
await repo.GetById(id));
app.Run();
Намного проще и быстрее чем с Redis:
IMemoryCache:
- Встроен в ASP.NET, не нужен отдельный сервер
- Нет сериализации JSON
- Нет сетевых задержек
- GetOrCreateAsync() — атомарная операция из коробки
Redis нужен когда:
- Несколько серверов API (shared cache)
- Переживание рестартов
- Pub/Sub для инвалидации
- Больше данных чем RAM одного сервера
Для одного сервера IMemoryCache оптимальнее практически всегда.
IMemoryCache: ~34 строки, работает сразу
Redis: ~50 строк + Docker + конфиг + сериализация, дополнительные знания о продукте
Что сломается без знаний:
Забыл TTL → Redis съест всю память
Неверная eviction policy → удалятся нужные ключи
Нет persistence → потеря кеша при рестарте
Connection pool → TimeoutException под нагрузкой
IMemoryCache — включил и работает. Redis — нужно хотя бы пару статей прочитать про eviction и memory management.
Существует мнение, что для 90+% проектов можно обойтись без дополнительного кэширования. Postgre выдаёт десятки тысяч RPS на далеко не топовом железе, а средний слой часто намного менее прожорлив и легко горизонтально масштабируется. Неприменимо, если у вас 5 микросервисов выстроенных в ряд друг за другом.
как раз вот postgres 18 вышел, с какими-то серьёзными улучшениями, так что нужны новые тесты.
Автор ТОЧНО понимает смысл кеширования? :)
Чел у которого проекты не требуют кэша отказался от кэша.
А можно тот-же самый тест, но не на смешных 30млн записей в кэше, а хотя-бы на порядок увеличить? И еще, почему БД занят только обслуживанием кэша? Не порядок! Давайте параллельно в разные таблички еще писать/читать, всякие джойны делать по табличкам и тд. Ведь автор не хочет лишних зависимостей, хочет все в одной базе. Я даже согласен чтоб реплика на чтение была на отдельном серваке, все равно результаты простгреса будут смешные при таком сценарии.
Для пет проекта можно что угодно делать, хоть в файлах все держать вместо СУБД. Для прода, с хорошей нагрузкой, надо все-таки думать как нагрузку снизить, иначе на операционных расходах можно деньги потратить с трудом заработанные.
Только сейчас заметил, что в варианте Постгреса нет удаления. Видать с удалением результаты совсем проседают. Пишет, что если понадобится, будет удалять по крону... Как оно может НЕ понадобиться? Инвалидация кэша? Всегда растущая табличка? У каждого ключа вечно одно и то-же значение? Инвалидация кэша по крону? :)
Смешных? На что вы их тратите, хотелось бы знать...
Такой себе бенчмарк. Без prepare и с такими простыми запросами, там значительный ресурс cpu должен уходить на парсинг sql.
Так Redis же однопоточный, поэтому и кушал только одно ядро в итоге. Попробуй valkey если хочешь утилизировать второе ядро и тогда отрыв будет ещё больше.
Не совсем, свежие версии таки выносят вспомогательные задачи в отдельные потоки. Но тут максимально простой случай где и кэш скорее всего в принципе не нужен.
А потом оказывается, что для небольших (а то и для средних) приложений достаточно и SQLite...
Первое, что бросилось в глаза - не учитывается нагрузка на постгрю в роли бд. Или под кэш отдельная СУБД будет?)
Редис тем и хорош, что сопровождение его вообще не требуется - у меня есть инстансы, которые за 5 лет перезагружались только вместе с хостом. А на крайний случай, если что-то пошло не так - убил его нафиг и поднял новый.
Postgresql Просто для своей работы требует много ресурсов. Там же постоянно идут процессы сохранений на диск кеша базы в оперативной памяти. Вакуумы, аналайзы, другие внутренние процессы с перестроением индексов. Чтобы сравнение было честным. Нужно поднимать отдельную СУБД только с unlogged таблицами. И основную на другом сервере. Думаю, что при определенных условиях БД себя покажет лучше чем в этом тесте. Наверное, тут если не хватает производительности - нужно использовать redis, она сильно проще масштабируется. С кеш есть другие проблемы, связанные с целостностью данных, которые придётся решать дополнительно.
А про автовакуум думали? Он может просто не успевать удалять записи, нужно партиционирование. Один фиг возиться придется
что за бред все пихать в кубер?? оверхед на сеть не пугает, и да, понятно, что при таких цифрах проблема в генераторе, бенчмарк редиса тебе 99к покажет по запросам, не в домашнем кубере, конечно
Redis работает быстро — я буду кэшировать данные в Postgres