Skip to content

Instantly share code, notes, and snippets.

@lukemerrett
Created April 7, 2026 12:22
Show Gist options
  • Select an option

  • Save lukemerrett/e66cbc3a29b0f411162db11e726b001c to your computer and use it in GitHub Desktop.

Select an option

Save lukemerrett/e66cbc3a29b0f411162db11e726b001c to your computer and use it in GitHub Desktop.
Redis Memory Diagnosis Script — analyze memory usage, key prefix distribution, TTL coverage, and big keys on any Redis instance
#!/usr/bin/env python3
"""
Redis Memory Diagnosis Script
Connects to a Redis instance and produces a comprehensive memory analysis:
- Memory summary (used, peak, RSS, fragmentation, eviction stats)
- Replication topology (master/replica, connected replicas)
- Keyspace overview (key counts, TTL coverage per DB)
- Key prefix distribution (what categories of keys exist and how much space they use)
- TTL analysis per prefix (which prefixes lack TTLs)
- Estimated memory by prefix (extrapolated from sample to full keyspace)
- Top large keys by memory usage
Designed for Todoist ElastiCache instances but works on any Redis server.
Usage:
python redis_memory_diagnosis.py <host:port>
python redis_memory_diagnosis.py todoist-redis16.iahb8b.ng.0001.use1.cache.amazonaws.com:6379
Options:
--db DB Database number to analyze (default: 0)
--scan-count N Keys to sample for prefix analysis (default: 50000)
--big-key-threshold Bytes threshold for "big key" reporting (default: 102400 / 100KB)
Examples:
python redis_memory_diagnosis.py redis16.example.com:6379
python redis_memory_diagnosis.py redis16.example.com:6379 --scan-count 200000
python redis_memory_diagnosis.py redis16.example.com:6379 --db 1
python redis_memory_diagnosis.py redis16.example.com:6379 --big-key-threshold 51200
"""
from __future__ import annotations
import argparse
import sys
import time
from collections import defaultdict
from redis import Redis
def connect(host: str, port: int, db: int = 0) -> Redis:
# decode_responses=False because some Redis instances store binary keys
return Redis(
host=host,
port=port,
db=db,
socket_connect_timeout=5,
socket_timeout=30,
decode_responses=False,
)
def safe_decode(value: bytes | str) -> str:
"""Decode bytes to string, replacing invalid UTF-8 sequences."""
if isinstance(value, str):
return value
return value.decode("utf-8", errors="replace")
def fmt_bytes(n: float) -> str:
for unit in ("B", "KB", "MB", "GB"):
if abs(n) < 1024:
return f"{n:.1f} {unit}"
n /= 1024
return f"{n:.1f} TB"
def fmt_number(n: int) -> str:
if n >= 1_000_000:
return f"{n / 1_000_000:.1f}M"
if n >= 1_000:
return f"{n / 1_000:.1f}K"
return str(n)
def section_header(title: str) -> None:
print(f"\n{'=' * 70}")
print(f" {title}")
print(f"{'=' * 70}")
def extract_prefix(key: str) -> str:
"""Extract a meaningful prefix from a Redis key.
Tries common separators and takes the first 1-2 segments to group
keys by purpose. Falls back to the raw key for short keys or
a truncated version for long ones.
"""
for sep in (":", "/", "."):
if sep in key:
parts = key.split(sep)
if len(parts) >= 2:
second = parts[1]
# If second part looks like an ID, just use first part
if second.isdigit() or (len(second) > 20 and not second.isalpha()):
return parts[0] + sep + "*"
if len(parts) > 2:
return parts[0] + sep + parts[1] + sep + "*"
return parts[0] + sep + parts[1]
return parts[0] + sep + "*"
return key if len(key) <= 30 else key[:30] + "..."
def print_memory_summary(r: Redis) -> None:
section_header("MEMORY SUMMARY")
mem = r.info("memory")
stats = r.info("stats")
keyspace = r.info("keyspace")
repl = r.info("replication")
clients = r.info("clients")
used = mem.get("used_memory", 0)
maxmem = mem.get("maxmemory", 0)
pct = f"{used / maxmem * 100:.1f}%" if maxmem else "N/A (no maxmemory set)"
print(f" Used memory: {fmt_bytes(used)} / {fmt_bytes(maxmem)} ({pct})")
print(f" Peak memory: {fmt_bytes(mem.get('used_memory_peak', 0))}")
print(f" RSS: {fmt_bytes(mem.get('used_memory_rss', 0))}")
print(f" Dataset: {fmt_bytes(mem.get('used_memory_dataset', 0))}")
print(f" Overhead: {fmt_bytes(mem.get('used_memory_overhead', 0))}")
print(f" Repl backlog: {fmt_bytes(mem.get('mem_replication_backlog', 0))}")
print(f" Clients (normal): {fmt_bytes(mem.get('mem_clients_normal', 0))}")
print(f" Clients (replicas): {fmt_bytes(mem.get('mem_clients_slaves', 0))}")
print(f" AOF buffer: {fmt_bytes(mem.get('mem_aof_buffer', 0))}")
print(f" Fragmentation ratio: {mem.get('mem_fragmentation_ratio', 'N/A')}")
print(f" Allocator: {mem.get('mem_allocator', 'N/A')}")
print()
# Replication
role = repl.get("role", "unknown")
print(f" Role: {role}")
if role == "master":
print(f" Connected replicas: {repl.get('connected_slaves', 0)}")
elif role == "slave":
print(f" Master: {repl.get('master_host', '?')}:{repl.get('master_port', '?')}")
print(f" Link status: {repl.get('master_link_status', '?')}")
print()
# Stats
print(f" Evicted keys: {fmt_number(stats.get('evicted_keys', 0))}")
print(f" Expired keys: {fmt_number(stats.get('expired_keys', 0))}")
print(f" Keyspace hits: {fmt_number(stats.get('keyspace_hits', 0))}")
print(f" Keyspace misses: {fmt_number(stats.get('keyspace_misses', 0))}")
print(f" Connected clients: {clients.get('connected_clients', '?')}")
print()
# Keyspace
if not keyspace:
print(" (no databases with keys)")
for db_name, db_stats in sorted(keyspace.items()):
total = db_stats["keys"]
with_ttl = db_stats["expires"]
no_ttl = total - with_ttl
avg_ttl_ms = db_stats.get("avg_ttl", 0)
avg_ttl_human = f"{avg_ttl_ms / 1000 / 3600:.1f} hours" if avg_ttl_ms else "N/A"
no_ttl_pct = f"{no_ttl / total * 100:.1f}%" if total else "0%"
print(
f" {db_name}: {fmt_number(total)} keys total, "
f"{fmt_number(with_ttl)} with TTL, "
f"{fmt_number(no_ttl)} WITHOUT TTL ({no_ttl_pct}), "
f"avg TTL: {avg_ttl_human}"
)
def analyze_key_prefixes(
r: Redis, db: int, scan_limit: int, big_key_threshold: int
) -> None:
section_header(f"KEY PREFIX ANALYSIS (DB {db}, sampling {fmt_number(scan_limit)} keys)")
prefix_stats: dict[str, dict] = defaultdict(
lambda: {
"count": 0,
"with_ttl": 0,
"without_ttl": 0,
"total_memory": 0,
"types": defaultdict(int),
}
)
big_keys: list[tuple[str, str, int, int]] = []
scanned = 0
cursor = 0
start_time = time.time()
last_progress = 0
while scanned < scan_limit:
cursor, keys = r.scan(cursor=cursor, count=500)
if not keys:
if cursor == 0:
break
continue
pipe = r.pipeline(transaction=False)
for key in keys:
pipe.type(key)
pipe.ttl(key)
pipe.memory_usage(key, samples=0)
results = pipe.execute()
for i, raw_key in enumerate(keys):
key = safe_decode(raw_key)
key_type = safe_decode(results[i * 3])
ttl = results[i * 3 + 1]
mem = results[i * 3 + 2] or 0
prefix = extract_prefix(key)
stats = prefix_stats[prefix]
stats["count"] += 1
stats["total_memory"] += mem
stats["types"][key_type] += 1
if ttl == -1:
stats["without_ttl"] += 1
else:
stats["with_ttl"] += 1
if mem >= big_key_threshold:
big_keys.append((key, key_type, mem, ttl))
scanned += 1
if scanned >= scan_limit:
break
if scanned - last_progress >= 10000:
elapsed = time.time() - start_time
rate = scanned / elapsed if elapsed > 0 else 0
print(f" ... scanned {fmt_number(scanned)} keys ({rate:.0f} keys/sec)")
last_progress = scanned
if cursor == 0:
break
elapsed = time.time() - start_time
print(f" Scanned {fmt_number(scanned)} keys in {elapsed:.1f}s\n")
if not prefix_stats:
print(" No keys found in sample.")
return
sorted_prefixes = sorted(
prefix_stats.items(), key=lambda x: x[1]["total_memory"], reverse=True
)
# Top prefixes by memory
print(
f" {'Prefix':<45} {'Keys':>10} {'Memory':>12} "
f"{'No TTL':>10} {'No TTL%':>8} {'Types'}"
)
print(f" {'-' * 45} {'-' * 10} {'-' * 12} {'-' * 10} {'-' * 8} {'-' * 20}")
for prefix, stats in sorted_prefixes[:40]:
no_ttl_pct = (
f"{stats['without_ttl'] / stats['count'] * 100:.0f}%"
if stats["count"]
else "0%"
)
types_str = ", ".join(
f"{t}:{c}"
for t, c in sorted(stats["types"].items(), key=lambda x: -x[1])
)
print(
f" {prefix:<45} {stats['count']:>10} "
f"{fmt_bytes(stats['total_memory']):>12} "
f"{stats['without_ttl']:>10} {no_ttl_pct:>8} {types_str}"
)
# Top prefixes by no-TTL count
sorted_by_no_ttl = sorted(
prefix_stats.items(), key=lambda x: x[1]["without_ttl"], reverse=True
)
has_no_ttl = any(s["without_ttl"] > 0 for _, s in sorted_by_no_ttl)
if has_no_ttl:
section_header(f"TOP PREFIXES BY NO-TTL KEY COUNT (DB {db})")
print(
f" {'Prefix':<45} {'No TTL':>10} {'Total':>10} "
f"{'No TTL%':>8} {'Memory':>12}"
)
print(f" {'-' * 45} {'-' * 10} {'-' * 10} {'-' * 8} {'-' * 12}")
for prefix, stats in sorted_by_no_ttl[:30]:
if stats["without_ttl"] == 0:
break
no_ttl_pct = f"{stats['without_ttl'] / stats['count'] * 100:.0f}%"
print(
f" {prefix:<45} {stats['without_ttl']:>10} "
f"{stats['count']:>10} "
f"{no_ttl_pct:>8} {fmt_bytes(stats['total_memory']):>12}"
)
# Extrapolated totals
keyspace = r.info("keyspace")
db_key = f"db{db}"
if db_key in keyspace and scanned > 0:
total_keys = keyspace[db_key]["keys"]
scale = total_keys / scanned
section_header(
f"ESTIMATED MEMORY BY PREFIX (extrapolated to {fmt_number(total_keys)} keys)"
)
print(
f" Sample: {fmt_number(scanned)} keys, "
f"Total: {fmt_number(total_keys)} keys, "
f"Scale factor: {scale:.1f}x\n"
)
print(
f" {'Prefix':<45} {'Est. Keys':>12} "
f"{'Est. Memory':>14} {'Est. No-TTL':>12}"
)
print(f" {'-' * 45} {'-' * 12} {'-' * 14} {'-' * 12}")
for prefix, stats in sorted_prefixes[:25]:
est_keys = int(stats["count"] * scale)
est_mem = stats["total_memory"] * scale
est_no_ttl = int(stats["without_ttl"] * scale)
print(
f" {prefix:<45} {fmt_number(est_keys):>12} "
f"{fmt_bytes(est_mem):>14} {fmt_number(est_no_ttl):>12}"
)
# Big keys
if big_keys:
section_header(f"BIG KEYS (>{fmt_bytes(big_key_threshold)}) FOUND IN DB {db}")
big_keys.sort(key=lambda x: x[2], reverse=True)
print(
f" {'Key':<60} {'Type':<8} {'Memory':>12} {'TTL':>10}"
)
print(f" {'-' * 60} {'-' * 8} {'-' * 12} {'-' * 10}")
for key, ktype, mem, ttl in big_keys[:50]:
display_key = key if len(key) <= 58 else key[:55] + "..."
ttl_str = "NO TTL" if ttl == -1 else f"{ttl}s"
print(
f" {display_key:<60} {ktype:<8} "
f"{fmt_bytes(mem):>12} {ttl_str:>10}"
)
else:
print(
f"\n No keys >{fmt_bytes(big_key_threshold)} found in DB {db} sample."
)
def parse_endpoint(endpoint: str) -> tuple[str, int]:
if ":" in endpoint:
h, p = endpoint.rsplit(":", 1)
return h, int(p)
return endpoint, 6379
def main() -> None:
parser = argparse.ArgumentParser(
description="Analyze memory usage of a Redis instance.",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog=__doc__,
)
parser.add_argument(
"endpoint",
help="Redis endpoint as host:port (e.g. redis.example.com:6379)",
)
parser.add_argument(
"--db",
type=int,
default=0,
help="Database number to analyze (default: 0)",
)
parser.add_argument(
"--scan-count",
type=int,
default=50000,
help="Number of keys to sample for prefix analysis (default: 50000)",
)
parser.add_argument(
"--big-key-threshold",
type=int,
default=100 * 1024,
help="Bytes threshold for big key reporting (default: 102400 / 100KB)",
)
args = parser.parse_args()
host, port = parse_endpoint(args.endpoint)
print(f"Connecting to {host}:{port} (db{args.db})...")
r = connect(host, port, db=args.db)
try:
r.ping()
except Exception as e:
print(f"ERROR: Cannot connect to {host}:{port}: {e}")
sys.exit(1)
print(
f"Connected. Scan limit: {fmt_number(args.scan_count)} keys, "
f"big key threshold: {fmt_bytes(args.big_key_threshold)}"
)
print_memory_summary(r)
analyze_key_prefixes(
r,
db=args.db,
scan_limit=args.scan_count,
big_key_threshold=args.big_key_threshold,
)
section_header("DONE")
print(" Next steps based on results:")
print(" 1. Identify prefixes with high no-TTL counts — these are memory growth drivers")
print(" 2. Trace prefixes back to application code to understand what's writing them")
print(" 3. Check the eviction policy in AWS console (Parameter Group)")
print(" 4. If evictions are happening, the instance may need scaling up")
print(" 5. If no-TTL keys dominate, add TTLs or run cleanup scripts")
print()
r.close()
if __name__ == "__main__":
main()
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment