Giỏ hàng, số lượng, giá thay đổi, RedisCarts, quantities, changing prices, Redis
Cart Service giữ giỏ khách vãng lai trong Redis hash có TTL và giỏ của người đã đăng nhập trong PostgreSQL. Giá và tồn kho luôn hỏi Catalog khi xem giỏ hoặc trước khi chuyển sang Checkout. Chọn “Giá đổi trước khi thanh toán” để xem cảnh báo và bước xác nhận.The Cart Service keeps guest carts in a Redis hash with a TTL and signed-in carts in PostgreSQL. Price and stock are always fetched from the Catalog when the cart is viewed or before handing over to Checkout. Pick “Price changed before checkout” to see the warning and the confirmation step.
1Khách chưa đăng nhập thêm 2 cái A1. App gửi cookie cart_id do server cấp ở lần đầu.An anonymous visitor adds 2 × A1. The app sends the cart_id cookie the server issued on the first visit.
Giá lưu lúc thêm sẽ cũ. Giỏ chỉ lưu product_id và số lượng; giá lấy từ catalog khi xem và khi thanh toán. price_seen nếu có chỉ dùng để cảnh báo.A price saved at add time goes stale. The cart stores product_id and quantity; prices come from the catalog for display and checkout. A price_seen column, if any, only drives warnings.
Đọc rồi ghi lại sẽ mất một lượt. Redis dùng HINCRBY nguyên tử; giỏ trong DB dùng UPDATE qty = qty + ? hoặc cột version.Read-then-write loses an update. Redis uses atomic HINCRBY; the DB cart uses UPDATE qty = qty + ? or a version column.
Cộng dồn dễ gấp đôi khi gọi lại. Lấy số lượng lớn hơn cho sản phẩm trùng, cắt theo giới hạn, ghi trong một transaction rồi mới xoá giỏ khách.Adding quantities doubles them on a retry. Keep the larger quantity for shared products, clamp to the limits, write in one transaction, then delete the guest cart.
Giới hạn 50 dòng, 10 cái mỗi dòng và TTL cho giỏ khách. Bot tạo hàng triệu giỏ thì Redis tự dọn khi hết hạn.Limit to 50 lines, 10 units per line, and a TTL on guest carts. When bots create millions of carts, Redis clears them as they expire.
Mỗi thay đổi cập nhật last_activity_at. Job quét giỏ không động tới sau 24 giờ, phát CartAbandoned; worker gửi email nhắc, mỗi giỏ tối đa một lần.Every change updates last_activity_at. A job finds carts idle for 24 hours and emits CartAbandoned; a worker sends a reminder, at most once per cart.
| BảngTable | Cột chínhKey columns | Ghi chúNotes |
|---|---|---|
cart:{guestId} (Redis hash) | field = product_id, value = quantity; TTL 7d | Giỏ khách vãng lai; HINCRBY để tăng, HDEL để bỏ dòngGuest cart; HINCRBY to increase, HDEL to remove a line |
cart | id, user_id, version, last_activity_at, abandoned_notified_at, created_at | unique(user_id): mỗi tài khoản một giỏ đang mởunique(user_id): one open cart per account |
cart_item | cart_id, product_id, quantity, price_seen, added_at | PK(cart_id, product_id); price_seen chỉ để cảnh báo, không dùng tính tiềnPK(cart_id, product_id); price_seen only drives warnings, never billing |
checkout_quote | id, user_id, lines, total, expires_at | Giá đã xác nhận, hết hạn sau 10 phútConfirmed prices, expiring after 10 minutes |
Giỏ khách vãng lai trên Redis: thêm hàng có giới hạn, tăng nguyên tử bằng HINCRBY, gia hạn TTL; gộp vào DB khi đăng nhập theo cách gọi lại không sai.A guest cart in Redis: add items within limits, increase atomically with HINCRBY, extend the TTL; merge into the DB on sign-in in a way that is safe to retry.
@Service
@RequiredArgsConstructor
public class GuestCartService {
private static final Duration TTL = Duration.ofDays(7);
private static final int MAX_LINES = 50;
private static final int MAX_QTY = 10;
private final StringRedisTemplate redis;
private final CatalogClient catalog;
private final CartRepository carts;
public void addItem(String cartId, String productId, int qty) {
if (!catalog.isSellable(productId)) throw new ProductUnavailableException(productId);
String key = "cart:" + cartId;
HashOperations<String, String, String> h = redis.opsForHash();
if (!h.hasKey(key, productId) && h.size(key) >= MAX_LINES)
throw new CartFullException(MAX_LINES);
long now = h.increment(key, productId, qty); // HINCRBY
if (now > MAX_QTY) {
h.increment(key, productId, -qty); // undo our own increment
throw new QuantityLimitException(MAX_QTY);
}
redis.expire(key, TTL); // sliding expiry
}
public void mergeOnLogin(String guestCartId, long userId) {
String key = "cart:" + guestCartId;
Map<String, String> guest = redis.<String, String>opsForHash().entries(key);
carts.mergeMax(userId, guest, MAX_QTY); // one tx: ON CONFLICT … GREATEST
redis.delete(key); // after commit; a retry is harmless
}
}const (
cartTTL = 7 * 24 * time.Hour
maxLines = 50
maxQty = 10
)
type GuestCarts struct {
rdb *redis.Client
catalog Catalog
carts CartRepo
}
func (c *GuestCarts) AddItem(ctx context.Context, cartID, productID string, qty int64) error {
if !c.catalog.IsSellable(ctx, productID) {
return ErrProductUnavailable
}
key := "cart:" + cartID
exists, err := c.rdb.HExists(ctx, key, productID).Result()
if err != nil {
return err
}
if !exists && c.rdb.HLen(ctx, key).Val() >= maxLines {
return ErrCartFull
}
now, err := c.rdb.HIncrBy(ctx, key, productID, qty).Result()
if err != nil {
return err
}
if now > maxQty {
c.rdb.HIncrBy(ctx, key, productID, -qty) // undo our own increment
return ErrQuantityLimit
}
return c.rdb.Expire(ctx, key, cartTTL).Err()
}
func (c *GuestCarts) MergeOnLogin(ctx context.Context, cartID string, userID int64) error {
key := "cart:" + cartID
guest, err := c.rdb.HGetAll(ctx, key).Result()
if err != nil || len(guest) == 0 {
return err
}
if err := c.carts.MergeMax(ctx, userID, guest, maxQty); err != nil {
return err // guest cart kept, safe to retry
}
return c.rdb.Del(ctx, key).Err()
}| Redis hash + TTLRedis hash + TTL | PostgreSQLPostgreSQL | |
|---|---|---|
| Hợp vớiGood for | Khách vãng lai, ghi nhiều, dữ liệu tạmGuests, heavy writes, short-lived data | Người đã đăng nhập, giữ giỏ qua nhiều thiết bịSigned-in users keeping a cart across devices |
| Đồng thờiConcurrency | HINCRBY nguyên tử, không cần khoáAtomic HINCRBY, no locks needed | UPDATE qty = qty + ? hoặc cột versionUPDATE qty = qty + ? or a version column |
| Mất dữ liệuData loss | Có thể mất vài giây khi Redis sập, chấp nhận được với giỏ tạmA few seconds may be lost if Redis crashes, acceptable for a temporary cart | Bền, có báo cáo và job tìm giỏ bị bỏDurable, supports reports and abandoned-cart jobs |
Mỗi pattern mở bài học tương ứng trong menu Pattern.Each one opens its lesson in the Patterns menu.
Giá và khuyến mãi đổi liên tục. Giỏ là ý định mua, không phải cam kết giá. Giá được tính lại khi xem và chốt khi tạo đơn; nếu khác giá người dùng đã thấy thì phải hỏi xác nhận.Prices and promotions change constantly. A cart is an intent to buy, not a price commitment. Prices are recomputed on view and locked when the order is created; if they differ from what the user saw, ask for confirmation.
Lấy số lớn hơn thì idempotent: gọi lại sau lỗi không gấp đôi số lượng, và khớp với ý định thường gặp là người dùng thêm cùng món ở hai nơi. Cộng dồn phải kèm cờ đã gộp để không chạy hai lần.Taking the larger value is idempotent: a retry after a failure never doubles quantities, and it matches the common case of adding the same item on two devices. Adding requires a merged flag so it never runs twice.
Không đọc rồi ghi. Dùng lệnh tăng nguyên tử: HINCRBY trên Redis, UPDATE … SET qty = qty + ? trên DB. Nếu cần ghi cả giỏ thì dùng cột version.Avoid read-then-write. Use an atomic increment: HINCRBY in Redis, UPDATE … SET qty = qty + ? in the DB. When the whole cart is rewritten, use a version column.
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.