Part of Distributed Rate Limiter

Sliding-window accounting with Redis sorted sets

Replaced bucketed counters with a Redis sorted-set window so each decision trims expired hits, counts the live range, and records the current request in one transaction pipeline.

The first version used a fixed counter per identity and time bucket. It was cheap, but the boundary behavior was wrong: a client could spend one full bucket near the end of a minute and another full bucket at the start of the next minute. The configured limit still looked correct in storage, but the effective rate at the boundary was almost double. That was not acceptable for admission control in front of workers with finite queue depth.

The rewrite stores each admitted request as a member in a Redis sorted set. The score is UnixMilli, the member is UnixNano plus a process suffix to avoid collisions between requests that land in the same millisecond, and the key TTL is the window length. Each decision removes expired scores, reads the remaining cardinality, writes the candidate hit, and sets the TTL through a transaction pipeline. If the count is already at the limit, the candidate member is removed after the pipeline and the request is denied.

func allowRedis(ctx context.Context, rdb *redis.Client, key string, limit int, window time.Duration) (bool, error) {
	now := time.Now()
	cutoff := now.Add(-window).UnixMilli()
	member := fmt.Sprintf("%d:%s", now.UnixNano(), processID)

	pipe := rdb.TxPipeline()
	pipe.ZRemRangeByScore(ctx, key, "0", strconv.FormatInt(cutoff, 10))
	count := pipe.ZCard(ctx, key)
	pipe.ZAdd(ctx, key, redis.Z{Score: float64(now.UnixMilli()), Member: member})
	pipe.Expire(ctx, key, window)
	if _, err := pipe.Exec(ctx); err != nil {
		return false, err
	}
	if count.Val() >= int64(limit) {
		_ = rdb.ZRem(ctx, key, member).Err()
		return false, nil
	}
	return true, nil
}

This changed the storage cost from one integer per bucket to one member per accepted request inside the active window. The cost is bounded by limit * keys plus short-lived rejected candidates, and the TTL removes idle identities without a cleanup worker. The gain is that every instance evaluates the same moving window instead of guessing from local time buckets.