
В интернете есть книги и множество статей, таких как эта, в которых авторы приводят аргументы в пользу использования Postgres для всего. Я решил рассмотреть один из вариантов использования — применение Postgres вместо Redis для кэширования. Я довольно часто работаю с API, поэтому я создал очень простой HTTP-сервер, который отвечает данными из этого кэша. Я начал с Redis, так как часто сталкиваюсь с этим на работе, а затем переключился на Postgres с использованием нежурналируемых таблиц и посмотрел, есть ли разница.
Настройка
Я проведу эксперимент на своём кластере k8s домашней лаборатории. Идея состоит в том, чтобы запустить Postgres или Redis на одном узле, ограничив его двумя процессорами с помощью ограничений k8s, а также 8 ГБ памяти. На другом узле я запущу сам веб-сервер, а затем создам модуль для бенчмарка, который будет выполняться через k6 на третьем узле.
И postgres, и redis используются с готовыми настройками для следующих образов:
Postgres -
postgres:17.6Редис -
redis:8.2
Я написал простой веб-сервер с двумя конечными точками, кэшем и структурой «Session», которую мы будем хранить в кэше:
Код
var ErrCacheMiss = errors.New("cache miss")
type Cache interface {
Get(ctx context.Context, key string) (string, error)
Set(ctx context.Context, key string, value string) error
}
type Session struct {
ID string
}
func serveHTTP(c Cache) {
http.HandleFunc("/get", getHandler(c))
http.HandleFunc("/set", setHandler(c))
port := os.Getenv("PORT")
if port == "" {
port = "8080"
}
fmt.Println("Server starting on http://0.0.0.0:" + port)
server := &http.Server{Addr: "0.0.0.0:" + port}
go func() {
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
fmt.Println("Error starting server:", err)
}
}()
quit := make(chan os.Signal, 1)
signal.Notify(quit, os.Interrupt)
<-quit
fmt.Println("Shutting down server...")
if err := server.Close(); err != nil {
fmt.Println("Error shutting down server:", err)
}
}Для Redis я реализовал кэш с помощью github.com/redis/go-redis/v9 следующим образом:
Код
type RedisCache struct {
client *redis.Client
}
func NewRedisCache() *RedisCache {
redisURL := os.Getenv("REDIS_URL")
if redisURL == "" {
redisURL = "localhost:6379"
}
fmt.Println("Connecting to Redis at", redisURL)
client := redis.NewClient(&redis.Options{
Addr: redisURL,
Password: "",
DB: 0,
})
return &RedisCache{
client: client,
}
}
func (r *RedisCache) Get(ctx context.Context, key string) (string, error) {
val, err := r.client.Get(ctx, key).Result()
if err == redis.Nil {
return "", ErrCacheMiss
}
if err != nil {
return "", err
}
return val, nil
}
func (r *RedisCache) Set(ctx context.Context, key string, value string) error {
return r.client.Set(ctx, key, value, 0).Err()
}Кэш Postgres реализован с использованием библиотеки github.com/jackc/pgx/v5:
Код
type PostgresCache struct {
db *pgxpool.Pool
}
func NewPostgresCache() (*PostgresCache, error) {
pgDSN := os.Getenv("POSTGRES_DSN")
if pgDSN == "" {
pgDSN = "postgres://user:password@localhost:5432/mydb"
}
cfg, err := pgxpool.ParseConfig(pgDSN)
if err != nil {
return nil, err
}
cfg.MaxConns = 50
cfg.MinConns = 10
pool, err := pgxpool.NewWithConfig(context.Background(), cfg)
if err != nil {
return nil, err
}
_, err = pool.Exec(context.Background(), `
CREATE UNLOGGED TABLE IF NOT EXISTS cache (
key VARCHAR(255) PRIMARY KEY,
value TEXT
);
`)
if err != nil {
return nil, err
}
return &PostgresCache{
db: pool,
}, nil
}
func (p *PostgresCache) Get(ctx context.Context, key string) (string, error) {
var content string
err := p.db.QueryRow(ctx, `SELECT value FROM cache WHERE key = $1`, key).Scan(&content)
if err == pgx.ErrNoRows {
return "", ErrCacheMiss
}
if err != nil {
return "", err
}
return content, nil
}
func (p *PostgresCache) Set(ctx context.Context, key string, value string) error {
_, err := p.db.Exec(ctx, `INSERT INTO cache (key, value) VALUES ($1, $2) ON CONFLICT (key) DO UPDATE SET value = $2`, key, value)
return err
}Я заполню Redis и Postgres 30 миллионами записей в каждой, сохраняя записи о вставленных uuids. Затем я сгенерирую подмножество существующих uuids для использования при тестировании. Это позволит имитировать как попадания, так и промахи.
Сначала я проведу несколько тестов для получения данных, затем для их установки, а потом смешанный тест. Каждый тест будет выполняться в течение 2 минут. Я буду следить за количеством операций в секунду, задержками, а также за использованием памяти и процессора в это время.
Чтобы смоделировать более реалистичный сценарий, при котором в кэше существует только подмножество ключей, в тесте на установку вероятность обновления существующего ключа составит 10 %, а в тесте на получение — 80 %. В смешанной рабочей нагрузке вероятность выполнения теста на установку составит 20 %, а теста на получение — 80 %.
Результаты
Получение значений из кэша

Redis показал себя лучше, чем Postgres, что меня совсем не удивило. Узким местом на самом деле был HTTP-сервер. Машина, на которой работал HTTP-сервер, была перегружена по процессору, в то время как Redis комфортно работал с ~1280 mCPU, что меньше установленного лимита в 2000 mCPU. Redis использовал ~3800 МБ оперативной памяти, и этот показатель оставался неизменным во всех запусках.
Для Postgres узким местом был процессор на стороне Postgres. Он постоянно загружал два выделенных ему ядра, а также использовал около 5000 МБ оперативной памяти.
Redis также показал лучшие результаты в плане задержки HTTP-ответов:

Установка значений в кэше

Redis снова показал лучшие результаты. Загрузка процессора осталась примерно на том же уровне, что и в случае с GET-запросом, а использование оперативной памяти выросло до ~4300 МБ из-за добавления новых ключей. Узким местом по-прежнему оставался HTTP-сервер, а Redis снова использовал ~1280 mCPU.
Postgres снова столкнулся с проблемой нехватки ресурсов процессора, постоянно используя 100 % из 2 доступных ядер. В ходе выполнения теста использование памяти выросло до ~5500 МБ.
Тест на задержку:

Скорость чтения/записи

Смешанный бенчмарк также показал предсказуемый результат: Redis лидирует. Как и в предыдущих тестах, загрузка ЦП составила ~1280 mCPU, а использование оперативной памяти немного выросло из-за добавления новых ключей.
Postgres задействовал оба ядра и использовал около 6 ГБ памяти.
При использовании Redis задержки снова сократились:

Нежурналируемые таблицы
В тесте я использовал такую таблицу для Postgres, но это, похоже, не помогло, или всё-таки помогло? Если я повторю тот же тест с обычной таблицей, мы сможем посмотреть на результаты.

Как видим есть влияние на результаты теста записи и в меньшей степени, но всё же влияет на результаты теста смешанной нагрузки. Это связано с тем, что в таблицах без журнала транзакций не используется журнал упреждающей записи, что значительно ускоряет запись. Однако разница в производительности при чтении незначительна, и я ожидаю, что при большем количестве запусков результаты двух тестов совпадут.
Заключение - выбираю PostgreSQL
Когда дело доходит до кэширования, Redis работает быстрее postgres, в этом нет никаких сомнений.
К тому же у него есть множество других полезных функций, которые можно было бы ожидать от кэша, таких как TTL.
Узким местом при тестирование были используемое аппаратное обеспечение, сам сервис. Если их улучшить тестирование, безусловно, могло бы показать лучшие показатели.
Тогда, конечно, мы все должны использовать Redis для наших нужд кэширования, не так ли? Что ж, я думаю, что все равно буду использовать postgres. И вот почему:
а) Почти всегда моим проектам нужна база данных
б) Отсутствие необходимости добавлять ещё одну зависимость имеет свои преимущества
в) Если мне нужно, чтобы срок действия моих ключей истёк, я добавлю для этого столбец и задание cron для удаления этих ключей из таблицы.
г) Что касается скорости, то 7425 запросов в секунду — это всё равно много. Это более полумиллиарда запросов в день. И всё это на оборудовании, которому 10 лет, с процессорами для ноутбуков. Немногие проекты достигают такого масштаба, а если и достигают, то я могу просто обновить экземпляр Postgres или, если потребуется, запустить Redis.
д) Если нужно будет сменить используемую СУБД благодаря наличию интерфейса для кэша я легко смогу это сделать.
Спасибо за чтение!
Подробней про нежурналируемые таблицы.
Мой блог про System Design, Архитектурные каты, паттерны - @System_Design_World
Комментарии (19)

Dhwtj
27.09.2025 08:18Можно и в приложении кешировать 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 через токены

Dhwtj
27.09.2025 08:18Вот с редис
// 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.

Pusk1
27.09.2025 08:18Существует мнение, что для 90+% проектов можно обойтись без дополнительного кэширования. Postgre выдаёт десятки тысяч RPS на далеко не топовом железе, а средний слой часто намного менее прожорлив и легко горизонтально масштабируется. Неприменимо, если у вас 5 микросервисов выстроенных в ряд друг за другом.

Allirey
27.09.2025 08:18как раз вот postgres 18 вышел, с какими-то серьёзными улучшениями, так что нужны новые тесты.

Politura
27.09.2025 08:18А можно тот-же самый тест, но не на смешных 30млн записей в кэше, а хотя-бы на порядок увеличить? И еще, почему БД занят только обслуживанием кэша? Не порядок! Давайте параллельно в разные таблички еще писать/читать, всякие джойны делать по табличкам и тд. Ведь автор не хочет лишних зависимостей, хочет все в одной базе. Я даже согласен чтоб реплика на чтение была на отдельном серваке, все равно результаты простгреса будут смешные при таком сценарии.
Для пет проекта можно что угодно делать, хоть в файлах все держать вместо СУБД. Для прода, с хорошей нагрузкой, надо все-таки думать как нагрузку снизить, иначе на операционных расходах можно деньги потратить с трудом заработанные.

Politura
27.09.2025 08:18Только сейчас заметил, что в варианте Постгреса нет удаления. Видать с удалением результаты совсем проседают. Пишет, что если понадобится, будет удалять по крону... Как оно может НЕ понадобиться? Инвалидация кэша? Всегда растущая табличка? У каждого ключа вечно одно и то-же значение? Инвалидация кэша по крону? :)

Mausglov
27.09.2025 08:18Смешных? На что вы их тратите, хотелось бы знать...

Politura
27.09.2025 08:18Много активных пользователей, много серверов и много бизнеслогики. Тяжелые функции бизнес-логики народ любит кэшировать. При этом кэшируемые данные привязываются к какому-то объекту, при изменении которого данные в кэше должны сбрасываться.

xadd
27.09.2025 08:18Такой себе бенчмарк. Без prepare и с такими простыми запросами, там значительный ресурс cpu должен уходить на парсинг sql.

melon
27.09.2025 08:18Так Redis же однопоточный, поэтому и кушал только одно ядро в итоге. Попробуй valkey если хочешь утилизировать второе ядро и тогда отрыв будет ещё больше.

SabMakc
27.09.2025 08:18А потом оказывается, что для небольших (а то и для средних) приложений достаточно и SQLite...

t0rr
27.09.2025 08:18Первое, что бросилось в глаза - не учитывается нагрузка на постгрю в роли бд. Или под кэш отдельная СУБД будет?)
Редис тем и хорош, что сопровождение его вообще не требуется - у меня есть инстансы, которые за 5 лет перезагружались только вместе с хостом. А на крайний случай, если что-то пошло не так - убил его нафиг и поднял новый.

seekerhan
27.09.2025 08:18Postgresql Просто для своей работы требует много ресурсов. Там же постоянно идут процессы сохранений на диск кеша базы в оперативной памяти. Вакуумы, аналайзы, другие внутренние процессы с перестроением индексов. Чтобы сравнение было честным. Нужно поднимать отдельную СУБД только с unlogged таблицами. И основную на другом сервере. Думаю, что при определенных условиях БД себя покажет лучше чем в этом тесте. Наверное, тут если не хватает производительности - нужно использовать redis, она сильно проще масштабируется. С кеш есть другие проблемы, связанные с целостностью данных, которые придётся решать дополнительно.
daniil_kulikov
Не понял пункта про зависимости. В проектах число зависимостей зачастую как у соседа в Мурино. Неужели наличие ещё одной критичнее, чем прожорливость и скорость?
А вообще, было бы любопытно глянуть на перфоманс кэшей без упора в железо. Чисто ради интереса
noavarice
Про зависимости - проще администрировать один постгрес, чем постгрес и редис (настраивать, следить за обновлениями и уязвимостями и т.д.). Редис и постгрес более похожи, чем скажем постгрес и рэббит - можно очередь через БД реализовать и отказаться от рэббита, но это намного сложнее, чем отказаться от редиса и реализовать его функции в постгресе
Politura
И сколько времени в год у вас тратится на администрирование редиса? Ну чтоб понимать, насколько именно проще без него. :)
avovana7 Автор
Даниил, я бы раскрыл со стороны технологического ландшафта. Если у нас есть PostgreSQL и мы умеем её готовить, то доп сущность/СУБД привносит дополнительные издержки на поддержку/нужду в наработке экспертизы.
Автор исходил из фокуса использования кэша для условно небольших проектов. Где PostgreSQL вполне может хватить. Тем более, если он уже есть.
Про перформанс кэшэй без упора в железо - имеется ввиду как-то абстрагироваться от тестовых стендов?