На минулому тижні до нас прийшов клієнт: його аукціон «зависав», коли два користувачі одночасно ставили на один лот — одна ставка зникала, інша дублювалася. Втрати становили до $5000 за лот. Така ситуація знайома багатьом власникам аукціонів. Ми розробляємо аукціонні системи, які виключають подібні помилки «з коробки». За 7 років ми реалізували понад 30 проєктів для e-commerce та торгових майданчиків.
Проблеми, які вирішуємо
Race condition при паралельних ставках — найпоширеніша біда. Два запити обробляються одночасно, кожен бачить поточну ціну $100, обидва записують $110 — перемагає перший, другий має бути відхилений. Без блокування клієнт отримує хибне підтвердження, а аукціон — спірну ситуацію. Ми усуваємо це через песимістичне блокування Redis, виключаючи подвійні записи.
Хибне визначення переможця — проблема через несинхронізовані таймери. Сервер показує 5 секунд до кінця, але через затримку мережі клієнт вважає, що залишилося 10. Ставка йде вчасно, але сервер вже закрив лот. Втрати для організатора — тисячі доларів. Наш підхід — синхронізація часу через WebSocket із серверним таймером.
Антиснайпінг — коли учасник чекає останньої секунди і «краде» лот. Без механізму продовження аукціон втрачає чесність. Ми додаємо автоматичне продовження на 2 хвилини після кожної ставки у фінальній фазі.
Як ми це робимо: стек та ключові рішення
Обираємо Node.js (NestJS для строгої архітектури) та PostgreSQL — перевірена зв'язка для транзакційних навантажень. Песимістичне блокування через Redis SETNX — щоб жоден запит не проскочив без блокування. WebSocket (Socket.IO) — для миттєвих update всіх клієнтів; fallback на long-polling, якщо мережа блокує WebSocket. Фронтенд — React 18 з Next.js (SSR для SEO) та TypeScript, компоненти на Tailwind.
Порівняємо два підходи до блокування:
| Метод | Продуктивність | Надійність | Складність |
|---|---|---|---|
| Оптимістичне (retry) | Висока, але повторні запити | Низька при високій конкуренції | Низька |
| Песимістичне (Redis lock) | Середня, блокування на 5 с | Висока, виключає race condition | Середня |
Ми використовуємо песимістичне блокування як більш надійне для аукціонів з високою конкуренцією.
Як запобігти конфлікту ставок?
Песимістичне блокування через Redis — найнадійніше. Алгоритм:
class AuctionService { async placeBid(auctionId: string, userId: string, amount: number): Promise<BidResult> { const lockKey = `lock:auction:${auctionId}`; const lockAcquired = await redis.set(lockKey, userId, 'NX', 'PX', 5000); if (!lockAcquired) { throw new Error('Auction is processing another bid, try again'); } try { const auction = await auctionRepo.findById(auctionId); if (auction.status !== 'active') throw new BidError('Auction is not active'); if (new Date() > auction.endsAt) throw new BidError('Auction has ended'); if (amount <= auction.currentPrice) { throw new BidError(`Bid must be higher than ${auction.currentPrice}`); } if (amount < auction.currentPrice + auction.minIncrement) { throw new BidError(`Minimum increment is ${auction.minIncrement}`); } if (auction.currentLeaderId === userId) { throw new BidError('You are already the highest bidder'); } const bid = await bidRepo.create({ auctionId, userId, amount }); await auctionRepo.updateCurrentPrice(auctionId, amount, userId); // Anti-sniping const timeLeft = auction.endsAt.getTime() - Date.now(); if (timeLeft < 2 * 60 * 1000) { const newEndTime = new Date(Date.now() + 2 * 60 * 1000); await auctionRepo.extendTime(auctionId, newEndTime); } await this.broadcastBid(auctionId, bid, auction); return { success: true, bid }; } finally { await redis.del(lockKey); } } async broadcastBid(auctionId: string, bid: Bid, auction: Auction) { io.to(`auction:${auctionId}`).emit('bid:new', { bidId: bid.id, amount: bid.amount, bidderId: bid.userId, bidderName: anonymizeBidder(bid.userId), timestamp: bid.createdAt, totalBids: auction.bidCount + 1, newEndTime: auction.endsAt }); } } Чому антиснайпінг критичний для аукціону?
Без нього будь-який учасник може дочекатися останніх мілісекунд і виграти без конкуренції. Наш таймер автоматично продовжується на 2 хвилини, даючи шанс відповісти. Сервер транслює оновлений час усім клієнтам через WebSocket.
class AuctionTimer { async startTimer(auctionId: string, endTime: Date): Promise<void> { const msUntilEnd = endTime.getTime() - Date.now(); setTimeout(async () => { await this.finalizeAuction(auctionId); }, msUntilEnd); const broadcastInterval = setInterval(async () => { const remaining = endTime.getTime() - Date.now(); if (remaining <= 0) { clearInterval(broadcastInterval); return; } if (remaining <= 60000) { io.to(`auction:${auctionId}`).emit('timer:tick', { remaining: Math.ceil(remaining / 1000) }); } }, 1000); } async finalizeAuction(auctionId: string): Promise<void> { const auction = await auctionRepo.findById(auctionId); if (auction.status !== 'active') return; await auctionRepo.finalize(auctionId); io.to(`auction:${auctionId}`).emit('auction:ended', { winnerId: auction.currentLeaderId, winnerName: await getUserName(auction.currentLeaderId), finalPrice: auction.currentPrice }); await this.notifyParticipants(auction); } } Клієнтський компонент на React
Усі дані надходять по WebSocket — компонент реагує миттєво. Жодних зайвих re-render, лише точкові оновлення.
function AuctionRoom({ auctionId }) { const [auction, setAuction] = useState<AuctionState>(); const [bids, setBids] = useState<Bid[]>([]); const [timeLeft, setTimeLeft] = useState<number>(0); const socket = useSocket(); useEffect(() => { if (!socket) return; socket.emit('auction:join', { auctionId }); socket.on('auction:state', (state) => setAuction(state)); socket.on('bid:new', (bid) => { setBids(prev => [bid, ...prev].slice(0, 50)); setAuction(prev => prev ? { ...prev, currentPrice: bid.amount } : prev); }); socket.on('timer:tick', ({ remaining }) => setTimeLeft(remaining)); socket.on('auction:ended', ({ winnerId, finalPrice }) => { setAuction(prev => prev ? { ...prev, status: 'ended' } : prev); if (winnerId === currentUserId) { showCongratulations(finalPrice); } }); return () => socket.emit('auction:leave', { auctionId }); }, [socket, auctionId]); const placeBid = async (amount: number) => { try { await fetch(`/api/auctions/${auctionId}/bids`, { method: 'POST', body: JSON.stringify({ amount }) }); } catch (e) { showError(e.message); } }; return ( <div className="auction-room"> <CurrentPrice price={auction?.currentPrice} /> <AuctionTimer seconds={timeLeft} critical={timeLeft < 30} /> <BidForm minBid={(auction?.currentPrice ?? 0) + (auction?.minIncrement ?? 100)} onBid={placeBid} disabled={auction?.status !== 'active'} /> <BidHistory bids={bids} currentUserId={currentUserId} /> </div> ); } Процес роботи
- Аналітика — дивимося на бізнес-логіку, визначаємо типи аукціонів (англійський, голландський, закритий), навантаження, інтеграції з платіжними системами.
- Проектування — малюємо архітектуру: схему БД, протокол WebSocket, стратегію блокувань, anti-sniping, таймери.
- Реалізація — пишемо сервер і клієнт паралельно. На кожен спринт — демо.
- Тестування — unit-тести, інтеграційні тести, навантажувальне тестування (до 2000 RPS). Перевіряємо race condition і таймінги.
- Деплой — налаштовуємо CI/CD, контейнеризацію, моніторинг (Grafana, Prometheus). Передаємо документацію та доступи.
Що входить в роботу
- Повна кодова база сервера (Node.js + TypeScript) та клієнта (React/Next.js).
- Документація API (Swagger) та архітектурна схема.
- Навантажувальне тестування зі звітом.
- Інструкція з розгортання та налаштування моніторингу.
- Один місяць підтримки після запуску (виправлення багів, консультації).
Строки орієнтовно
| Комплектація | Строк |
|---|---|
| Базовий аукціон: ставки, таймер, історія | 2–3 тижні |
| + Антиснайпінг, сповіщення, email-розсилки | +1 тиждень |
| + Мультилот, інтеграція з платіжними системами, особистий кабінет | 4–6 тижнів |
Точні строки оцінюємо після аналізу вашого ТЗ. Зв'яжіться з нами — надішлемо оцінку за 1 день.
Типові помилки при розробці аукціонів
| Помилка | Наслідок | Наше рішення |
|---|---|---|
| Відсутність блокування ставок | Race condition, подвійні ставки | Песимістичне блокування Redis |
| Реалізація таймера лише на клієнті | Розсинхронізація часу | Серверний таймер з broadcast |
| Немає anti-sniping | Лоти йдуть за мінімальною ціною | Автоматичне продовження на 2 хв |
| Ігнорування затримок мережі | Запізнілі ставки | WebSocket з підтвердженням |
«Після впровадження системи кількість спірних ставок знизилася до нуля» — клієнт з e-commerce.
Приклад конфлікту ставок та його вирішення
Одного разу на аукціоні два учасники одночасно подали ставки по $1000. Без блокування обидва запити пройшли, і система зафіксувала дві ставки. Після впровадження нашого блокування другий запит отримує помилку і клієнт повторює ставку з більшою сумою. У результаті — чесна перемога першого.
Уникнути цих граблів допомагає наш досвід — 7 років у розробці real-time систем, понад 30 завершених проєктів для e-commerce та аукціонів. Гарантуємо стабільність та масштабованість.
Якщо ви хочете таку саму систему, замовте консультацію — обговоримо деталі та приступимо. Отримайте консультацію — розкажіть про ваш аукціон, і ми запропонуємо оптимальне рішення.







