Đặt phòng, vé, lịch hẹn; trùng lịch, concurrencyRooms, tickets, appointments; double booking, concurrency
API ghi booking ở trạng thái HELD có hạn; PostgreSQL là nơi duy nhất quyết định ai được chỗ, nhờ unique hoặc exclusion constraint. Webhook thanh toán chuyển HELD sang CONFIRMED, sweeper chuyển hold quá hạn sang EXPIRED. Bật “Hai người cùng bấm” hoặc “Hết hạn giữ chỗ” để xem các nhánh lỗi.The API writes a booking as HELD with an expiry; PostgreSQL alone decides who gets the slot, through a unique or exclusion constraint. The payment webhook moves HELD to CONFIRMED, and a sweeper moves stale holds to EXPIRED. Turn on “Two users click at once” or “Hold expires” to see the failure paths.
1User 42 xem phòng R12 đêm 20/11. Danh sách chỗ trống đọc từ cache, có thể lệch vài giây.User 42 looks at room R12 for the night of 20 Nov. Availability comes from the cache and may be a few seconds old.
SELECT xem còn trống rồi INSERT có khe hở giữa hai câu. Để database chặn: unique(resource_id, slot) với mô hình slot, hoặc EXCLUDE USING gist (resource_id WITH =, during WITH &&) với khoảng thời gian. Lỗi 23505/23P01 thành 409.A SELECT to check followed by an INSERT leaves a gap between the two. Let the database enforce it: unique(resource_id, slot) for the slot model, or EXCLUDE USING gist (resource_id WITH =, during WITH &&) for time ranges. Map 23505/23P01 to 409.
Mỗi hold có expires_at. Sweeper chuyển HELD quá hạn sang EXPIRED bằng SKIP LOCKED để nhiều instance chia việc. Constraint chỉ áp cho HELD/CONFIRMED (partial index hoặc WHERE trong EXCLUDE).Every hold has an expires_at. A sweeper moves stale HELD rows to EXPIRED with SKIP LOCKED so several instances share the work. The constraint only applies to HELD/CONFIRMED (a partial index, or a WHERE clause on the EXCLUDE).
Xác nhận bằng UPDATE … WHERE status='HELD' AND expires_at > now(). Không có dòng nào đổi nghĩa là chỗ đã mất: hoàn tiền tự động, không xác nhận.Confirm with UPDATE … WHERE status='HELD' AND expires_at > now(). Zero rows means the slot is gone: refund automatically instead of confirming.
Sửa booking đã có (đổi ngày, thêm khách) dùng cột version và retry khi xung đột ít. Phòng hội nghị có quota theo ngày, tranh chấp cao thì SELECT … FOR UPDATE trên dòng inventory.Edits to an existing booking (new date, extra guest) use a version column and retry when conflicts are rare. For a daily quota with heavy contention, lock the inventory row with SELECT … FOR UPDATE.
Khách sạn và hãng bay bán quá 3–5% vì có người không đến. Lưu capacity và overbook_limit trên inventory theo ngày, quy tắc cho phép đặt là một Specification riêng, không rải trong code.Hotels and airlines sell 3–5% over capacity because of no-shows. Store capacity and overbook_limit on the per-day inventory row and keep the admission rule in its own Specification instead of scattering it through the code.
Lưu timestamptz (UTC) cùng time zone IANA của địa điểm. Ngày nhận phòng là ngày theo giờ địa phương; tính slot bằng ZonedDateTime/time.LoadLocation, không cộng 24 giờ.Store timestamptz (UTC) plus the venue's IANA time zone. A check-in date is a local date; compute slots with ZonedDateTime/time.LoadLocation, never by adding 24 hours.
| BảngTable | Cột chínhKey columns | Ghi chúNotes |
|---|---|---|
resource | id, type, venue_id, capacity, time_zone | Phòng, ghế, bác sĩ; time_zone theo IANA, ví dụ Asia/Ho_Chi_MinhA room, seat or doctor; time_zone is IANA, e.g. Asia/Ho_Chi_Minh |
booking | id, resource_id, user_id, during tstzrange, status, expires_at, payment_id, version | EXCLUDE USING gist (resource_id WITH =, during WITH &&) WHERE status IN ('HELD','CONFIRMED'); HELD → CONFIRMED · EXPIRED · CANCELLEDEXCLUDE USING gist (resource_id WITH =, during WITH &&) WHERE status IN ('HELD','CONFIRMED'); HELD → CONFIRMED · EXPIRED · CANCELLED |
seat_hold | show_id, seat_no, booking_id, status | Mô hình slot rời rạc: unique(show_id, seat_no) WHERE status <> 'EXPIRED'Discrete slot model: unique(show_id, seat_no) WHERE status <> 'EXPIRED' |
daily_inventory | resource_type, date, capacity, booked, overbook_limit | Quota theo ngày; CHECK (booked <= capacity + overbook_limit)Per-day quota; CHECK (booked <= capacity + overbook_limit) |
outbox | id, topic, key, payload, published_at | BookingConfirmed, BookingExpired đi cùng transactionBookingConfirmed and BookingExpired committed in the same transaction |
Java: giữ chỗ dựa vào constraint, xác nhận bằng UPDATE có điều kiện. Go: cùng phần giữ chỗ và sweeper trả các hold quá hạn.Java: holding relies on the constraint, confirming uses a conditional UPDATE. Go: the same hold plus the sweeper that releases expired holds.
@Service
@RequiredArgsConstructor
public class BookingService {
private static final Duration HOLD_TTL = Duration.ofMinutes(10);
private final BookingRepository repo;
private final Clock clock;
@Transactional
public Booking hold(HoldRequest req) {
Booking b = Booking.held(req.resourceId(), req.during(), req.userId(),
clock.instant().plus(HOLD_TTL));
try {
return repo.saveAndFlush(b); // constraint checked here
} catch (DataIntegrityViolationException ex) {
throw new SlotTakenException(req.resourceId(), req.during());
}
}
@Transactional
public void confirm(UUID bookingId, String paymentId) {
int updated = repo.confirmIfHeld(bookingId, paymentId, clock.instant());
if (updated == 0) {
throw new HoldExpiredException(bookingId); // caller refunds
}
}
}
interface BookingRepository extends JpaRepository<Booking, UUID> {
@Modifying
@Query(nativeQuery = true, value = """
UPDATE booking SET status = 'CONFIRMED', payment_id = :pid
WHERE id = :id AND status = 'HELD' AND expires_at > :now""")
int confirmIfHeld(UUID id, String pid, Instant now);
}const holdTTL = 10 * time.Minute
var ErrSlotTaken = errors.New("booking: slot already taken")
type Store struct{ db *pgxpool.Pool }
func (s *Store) Hold(ctx context.Context, resID, userID int64, from, to time.Time) (int64, error) {
var id int64
err := s.db.QueryRow(ctx, `
INSERT INTO booking (resource_id, user_id, during, status, expires_at)
VALUES ($1, $2, tstzrange($3, $4, '[)'), 'HELD', now() + make_interval(secs => $5))
RETURNING id`, resID, userID, from, to, holdTTL.Seconds()).Scan(&id)
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) && (pgErr.Code == "23P01" || pgErr.Code == "23505") {
return 0, ErrSlotTaken
}
return id, err
}
func (s *Store) ExpireHolds(ctx context.Context) (int64, error) {
tag, err := s.db.Exec(ctx, `
UPDATE booking SET status = 'EXPIRED'
WHERE id IN (
SELECT id FROM booking
WHERE status = 'HELD' AND expires_at < now()
ORDER BY expires_at LIMIT 500
FOR UPDATE SKIP LOCKED)`)
return tag.RowsAffected(), err
}
func RunSweeper(ctx context.Context, s *Store, every time.Duration) {
t := time.NewTicker(every)
defer t.Stop()
for {
select {
case <-ctx.Done():
return
case <-t.C:
if n, err := s.ExpireHolds(ctx); err != nil {
slog.Error("expire holds", "err", err)
} else if n > 0 {
slog.Info("holds expired", "count", n)
}
}
}
}| Slot / inventory theo ngàySlots / per-day inventory | Khoảng tstzrange + EXCLUDEtstzrange + EXCLUDE | |
|---|---|---|
| Hợp vớiGood for | Ghế, vé, lịch khám 30 phút, phòng theo đêmSeats, tickets, 30-minute appointments, rooms by the night | Thời lượng tuỳ ý: phòng họp, thuê xe theo giờArbitrary durations: meeting rooms, hourly car rental |
| Chặn trùngBlocking overlaps | Unique index, có ở mọi databaseA unique index, available in every database | Exclusion constraint, cần PostgreSQL và btree_gistAn exclusion constraint, needs PostgreSQL and btree_gist |
| Đọc chỗ trốngReading availability | Đơn giản: liệt kê slot chưa có bookingSimple: list slots without a booking | Phải tính khoảng trống giữa các rangeGaps between ranges must be computed |
| Chi phíCost | Phải sinh trước slot cho nhiều thángSlots must be generated months ahead | Index GiST lớn hơn, ghi chậm hơn một chútA larger GiST index, slightly slower writes |
Mỗi pattern mở bài học tương ứng trong menu Pattern.Each one opens its lesson in the Patterns menu.
Service chạy nhiều instance, khoá trong bộ nhớ chỉ có tác dụng trong một process. Database là nơi duy nhất mọi instance cùng nhìn thấy, nên constraint ở đó mới là bảo đảm thật.The service runs as several instances and an in-memory lock only covers one process. The database is the one place every instance sees, so a constraint there is the real guarantee.
Chưa, vì constraint vẫn thấy dòng HELD; predicate của constraint không được dùng now(). Cách xử lý: chạy sweeper dày (30 giây), hoặc trong transaction giữ chỗ chuyển các hold quá hạn giao khoảng sang EXPIRED trước khi INSERT.Not yet, because the constraint still sees a HELD row; a constraint predicate cannot use now(). Either run the sweeper often (30 seconds), or, inside the hold transaction, expire overlapping stale holds before the INSERT.
Optimistic (cột version) khi xung đột hiếm, ví dụ sửa một booking. Pessimistic (FOR UPDATE) khi nhiều người tranh cùng một dòng, ví dụ quota theo ngày lúc mở bán, để tránh vòng retry dài.Optimistic (a version column) when conflicts are rare, such as editing one booking. Pessimistic (FOR UPDATE) when many users fight over one row, such as a daily quota at launch, to avoid long retry loops.
UPDATE có điều kiện trả 0 dòng nên booking không được xác nhận. Hệ thống hoàn tiền tự động bằng idempotency key, rồi báo người dùng; có thể thử giữ lại chỗ nếu vẫn còn trống.The conditional UPDATE returns 0 rows, so the booking is not confirmed. The system refunds automatically with an idempotency key and informs the user; it may try to re-hold the slot if it is still free.
Phím 1–4 để chọn đáp án, Enter để kiểm tra.Press 1–4 to choose an answer, Enter to check.
Ôn tập ngắt quãng (spaced repetition): thẻ được lặp lại sau 1 → 3 → 7 → 14 → 30 ngày để củng cố trí nhớ dài hạn.Spaced repetition: cards are repeated after 1 → 3 → 7 → 14 → 30 days to build long-term memory.