A high-performance, concurrency-safe Go library for flash sale inventory deduction using Redis and Lua scripts.
- π Atomic Operations: Uses Redis Lua scripts for true atomicity under high concurrency
- π No Overselling: Guaranteed stock protection even with thousands of concurrent requests
- β±οΈ Auto-Expiring Reservations: TTL-based reservations prevent stock lockup
- π Observability: Built-in Prometheus metrics and structured logging (slog)
- π·οΈ Namespace Isolation: Run multiple flash sales on the same Redis instance
- β Idempotent Operations: Safe to retry commits and rollbacks
go get github.com/ahadiihsanrasyidin/inventory-sentinelFor a complete runnable example, see examples/basic/main.go.
package main
import (
"context"
"log"
"time"
sentinel "github.com/ahadiihsanrasyidin/inventory-sentinel"
"github.com/redis/go-redis/v9"
)
func main() {
ctx := context.Background()
// Connect to Redis
client := redis.NewClient(&redis.Options{
Addr: "localhost:6379",
})
// Create sentinel with namespace
s := sentinel.New(client,
sentinel.WithNamespace("flash_sale"),
)
// Initialize stock (typically done by admin service)
s.SetStock(ctx, "iphone15", 100)
// Reserve 2 units for 5 minutes
reservationID, err := s.Reserve(ctx, "iphone15", 2, 5*time.Minute)
if err != nil {
if err == sentinel.ErrInsufficientStock {
log.Println("Sorry, item is sold out!")
return
}
log.Fatal(err)
}
log.Printf("Reserved! ID: %s\n", reservationID)
// Process payment...
paymentSuccess := processPayment()
if paymentSuccess {
// Commit the reservation
if err := s.Commit(ctx, reservationID); err != nil {
log.Fatal(err)
}
log.Println("Purchase complete!")
} else {
// Rollback - return stock to pool
if err := s.Rollback(ctx, reservationID); err != nil {
log.Fatal(err)
}
log.Println("Payment failed, stock restored")
}
}
func processPayment() bool {
// Your payment logic here
return true
}This library implements the Two-Phase Reservation Pattern:
ββββββββββββ Reserve ββββββββββββββββ Lua Script βββββββββ
β Client β βββββββββββββββΊ β Sentinel β ββββββββββββββββββΊ β Redis β
ββββββββββββ ββββββββββββββββ βββββββββ
β β β
β β ββββ reservationID ββββββββββββββ€
β β β
β (process payment) β
β β β
βββ Commit βββββββββββββββββββΊ β βββββ Lua: delete key ββββββββββΊβ
β (if payment ok) β β
β β β
βββ Rollback βββββββββββββββββΊ β βββββ Lua: restore stock βββββββΊβ
(if payment fails)
Traditional WATCH/MULTI transactions in Redis require optimistic locking with client-side retries. Under the extreme concurrency of flash sales (thousands of requests per second), this causes:
- Retry storms as transactions fail and retry
- Cascading failures under load
- Higher latency from multiple round-trips
Lua scripts execute atomically on the Redis server, eliminating:
- The need for client-side retries
- Multiple round-trips per operation
- Race conditions between check and update
Strictly speaking: No. You can use it for everything. Realistically: You usually use it only for "Hot" items.
Why?
- Regular Items (Low Traffic): Your PostgreSQL/MySQL database can easily handle 50 people buying a standard t-shirt at the same time. Using the Sentinel adds unnecessary complexity (maintenance, syncing) for items that don't need it.
- Flash Sale Items (High Traffic): When 50,000 people try to buy an iPhone 15 at the exact same second, your database locks will cause the site to crash. This is where you switch to the Sentinel.
Yes, easily. Redis is incredibly fast.
- Capacity: You can store millions of SKUs in Redis. A simple key like
stock:sku_123takes up negligible RAM. - Performance: Redis can handle ~100,000 operations per second. 10,000 SKUs is "nothing" to Redis.
So, the limit is not performance, the limit is operational complexity (keeping Redis and your main Database in sync).
This is the "Engineer" part. You don't just "turn it on." You build a Hybrid Flow.
Imagine you are the backend for the "Checkout Service."
You don't want to query the SQL DB for the stock during the flash sale.
- Action: 1 hour before the sale, a cron job (or admin dashboard) pushes the stock count for the specific Flash Sale items from SQL -> Redis Sentinel.
- Status: Redis now has
sku:iphone_15 = 1000.
When a user clicks "Checkout," your code makes a decision:
func Checkout(user User, cart Cart) {
for _, item := range cart.Items {
// Step 1: Check if this item is protected by Sentinel
if sentinel.IsProtected(item.SkuID) {
// FAST PATH: Flash Sale Item
// 1. Try to reserve in Redis (0.5ms)
reservationID, err := sentinel.Reserve(ctx, item.SkuID, item.Qty, 5*time.Minute)
if err != nil {
return Error("Sold Out!") // Block request here. DB is never touched.
}
// 2. Attach reservation ID to the order context
order.Metadata["reservation_id"] = reservationID
} else {
// SLOW PATH: Regular Item
// Do standard DB check (SELECT FOR UPDATE)
}
}
// Step 3: Proceed to Payment Gateway
// If payment fails -> sentinel.Rollback(reservationID)
}This is the most important part. The Sentinel is temporary. The SQL DB is permanent.
- User Pays: Payment Gateway returns "Success".
- Commit: Your backend calls
sentinel.Commit(reservationID).- Note: In a pure "Sentinel" design, this
Commitmight just delete the temporary reservation key and permanently decrement the Redis counter.
- Note: In a pure "Sentinel" design, this
- Async Sync: A background worker (or message queue like Kafka/RabbitMQ) sees the successful order and updates the SQL Database inventory to match reality.
"I designed this library to be progressive.
- For 99% of products (long-tail), we use standard ACID database transactions.
- For the top 1% of viral products, we 'promote' them to the Sentinel.
This library allows the Platform Team to dynamically toggle 'High Concurrency Mode' for specific SKUs without redeploying the backend."
// Namespace for key isolation
sentinel.WithNamespace("flash_sale")
// Structured logging with slog
sentinel.WithLogger(slog.Default())
// Prometheus metrics
metrics := sentinel.NewMetrics(sentinel.DefaultMetricsOpts())
metrics.MustRegister(prometheus.DefaultRegisterer)
sentinel.WithMetrics(metrics)| Metric | Type | Description |
|---|---|---|
inventory_sentinel_reserve_total |
Counter | Reserve attempts by status |
inventory_sentinel_commit_total |
Counter | Commit attempts by status |
inventory_sentinel_rollback_total |
Counter | Rollback attempts by status |
inventory_sentinel_operation_duration_seconds |
Histogram | Operation latency |
var (
ErrInsufficientStock // Stock < requested amount
ErrReservationNotFound // Reservation expired or doesn't exist
ErrReservationAlreadyCommitted // Double-commit attempt
ErrInvalidAmount // Amount <= 0
ErrInvalidTTL // TTL <= 0
ErrEmptyKey // Empty SKU key
)All errors can be checked using errors.Is():
if errors.Is(err, sentinel.ErrInsufficientStock) {
// Handle sold out
}The test suite uses testcontainers-go to run tests against a real Redis instance:
# Run all tests (requires Docker)
go test -v -race ./...
# Run specific concurrent load test
go test -v -run TestConcurrentReserve ./...The concurrent load test spawns 50 goroutines competing for 1 item, verifying that exactly 1 succeeds.
MIT License - see LICENSE for details.