Skip to content

Instantly share code, notes, and snippets.

@v0ff4k
Created February 4, 2026 12:56
Show Gist options
  • Select an option

  • Save v0ff4k/8fd13fd8fc50ca120efac663677b51fc to your computer and use it in GitHub Desktop.

Select an option

Save v0ff4k/8fd13fd8fc50ca120efac663677b51fc to your computer and use it in GitHub Desktop.
2 like dislike suggestion

ЦЕЛЬ

Поддержка лайков/дислайков для любых сущностей (полиморфизм).
Высокая производительность записи и чтения.
Защита от дублирования (один пользователь — одно действие на сущность).
Масштабируемость под рост нагрузки.
Простота добавления новых типов сущностей.

Структура:

На примере классики в Ларке, но может быть и другая структура, так как один проект = одна структура.

    app/
    ├── Models/
    │   └── Like.php                 # Eloquent модель
    ├── Http/
    │   ├── Controllers/
    │   │   └── LikeController.php   # API эндпоинты
    │   └── Requests/
    │       └── StoreLikeRequest.php # валидация
    ├── Services/
    │   └── LikeService.php          # бизнес-логика
    database/
    └── migrations/
        └── create_likes_table.php   # полиморфная таблица
    routes/
    └── api.php                      # POST /likes, GET /likes/stats
    resources/js/                    # frontend (если SSR не нужен)

Стек php+js (на GO было бы быстрее)

Backend - PHP 8.2+ (FPM или Swoole для высокой нагрузки)
Фреймворк - Laravel, Symfony, или чистый PHP с PSR (минимализм)
База данных - PostgreSQL или MySQL 8+
Кэш / рейт-лимит - Redis
Frontend- Vanilla JS или React/Vue (только для UI)
Веб-сервер - Nginx + PHP-FPM
Деплой- Docker + Kubernetes / обычные VPS

БД Postgre

-- Полиморфная таблица "target"

CREATE TABLE likes (
    id BIGSERIAL PRIMARY KEY,
    user_id UUID NOT NULL,
    target_type VARCHAR(50) NOT NULL,  -- 'post', 'comment', 'product'
    target_id UUID NOT NULL,           -- ID сущности в её таблице
    action SMALLINT NOT NULL CHECK (action IN (1, -1)), -- 1 = like, -1 = dislike
    created_at TIMESTAMPTZ DEFAULT NOW(),
    
    UNIQUE (user_id, target_type, target_id)
);

-- Индексы
CREATE INDEX idx_likes_target ON likes (target_type, target_id);
CREATE INDEX idx_likes_user ON likes (user_id);

Или через миграцию

Schema::create('likes', function (Blueprint $table) {
    $table->id();
    $table->uuid('user_id');
    $table->morphs('likeable'); // likeable_type, likeable_id
    $table->tinyInteger('action')->comment('1 = like, -1 = dislike');
    $table->timestamps();

    $table->unique(['user_id', 'likeable_type', 'likeable_id']);
});

Ключевые "API"-эндпоинты

# голосование
POST /api/v1/likes
{
  "user_id": "usr-123",
  "target_type": "post",
  "target_id": "post-456",
  "action": 1  // или -1(dislike)
}

# Пример, без ЧПУ
GET /api/v1/likes/stats?target_type=post&target_id=post-456
→ { "likes": 1200, "dislikes": 300, "total": 900 }

Или по минимальной классике:

// routes/api.php
Route::post('/api/v1/likes', [LikeController::class, 'store']);
Route::get('/api/v1/likes/stats', [LikeController::class, 'stats']);

Контроллер

public function store(StoreLikeRequest $request)
{
    return app(LikeService::class)->toggle(
        userId: $request->user_id,
        targetType: $request->target_type,
        targetId: $request->target_id,
        action: $request->action
    );
}

Front

async function toggleLike(targetType, targetId, action) {
  const res = await fetch('/api/v1/likes', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      user_id: currentUser.id,
      csrf: @csrf
      target_type: targetType,
      target_id: targetId,
      action: action // 1 or -1
    })
  });
  const stats = await res.json();
  updateUI(stats); // обновить кнопки и счётчики
}

Логика работы (use-case)

AddLike(user_id, target_type, target_id, action)

Валидация: action ∈ {1, -1}, target_type разрешён.
Проверка дубля: через UNIQUE в БД → ON CONFLICT DO UPDATE.
Запись в БД:
INSERT INTO likes (user_id, target_type, target_id, action)
VALUES ($1, $2, $3, $4)
ON CONFLICT (user_id, target_type, target_id)
DO UPDATE SET action = EXCLUDED.action, updated_at = NOW();
Инвалидация кэша в Redis: DEL like:stats:post:post-456.
(Опционально) отправить событие в Kafka: like.created.GetStats(target_type, target_id)

Бизнес логика

class LikeService
{
    public function toggle(string $userId, string $targetType, string $targetId, int $action): array
    {
        // 1. Преобразуем тип в класс модели (например, 'post' → Post::class)
        $modelClass = $this->resolveModel($targetType);

        // 2. Используем upsert (Laravel 8+)
        Like::updateOrCreate(
            [
                'user_id' => $userId,
                'likeable_type' => $modelClass,
                'likeable_id' => $targetId,
            ],
            ['action' => $action]
        );

        // 3. Инвалидируем кэш
        Redis::del("like:stats:{$targetType}:{$targetId}");

        // 4. (Опционально) отправляем событие
        event(new LikeUpdated($targetType, $targetId));

        return $this->getStats($targetType, $targetId);
    }

    public function getStats(string $targetType, string $targetId): array
    {
        $cacheKey = "like:stats:{$targetType}:{$targetId}";
        
        return Cache::remember($cacheKey, 300, function () use ($targetType, $targetId) {
            $modelClass = $this->resolveModel($targetType);
            $likes = Like::where('likeable_type', $modelClass)
                         ->where('likeable_id', $targetId)
                         ->selectRaw('SUM(CASE WHEN action = 1 THEN 1 ELSE 0 END) as likes')
                         ->selectRaw('SUM(CASE WHEN action = -1 THEN 1 ELSE 0 END) as dislikes')
                         ->first();

            return [
                'likes' => (int) $likes->likes,
                'dislikes' => (int) $likes->dislikes,
                'total' => (int) $likes->likes - (int) $likes->dislikes,
            ];
        });
    }
}

Защита и оптимизация

Флуд запросами

Rate-limit через middleware: RateLimiter::for('likes', fn($job) => Limit::perMinute(10)->by($job->user_id));

Дубли

Уникальный индекс в БД + updateOrCreate

Медленный подсчёт

Кэширование в Redis на 5 минут

Высокая нагрузка

Использовать OPcache, PHP 8.2+, connection pooling (pgBouncer) PHP-FPM создаёт процесс на каждый запрос → выше overhead, чем Go. Нужно больше RAM при высокой нагрузке. Для >10 млн/день может потребоваться переход на Swoole или микросервис на другом языке.

Блокировки

Избегать SELECT FOR UPDATE — использовать INSERT ... ON DUPLICATE KEY UPDATE (MySQL) или ON CONFLICT (PostgreSQL)

Масштабирование

Горизонтальное: несколько PHP-FPM воркеров + балансировщик (Nginx).
Кэш: Redis на отдельном сервере или кластере.
БД: реплика только для чтения (если нужны аналитические отчёты).
Очереди: если нужно уведомлять — используйте Laravel Queues с Redis или DB драйвером.

При 1 млн/день (~12 RPS) даже один сервер (4 ядра, 8 ГБ RAM) справится, если:

    Есть Redis кэш
    Есть уникальный индекс
    Нет N+1 запросов

TODO

Laravel — он идеально подходит для этой задачи.
Обязательно подключите Redis.
Настройте мониторинг (Laravel Telescope, Prometheus, New Relic).
Тестируйте нагрузку через k6 или Artillery.
блаблабла...
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment