תארו לעצמכם: 5,000 משתמשים בו-זמנית עושים לייק למאמר. המונה משתגע, הרשומות מוכפלות, ומסד הנתונים קורס תחת deadlock. זה לא היפותטי—ראינו את זה בסביבת production. מערכת לייקים ודירוגים היא לא רק 'כפתור לב'; היא אתגר הנדסי הכולל אטומיות, caching, והגנה מפני ספאם. אנחנו בונים מערכות כאלה כבר למעלה מחמש שנים, והנה איך אנחנו עושים את זה. מעל 10,000 לייקים בשנייה אפשריים עם אינדוקס נכון.
למה מערכת לייקים מורכבת יותר ממה שהיא נראית?
במבט ראשון—'כפתור + מונה.' אבל תחת בקשות מקבילות ללא נעילות, ערך המונה יכול לסטות מהספירה האמיתית של ההצבעות. בעיה נוספת היא כפילות: משתמש אחד יכול לעשות לייק מספר פעמים אם אין אילוץ ייחודיות. לבסוף, מהירות: אם כל לייק נכתב למסד הנתונים ומחושב מחדש, העמוד ייגרר תחת עומס שיא. אנחנו פותרים את זה עם שילוב של denormalization, caching, ועדכוני frontend אופטימיים.
איך להגן על המערכת מפני ספאם?
ההגנה בנויה על מספר רבדים. הצבעות משתמשים נשלטות דרך אילוצי ייחודיות. ברמת מסד הנתונים, אינדקס ייחודי על (user_id, likeable_id, likeable_type) מבטיח הצבעה אחת לכל משתמש. ברמת ה-API—rate limiting: לא יותר מ-60 בקשות בדקה לכל משתמש. לאורחים, אנחנו משתמשים בהגבלות מבוססות IP עם cookies. ב-cache, אנחנו שומרים את עובדת ההצבעה ל-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, יחס ה-morphMany משמש לאסוציאציות פולימורפיות. להלן ה-trait של 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}"),
};
}
}ה-controller של הדירוגים למטה מטפל בדירוגי 1–5 כוכבים:
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 לכפתור הלייק ולדירוג הכוכבים מוצגים למטה:
// Лайк-кнопка
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>
);
}הנה השוואה בין גישות סנכרוניות לעומת אופטימיות:
| קריטריון | סנכרוני (המתנה לתגובה) | אופטימי (מיידי) |
|---|---|---|
| מהירות נתפסת | איטי, עיכוב של 200–500 אלפיות שנייה | מיידי, חוויית משתמש טובה פי 3 |
| מורכבות יישום | נמוכה | בינונית (rollback בשגיאה) |
| אמינות המונה | מוחלטת | ייתכן אי-התאמה זמנית |
| עומס על השרת | גבוה (כל לחיצה = בקשה) | בינוני (אותן בקשות, אבל חוויית המשתמש לא מושפעת) |
איך להבטיח ביצועים תחת עומס גבוה?
אנחנו משתמשים בשילוב של מוני denormalization ו-Redis caching. ללייקים: כל הצבעה מגדילה אטומית את -- Универсальная таблица лайков (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; דרך 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'), ]); } } . לדירוגים: אגרגטים מחושבים מחדש רק כשדירוג משתנה, לא בכל צפייה. בנוסף, עדכונים אופטימיים מיושמים ב-frontend: המשתמש רואה את השינוי מיידית, בעוד הבקשה נשלחת בצורה אסינכרונית. במקרה של שגיאה, המצב מתבטל. גישה זו יכולה להפחית את עומס השרת ב-30% ולשפר את זמן התגובה הנתפס ב-200 אלפיות שנייה. מונים בזמן אמת מתעדכנים דרך Redis pub/sub.
למסד הנתונים, מומלץ PostgreSQL בשל אינדקסים חלקיים ותמיכה טובה יותר בעדכונים מקבילים. הנה השוואה:
PostgreSQL או MySQL?
| קריטריון | PostgreSQL | MySQL |
|---|---|---|
| אילוצי ייחודיות | נתמך | נתמך |
| אינדקסים חלקיים | כן (סינון לפי סטטוס) | לא |
| שדות JSON למטא-דאטה | מצוין | מוגבל |
| ביצועים ב-1000 RPS | גבוהים | גבוהים |
| המלצה | לשאילתות מורכבות | לסכמות פשוטות |
שני מסדי הנתונים יכולים להתמודד עם עד 10,000 לייקים בשנייה עם אינדוקס נכון. אנחנו בדרך כלל משתמשים ב-PostgreSQL בשל האינדקסים החלקיים והתמיכה הטובה יותר בעדכונים מקבילים.
שלבי היישום כוללים: ניתוח (יום אחד), עיצוב (1–2 ימים), יישום (2–4 ימים), בדיקות (1–2 ימים), פריסה והדרכה (יום אחד).
אנחנו מספקים חבילה מלאה: קוד מקור עם הערות, תיעוד API (OpenAPI), הוראות פריסה, גישה ל-repository, הדרכת צוות (1–2 שעות), ותמיכה למשך שבועיים לאחר המסירה. אנחנו מבטיחים שהמערכת תעבור בדיקות עומס ב-1000 RPS.
מערכת לייקים בסיסית מתחילה ב-$2,500 ואורכת 2–3 ימים. דירוגים מוסיפים $1,500 ועוד 1–2 ימים. תקציב טיפוסי למערכת מלאה נע בין $4,000 ל-$7,000. צרו קשר—נעריך את המשימה ביום אחד.
לצוות שלנו יש חמש שנות ניסיון בפיתוח מערכות אינטראקציה, מעל 120 פרויקטים שהושלמו, ושיעור המלצות לקוחות של 98%. אנחנו משתמשים רק בטכנולוגיות מוכחות: Laravel, React, PostgreSQL. אנחנו מבטיחים שהמערכת תעבוד ללא רבב תחת עומסי שיא. אם משהו משתבש, נתקן אותו תוך 24 שעות. הפתרון המוכן שלנו חותך את התקציב בחצי בהשוואה לבנייה מאפס ומקצר את לוחות הזמנים פי שלושה. קבלו הערכת פרויקט—נחשב את העלות ביום אחד. צרו קשר לייעוץ, ונראה לכם איך המערכת עובדת תחת העומס שלכם.







