Redis 键是平面键空间中的二进制安全名称,不是文件路径,也不是数据库表结构声明。冒号可以按约定分隔业务归属、环境、实体和标识,例如 cart:prod:string:42 能表达意图。但 Redis 不会把这些字段解释成类型检查或访问限制,不能仅凭名字就推断服务端已经替应用建立了约束。
让约定没有歧义
写入方、读取方、维护任务和迁移逻辑,应采用相同字段顺序与标识编码。不同前缀可以让两个服务分别存储同一个业务编号;若最后编码出来的完整名字相同,仍然会碰撞。命名规范帮助人和代码协作,并没有创建由 Redis 独立管理的命名空间。
类型标签也只是说明。SET 可以在名字带 hash 的键下写入字符串,之后 HSET 就会报 WRONGTYPE。排查时应用 TYPE 查看真实数据类型,序列化格式与类型要求则属于应用契约。不能把名字中的“列表”“哈希”当作当前数据已经符合预期的证据。
名字宜简短清晰,不必压缩成难懂缩写,也不要把整段文档塞进去。键名占内存并随命令传输,但对象开销和负载本身同样重要;没有测量就不能宣称一次改名显著加快查询。标识编码还应避免分隔符、通配符和集群花括号取得本来不希望赋予的含义。
模式匹配不等于模式删除
SCAN MATCH 接受 glob 模式。从游标零开始,把每次返回的新游标用于下一次调用,直到再次返回零。COUNT 是工作量提示,不保证精确返回多少条,也不是严格内存上限。某一页可能没有匹配项,但游标还不为零,不能据此提前结束。每次调用仍需要服务端工作,增量扫描不等于完全没有阻塞成本。
完整迭代保证能看到在迭代全过程始终存在、且符合模式的键,但可能重复返回;迭代中新增或删除的键没有同样保证。应用应按需要去重,或让处理逻辑可以安全重复。扫描不是快照,更不能把一个空页面当作集合已经遍历完毕。
DEL 的语义不同:它接收精确键名,不接收模式。参数末尾带星号,不会删除所有同前缀兄弟键;只有真的存在名字包含这个星号的键,才会删除那一个。命令行工具之外还有 shell 展开这一层,应正确引用参数,不要把终端的展开行为误认为 Redis 自己支持通配删除。
坏掉的样子
维护脚本若假定 DEL 会展开前缀,可能实际什么也没删,却报告清理完成。相反,先用宽泛模式收集 SCAN 结果,再无条件删除每个匹配,也可能误伤不属于自己的数据。必须核对精确目标与修改策略;而且发现与删除之间,同名键可能已经被重建,之前审阅过的名字不一定还指向同一个逻辑对象。
前缀也不是安全边界。访问限制应通过合适的 ACL 键模式和命令权限落实,必要时使用独立部署边界。键模式权限无法约束不携带键参数、直接作用于整个数据库的命令,所以给账户一个前缀模式不等于万事安全。服务名前缀也不会自动预留内存,更不构成淘汰策略隔离。
Redis Cluster 的哈希标签是命名约定之外一个会影响路由的明确机制。有效花括号标签决定槽计算所使用的字节;即便两个服务前缀不同,只要标签相同也能落在同一槽。这可以服务于有关联的多键操作,但若给所有键套上同一个固定标签,就可能把负载集中起来,而不是均匀分散。
快速验证
原有实验记录使用 macOS、Python 3.14.4、Redis 8.10.1 和 redis-py 8.1.0;本轮审阅没有重新执行服务实验,下面的结果说明保留原有观察范围。准备 POSIX 环境、C 编译器、make、curl、tar 与 Python 3.10 或更新版本。安装步骤仅用 libc 构建 Redis 核心,并把客户端装入临时虚拟环境。
redis_lab=$(mktemp -d)
curl --fail --location --output "$redis_lab/redis.tar.gz" https://download.redis.io/releases/redis-8.10.1.tar.gz
tar -xzf "$redis_lab/redis.tar.gz" -C "$redis_lab"
make -C "$redis_lab/redis-8.10.1" build redis -j2 MALLOC=libc
export REDIS_SERVER="$redis_lab/redis-8.10.1/src/redis-server"
python3 -m venv "$redis_lab/venv"
"$redis_lab/venv/bin/python" -m pip install redis==8.1.0
在同一终端用 "$redis_lab/venv/bin/python" 运行下方代码。它在 /tmp 启动只用私有 Unix 套接字的 Redis,关闭 TCP 和持久化,结束时仅停止自己的进程并清理运行目录。下载需要联网,实验不会连接已有 Redis 实例。
from contextlib import contextmanager
import os
from pathlib import Path
import subprocess
from tempfile import TemporaryDirectory
import time
import redis
from redis.backoff import NoBackoff
from redis.retry import Retry
@contextmanager
def instance():
with TemporaryDirectory(prefix="redis-lab-", dir="/tmp") as directory:
endpoint = str(Path(directory) / "redis.sock")
process = subprocess.Popen([
os.environ["REDIS_SERVER"], "--port", "0",
"--unixsocket", endpoint, "--unixsocketperm", "700",
"--save", "", "--appendonly", "no", "--dir", directory,
"--maxmemory-policy", "noeviction",
], stdout=subprocess.DEVNULL, stderr=subprocess.PIPE)
client = redis.Redis(unix_socket_path=endpoint, decode_responses=False,
socket_timeout=2, retry=Retry(NoBackoff(), 0))
try:
deadline = time.monotonic() + 5
while True:
assert process.poll() is None, "Redis exited during startup"
try:
if client.ping():
break
except redis.ConnectionError:
if time.monotonic() >= deadline:
raise
time.sleep(0.02)
yield client
finally:
client.close()
if process.poll() is None:
process.terminate()
try:
_, errors = process.communicate(timeout=5)
except subprocess.TimeoutExpired:
process.kill()
process.communicate()
raise
assert process.returncode == 0, errors.decode()
with instance() as client:
client.set("cart:prod:string:42", "cart-value")
client.set("billing:prod:string:42", "billing-value")
assert client.get("cart:prod:string:42") == b"cart-value"
assert client.get("billing:prod:string:42") == b"billing-value"
client.set("cart:prod:hash:42", "not-a-hash")
assert client.type("cart:prod:hash:42") == b"string"
try:
client.hset("cart:prod:hash:42", "item", "value")
except redis.ResponseError as error:
assert "WRONGTYPE" in str(error)
else:
raise AssertionError("a type label unexpectedly enforced a schema")
print("namespaces separate exact names; a hash label does not create a hash value")
prefix = "cart:prod:string:"
expected = {f"{prefix}{number}".encode() for number in range(24)}
for key in expected:
client.set(key, "value")
expected.add(b"cart:prod:string:42")
cursor, found, pages = 0, set(), 0
while True:
cursor, keys = client.scan(cursor=cursor, match=prefix + "*", count=3)
assert all(key.startswith(prefix.encode()) for key in keys)
found.update(keys)
pages += 1
assert pages < 1000, "unexpected nontermination in this bounded test"
if cursor == 0:
break
assert found == expected
print("SCAN reaches cursor zero and finds the stable fixture, with duplicates deduplicated")
assert client.delete(prefix + "*") == 0
assert all(client.exists(key) for key in expected)
client.set(prefix + "*", "literal-star")
assert client.delete(prefix + "*") == 1
assert all(client.exists(key) for key in expected)
print("DEL treats the asterisk literally; it does not expand a key pattern")
from redis.crc import key_slot
assert key_slot(b"cart:{customer-42}:items") == key_slot(b"billing:{customer-42}:total")
assert key_slot(b"cart:{customer-42}:items") == key_slot(b"customer-42")
print("matching hash tags select the same slot across different service prefixes")
第一组在 cart 与 billing 前缀下分别写值,再故意向名字标注 hash 的键写入字符串。TYPE 返回 string,HSET 失败,证明可读标签不会强制建立类型。它们只是不同的完整键名,不是具有目录继承关系的路径。
稳定测试数据包含二十五个匹配的字符串键。扫描循环持续到游标归零,用集合消化可能的重复,也接受页大小变化。测试中的次数上限只是捕获这份小型固定数据上的异常不终止,不是生产通用扫描上限,也没有验证并发增删时的集合完整性。
第一次删除“前缀加字面星号”返回零,二十五个键都还在。创建完全等于那个名称的键后,再删就返回一,其它兄弟键仍保留。实验没有实施通配清理,也没有删除任何共享数据。
最后使用固定版本客户端的 key_slot 计算,确认两个不同前缀、都带 customer-42 标签的键会算出同一个槽。这是在客户端进行的计算,不是实际搭建 Redis Cluster 的路由验收。四条摘要均有断言支撑,但不验证 ACL 强制执行、并发删除安全、集群路由或内存节省。
来源
- Redis 键与值,二进制安全键名及命名约定,已核验 2026-09-08。
- Redis SCAN,游标保证、重复结果与 COUNT 提示,已核验 2026-09-08。
- Redis DEL,精确键参数删除,已核验 2026-09-08。
- Redis TYPE,检查真实值类型,已核验 2026-09-08。
- Redis ACL 文档,键模式与独立的命令权限,已核验 2026-09-08。
- Redis Cluster 规范,哈希标签与槽分布,已核验 2026-09-08。
- redis-py 8.1.0 键槽计算,实验使用的客户端哈希算法,已核验 2026-09-08。
- Redis 8.10.1 构建说明,仅编译核心及选择分配器,已核验 2026-09-08。