Представьте: на сайте 5000 пользователей одновременно лайкают одну статью. Счётчик сошёл с ума, записи дублируются, а база падает под deadlock. Это не гипотетика — мы видели такое на продакшене. Система лайков и рейтингов — не просто «кнопка сердечка», а инженерная задача с атомарностью, кэшированием и защитой от накрутки. Мы разрабатываем такие системы под ключ уже более пяти лет, и вот как это делаем.
Почему система лайков сложнее, чем кажется?
На первый взгляд — связка «кнопка + счётчик». Но при параллельных запросах без блокировок значение счётчика может разойтись с реальным количеством голосов. Ещё одна проблема — дублирование: один пользователь может поставить лайк много раз, если не поставить уникальный 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('ru-RU')}</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('ru-RU')} оценок)
</span>
</div>
);
}
Сравнение подходов: синхронное vs оптимистичное обновление
| Критерий | Синхронное (ожидание ответа) | Оптимистичное (мгновенное) |
|---|---|---|
| Восприятие скорости | Медленное, задержка 200-500 мс | Мгновенное, 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 дня. Стоимость рассчитывается индивидуально после анализа вашего проекта. Свяжитесь с нами — оценим задачу за один день.
Почему выбирают нас
- Пятилетний опыт в разработке систем взаимодействия
- Более 120 реализованных проектов
- 98% клиентов рекомендуют нас коллегам
- Используем только проверенные технологии: Laravel, React, PostgreSQL
Гарантируем: система будет работать без ошибок при пиковых нагрузках, а если что-то пойдёт не так — исправим в течение 24 часов.
Наше готовое решение сокращает бюджет в два раза по сравнению с разработкой с нуля, а сроки — в три. Закажите оценку вашего проекта — мы рассчитаем стоимость за один день. Получите консультацию, и мы покажем, как система будет работать на ваших нагрузках.







