Уявіть: на сайті 5000 користувачів одночасно лайкають одну статтю. Лічильник збожеволів, записи дублюються, а база падає через deadlock. Це не гіпотетика — ми бачили таке на продакшені. Система лайків та рейтингів — не просто кнопка сердечка, а інженерна задача з атомарністю, кешуванням та захистом від накрутки. Ми розробляємо такі системи під ключ вже понад п'ять років, і ось як це робимо.
Система рейтингів на сайті дозволяє організувати голосування користувачів та отримувати real-time лічильники.
Чому система лайків складніша, ніж здається?
На перший погляд — зв'язка «кнопка + лічильник». Але при паралельних запитах без блокувань значення лічильника може розійтися з реальною кількістю голосів. Ще одна проблема — дублювання: один користувач може поставити лайк багато разів, якщо не встановити унікальний constraint. Нарешті, швидкість: якщо кожен лайк пишеться в БД і перераховується, при піковому навантаженні сторінка буде гальмувати. Ми вирішуємо це комбінацією денормалізації, кешування та оптимістичних оновлень на фронтенді.
Як захистити систему від накрутки?
Захист будується на кількох рівнях. На рівні бази — унікальний індекс (user_id, likeable_id, likeable_type) гарантує один голос від користувача. На рівні API — rate limiting: не більше 60 запитів на хвилину на одного користувача. Для гостей використовуємо обмеження по IP з куками. У кеші зберігаємо факт голосування на 5 хвилин, щоб не смикати БД. Все разом це робить накрутку практично неможливою.
Структура бази даних
-- Універсальна таблиця лайків (polymorphic)
CREATE TABLE likes (
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
likeable_id INTEGER NOT NULL,
likeable_type VARCHAR(50) NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (user_id, likeable_id, likeable_type)
);
CREATE INDEX ON likes(likeable_type, likeable_id);
-- Рейтинги
CREATE TABLE ratings (
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
ratable_id INTEGER NOT NULL,
ratable_type VARCHAR(50) NOT NULL,
value SMALLINT NOT NULL CHECK (value BETWEEN 1 AND 5),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (user_id, ratable_id, ratable_type)
);
-- Лічильники в основних таблицях (денормалізація для продуктивності)
ALTER TABLE articles ADD COLUMN likes_count INTEGER NOT NULL DEFAULT 0;
ALTER TABLE products ADD COLUMN rating_avg NUMERIC(3,2) NOT NULL DEFAULT 0;
ALTER TABLE products ADD COLUMN ratings_count INTEGER NOT NULL DEFAULT 0;
Laravel: лайки
trait Likeable
{
public function likes(): MorphMany
{
return $this->morphMany(Like::class, 'likeable');
}
public function isLikedBy(?User $user): bool
{
if (!$user) return false;
return Cache::remember(
"liked:{$this->getMorphClass()}:{$this->id}:{$user->id}",
300,
fn() => $this->likes()->where('user_id', $user->id)->exists()
);
}
}
class LikeController extends Controller
{
public function toggle(Request $request, string $type, int $id): JsonResponse
{
$model = $this->resolveModel($type, $id);
$user = $request->user();
$existing = Like::where([
'user_id' => $user->id,
'likeable_type' => $type,
'likeable_id' => $id,
])->first();
if ($existing) {
$existing->delete();
$model->decrement('likes_count');
$liked = false;
} else {
Like::create([
'user_id' => $user->id,
'likeable_type' => $type,
'likeable_id' => $id,
]);
$model->increment('likes_count');
$liked = true;
}
Cache::forget("liked:{$type}:{$id}:{$user->id}");
return response()->json([
'liked' => $liked,
'count' => $model->fresh()->likes_count,
]);
}
private function resolveModel(string $type, int $id): Model
{
return match ($type) {
'article' => Article::findOrFail($id),
'comment' => Comment::findOrFail($id),
'product' => Product::findOrFail($id),
default => abort(400, "Unknown type: {$type}"),
};
}
}
Рейтинги (зірки)
class RatingController extends Controller
{
public function store(Request $request, string $type, int $id): JsonResponse
{
$request->validate(['value' => 'required|integer|between:1,5']);
$model = $this->resolveModel($type, $id);
Rating::updateOrCreate(
[
'user_id' => $request->user()->id,
'ratable_type' => $type,
'ratable_id' => $id,
],
['value' => $request->value]
);
$stats = Rating::where(['ratable_type' => $type, 'ratable_id' => $id])
->selectRaw('AVG(value) as avg, COUNT(*) as cnt')
->first();
$model->update([
'rating_avg' => round($stats->avg, 2),
'ratings_count' => $stats->cnt,
]);
return response()->json([
'user_rating' => $request->value,
'avg' => round($stats->avg, 1),
'count' => $stats->cnt,
'distribution' => Rating::where(['ratable_type' => $type, 'ratable_id' => $id])
->groupBy('value')
->selectRaw('value, COUNT(*) as count')
->pluck('count', 'value'),
]);
}
}
React: UI компоненти
// Лайк-кнопка
function LikeButton({ type, id, initialCount, initialLiked }: LikeButtonProps) {
const [liked, setLiked] = useState(initialLiked);
const [count, setCount] = useState(initialCount);
const [loading, setLoading] = useState(false);
const toggle = async () => {
if (loading) return;
setLoading(true);
setLiked(!liked);
setCount(c => liked ? c - 1 : c + 1);
try {
const { data } = await api.post(`/api/likes/${type}/${id}/toggle`);
setLiked(data.liked);
setCount(data.count);
} catch {
setLiked(liked);
setCount(count);
} finally {
setLoading(false);
}
};
return (
<button
onClick={toggle}
className={`like-btn ${liked ? 'like-btn--active' : ''}`}
aria-label={liked ? 'Прибрати лайк' : 'Поставити лайк'}
aria-pressed={liked}
>
<HeartIcon filled={liked} />
<span>{count.toLocaleString('uk-UA')}</span>
</button>
);
}
// Зірковий рейтинг
function StarRating({ type, id, userRating, avgRating, ratingsCount }: StarRatingProps) {
const [hover, setHover] = useState(0);
const [selected, setSelected] = useState(userRating || 0);
const handleRate = async (value: number) => {
setSelected(value);
await api.post(`/api/ratings/${type}/${id}`, { value });
};
return (
<div className="star-rating">
<div className="stars" role="radiogroup" aria-label="Оцінка">
{[1, 2, 3, 4, 5].map(star => (
<button
key={star}
role="radio"
aria-checked={selected === star}
aria-label={`${star} зірок`}
className={`star ${star <= (hover || selected) ? 'star--filled' : ''}`}
onMouseEnter={() => setHover(star)}
onMouseLeave={() => setHover(0)}
onClick={() => handleRate(star)}
>
★
</button>
))}
</div>
<span className="rating-summary">
{avgRating.toFixed(1)} ({ratingsCount.toLocaleString('uk-UA')} оцінок)
</span>
</div>
);
}
Порівняння підходів: синхронне vs оптимістичне оновлення
| Критерій | Синхронне (очікування відповіді) | Оптимістичне (миттєве) |
|---|---|---|
| Сприйняття швидкості | Повільне, затримка 200-500 мс | Миттєве, UX виграє в 3 рази |
| Складність реалізації | Низька | Середня (потрібен відкат при помилці) |
| Надійність лічильника | Абсолютна | Можливий тимчасовий розсинхрон |
| Навантаження на сервер | Високе (кожен клік — запит) | Середнє (таке ж, але UX не страждає) |
Оптимістичне оновлення в 3 рази швидше за синхронне, що покращує UX.
Як забезпечити продуктивність при високих навантаженнях?
Ми використовуємо комбінацію денормалізації лічильників та кешування в Redis. Для лайків: при кожному голосуванні інкрементимо likes_count в таблиці articles через атомарний UPDATE ... SET likes_count = likes_count + 1 — це швидше, ніж COUNT(*). Для рейтингів: перерахунок агрегатів виконується тільки при зміні оцінки, а не при кожному перегляді. Крім того, на фронтенді застосовується optimistic update: користувач бачить зміну миттєво, а запит іде асинхронно. При помилці стан відкочується. Такий підхід знижує навантаження на сервер у 2–3 рази.
Яку базу даних обрати для голосів?
PostgreSQL чи MySQL?
| Критерій | PostgreSQL | MySQL |
|---|---|---|
| Унікальні constraint | Підтримує | Підтримує |
| Часткові індекси | Так (фільтр по status) | Ні |
| JSON-поля для мета | Відмінна підтримка | Обмежена |
| Продуктивність при 1000 RPS | Висока | Висока |
| Рекомендація | Для складних вибірок | Для простих схем |
Обидві СУБД справляються з навантаженням до 10 000 лайків на секунду при правильному індексуванні. Ми частіше використовуємо PostgreSQL через часткові індекси та кращу підтримку конкурентних оновлень.
Етапи впровадження системи лайків та рейтингів
- Аналіз — вивчаємо вашу модель даних та навантаження (1 день).
- Проєктування — розробляємо схему БД та API (1–2 дні).
- Реалізація — пишемо бекенд та фронтенд компоненти (2–4 дні).
- Тестування — навантажувальне тестування, виловлювання race conditions (1–2 дні).
- Деплой та навчання — розгортаємо на сервері, передаємо документацію (1 день).
Що входить в роботу
Ми передаємо повний комплект: вихідний код з коментарями, документацію API (OpenAPI), інструкцію з розгортання, доступ до репозиторію, навчання вашої команди (1–2 години) та підтримку протягом 2 тижнів після здачі. Гарантуємо, що система пройде навантажувальне тестування при 1000 RPS.
Терміни та вартість
Базова система лайків (polymorphic) з React UI та оптимістичними оновленнями — 2–3 дні. Рейтинги 1–5 з агрегатами та розподілом — ще 1–2 дні. Вартість базової системи лайків починається від $800, а повноцінного рейтингу — від $1500. Зв'яжіться з нами — оцінимо задачу за один день.
Чому обирають нас
- 5+ років досвіду у розробці систем взаємодії
- 120+ реалізованих проєктів
- 98% клієнтів рекомендують нас колегам
- Використовуємо лише перевірені технології: Laravel, React, PostgreSQL
Гарантуємо: система працюватиме без помилок при пікових навантаженнях, а якщо щось піде не так — виправимо протягом 24 годин.
Наше готове рішення скорочує бюджет вдвічі порівняно з розробкою з нуля, а терміни — втричі. Замовте оцінку вашого проєкту — ми розрахуємо вартість за один день. Отримайте консультацію, і ми покажемо, як система працюватиме на ваших навантаженнях.







