在工业边缘场景里,Redis 经常被用来承载设备在线状态、热点配置、告警去重、轻量队列、分布式锁和限流计数。它足够快,但"快"不等于可以随意使用:连接怎么复用、失败怎么重试、消息是否可确认、数据是否可丢,都需要在架构里写清楚。
本文基于 redis-py 5.x 的异步客户端展开,重点覆盖连接生命周期、常用数据结构、Pipeline 与事务边界、Pub/Sub 与 Streams 的选择、Cluster 接入方式和现场运维要点。示例偏工程演示,生产环境还需要结合部署形态、安全策略和资源限制统一验证。
一、先划清 Redis 在边缘链路中的边界
Redis 适合放在"热路径"上,不适合默认承担所有数据职责。
| 数据类型 | 典型特征 | 更合适的路径 |
|---|---|---|
| 设备最新状态 | 高频覆盖写、点查 | Redis Hash / String |
| 热点配置 | 读取多、变更少 | Redis 缓存 + 持久化 DB |
| 告警去重与限流 | 秒级甚至毫秒级判断 | Set / String INCR / 令牌桶 |
| 轻量任务通知 | 在线接收即可 | Pub/Sub |
| 可确认消费的日志 | 需要 pending、重试、ACK | Streams |
| 历史采样与审计 | 长期可靠、可追溯 | 时序库 / 关系库 / 对象存储 |
| 计量与结算数据 | 强持久化、可对账 | DB 事务 + Outbox |
一个简单原则是:Redis 保存热状态和协调信息,关键账本仍应落在明确持久化的数据库中。如果现场断电、Redis 持久化策略不满足要求,或数据需要长期审计,就不要把 Redis 当作唯一系统记录。
二、依赖与版本基线
建议使用 Python 3.10+ 和 redis-py 5.x。老版本 API 与异步行为差异较大,尤其要避免在同一项目里混用同步客户端、老版异步写法和新版生命周期管理。
bash
python -m pip install "redis>=5.2,<6"
如果项目使用 pyproject.toml,应把依赖固定到兼容区间,并在 CI 中覆盖最低支持版本和当前常用版本,避免升级后出现连接清理或 Cluster 参数不兼容的问题。
三、创建进程级异步客户端
异步程序里最常见的错误是每次请求都新建连接。TCP 握手、认证、TLS 握手都会被重复执行,连接泄漏和延迟抖动也会随之出现。正确做法通常是:一个进程共享一个客户端,让客户端内部复用连接池。
python
import os
from redis.asyncio import Redis
from redis.backoff import ExponentialBackoff
from redis.retry import Retry
REDIS_URL = os.getenv("REDIS_URL", "redis://127.0.0.1:6379/0")
def create_redis() -> Redis:
return Redis.from_url(
REDIS_URL,
decode_responses=True,
max_connections=50,
socket_connect_timeout=1.0,
socket_timeout=2.0,
health_check_interval=30,
retry=Retry(ExponentialBackoff(cap=0.2, base=0.008), retries=2),
auto_close_connection_pool=True,
)
几点说明:
decode_responses=True后,返回值是字符串,不是 bytes;数字仍需要显式转换。max_connections不是越大越好,边缘程序还要给采集、解析和上行队列留内存。socket_timeout覆盖读写等待,socket_connect_timeout覆盖建连等待,二者语义不同。health_check_interval有助于发现长期空闲连接的异常。- 重试只用于瞬时连接错误,业务命令是否能重放要由调用方判断。
auto_close_connection_pool=True明确客户端关闭时同时释放自己创建的连接池,归属更清晰。
应用生命周期
python
async def main() -> None:
redis = create_redis()
try:
await redis.ping()
value = await redis.get("device:001:online")
print(value)
finally:
await redis.aclose()
redis-py 5.x 推荐使用 aclose()。一些旧版本使用 close(),项目内应统一同一种写法,避免部分路径关闭、部分路径泄漏。
如果使用 FastAPI,可以在应用启动时创建客户端并保存到 app.state,在 shutdown 事件中统一关闭;不要在业务请求处理函数里反复创建客户端。
四、基础读写与设备状态建模
1. String:状态、计数和简单锁
python
async def mark_device_online(redis: Redis, device_id: str, ttl: int = 60) -> bool:
return await redis.set(
f"device:{device_id}:online",
"1",
ex=ttl,
nx=True,
)
NX 表示不存在才写入,可以用来抢占状态或实现简单锁。若把锁用于关键动作,还必须考虑锁过期、持有时长、误释放和业务 fencing token,不能只依赖一个 SET NX。
计数示例:
python
async def increase_event_count(redis: Redis, device_id: str) -> int:
return await redis.incr(f"device:{device_id}:event_count")
INCR 返回的是整数。但如果把数值写入 Hash 或 JSON,再读出来时通常已经是字符串,必须显式转换。
2. Hash:设备快照
python
from dataclasses import dataclass
@dataclass(frozen=True)
class DeviceSnapshot:
device_id: str
voltage: float
online: bool
updated_at: int
async def save_device_snapshot(redis: Redis, snapshot: DeviceSnapshot) -> None:
await redis.hset(
f"device:{snapshot.device_id}:snapshot",
mapping={
"voltage": str(snapshot.voltage),
"online": "1" if snapshot.online else "0",
"updated_at": str(snapshot.updated_at),
},
)
async def load_device_snapshot(
redis: Redis,
device_id: str,
) -> DeviceSnapshot | None:
data = await redis.hgetall(f"device:{device_id}:snapshot")
if not data:
return None
return DeviceSnapshot(
device_id=device_id,
voltage=float(data["voltage"]),
online=data["online"] == "1",
updated_at=int(data["updated_at"]),
)
Hash 适合保存一个设备的多个字段的最新值。若字段之间有强一致事务语义,例如"扣减库存且写入审计记录",应使用数据库事务或 Redis 事务明确设计,而不是分散成多条独立命令。
五、集合与有序集合的典型用法
1. Set:标签与告警去重
python
async def add_device_tags(redis: Redis, device_id: str, tags: set[str]) -> None:
if tags:
await redis.sadd(f"device:{device_id}:tags", *tags)
async def has_critical_tag(redis: Redis, device_id: str) -> bool:
return await redis.sismember(f"device:{device_id}:tags", "critical")
去重窗口如果需要自动过期,可以用 SET key value EX ttl NX,或者把时间窗口编码进 key。Set 本身不会让成员按时间过期。
2. ZSet:按时间维护事件索引
python
async def append_alert(
redis: Redis,
device_id: str,
alert_id: str,
occurred_at_ms: int,
) -> None:
await redis.zadd(
f"device:{device_id}:alerts",
{alert_id: occurred_at_ms},
)
async def recent_alerts(
redis: Redis,
device_id: str,
start_ms: int,
end_ms: int,
limit: int = 100,
) -> list[str]:
return await redis.zrangebyscore(
f"device:{device_id}:alerts",
min=start_ms,
max=end_ms,
start=0,
num=limit,
)
ZSet 便于按分数范围查询,但不是完整的事件系统。事件需要确认、重试、死信和回放时,应优先考虑 Streams 或专业消息系统。
六、Pipeline:减少往返,而不是天然原子
Pipeline 会把命令攒起来一次性发送,核心收益是减少网络往返。
python
async def save_many_snapshots(
redis: Redis,
snapshots: list[DeviceSnapshot],
) -> None:
async with redis.pipeline(transaction=False) as pipe:
for snapshot in snapshots:
pipe.hset(
f"device:{snapshot.device_id}:snapshot",
mapping={
"voltage": str(snapshot.voltage),
"online": "1" if snapshot.online else "0",
"updated_at": str(snapshot.updated_at),
},
)
await pipe.execute()
transaction=False 时,其他客户端命令可能穿插执行。如果需要这批命令作为一个事务提交,应使用 transaction=True,并明确 Redis 事务的语义:
MULTI后命令进入队列,EXEC时顺序执行;EXEC后命令失败不会导致已排队命令整体回滚;- 乐观并发控制依赖
WATCH; - 执行期间键被其他客户端修改时,可能触发
WatchError。
七、WATCH + MULTI:可重试的事务
下面的积分转移示例展示的是乐观锁写法:先读取余额,再在事务中提交变更。如果期间余额被其他任务修改,则放弃本次执行并重试。
python
from redis.exceptions import WatchError
async def transfer_points(
redis: Redis,
from_account: str,
to_account: str,
amount: int,
max_attempts: int = 5,
) -> bool:
from_key = f"account:{from_account}:points"
to_key = f"account:{to_account}:points"
for _ in range(max_attempts):
try:
async with redis.pipeline(transaction=True) as pipe:
await pipe.watch(from_key)
raw_balance = await pipe.get(from_key)
if raw_balance is None:
await pipe.unwatch()
raise KeyError(f"account not found: {from_account}")
if int(raw_balance) < amount:
await pipe.unwatch()
return False
pipe.multi()
pipe.decrby(from_key, amount)
pipe.incrby(to_key, amount)
await pipe.execute()
return True
except WatchError:
continue
raise RuntimeError("too many concurrent transfers")
这不是数据库事务的替代品。对必须同时写 Redis 和 DB 的操作,常见做法是先落 DB 事务,再通过版本号、失效标记或 Outbox 处理 Redis 缓存,避免两个系统之间出现无法恢复的半提交状态。
八、Pub/Sub:只做在线通知
Pub/Sub 的语义是"发给当前订阅者"。客户端断线、订阅前发生的事件、无人订阅时的事件,都不会被自动补发。
python
async def publish_device_event(
redis: Redis,
device_id: str,
event: str,
) -> None:
await redis.publish(f"device:{device_id}:events", event)
async def subscribe_device_events(redis: Redis, device_id: str) -> None:
async with redis.pubsub(ignore_subscribe_messages=True) as pubsub:
await pubsub.subscribe(f"device:{device_id}:events")
async for message in pubsub.listen():
if message["type"] == "message":
print(message["channel"], message["data"])
适合用途:
- 通知在线进程刷新配置;
- 提示界面立即刷新;
- 作为低价值的实时事件广播。
不适合用途:
- 关键指令下发;
- 需要确认执行的任务;
- 断线后必须补投的事件。
这些场景应使用 Streams,或使用具备完整投递语义的消息中间件。
九、Streams:可确认的消费组
Streams 会保留事件,并支持消费组、pending 列表和 ACK。工业边缘中,它适合承载"设备事件本地缓冲""控制命令执行日志""采集异常事件"等需要确认和重试的数据。
1. 写入事件
python
async def add_device_stream_event(
redis: Redis,
device_id: str,
event: str,
payload: str,
) -> str:
return await redis.xadd(
"device:events",
{
"device_id": device_id,
"event": event,
"payload": payload,
},
)
2. 创建消费组
python
from redis.exceptions import ResponseError
async def ensure_consumer_group(redis: Redis) -> None:
try:
await redis.xgroup_create(
"device:events",
"edge-workers",
id="$",
mkstream=True,
)
except ResponseError as exc:
if "BUSYGROUP" not in str(exc):
raise
id="$" 表示只消费创建之后写入的事件。如果服务重启后必须处理历史消息,应显式使用 id="0-0",或按业务水位选择起点,不要把二者混为一谈。
3. 消费并确认
python
async def consume_device_events(redis: Redis, consumer_name: str) -> None:
while True:
messages = await redis.xreadgroup(
"edge-workers",
consumer_name,
{"device:events": ">"},
count=20,
block=5_000,
)
for _stream_name, entries in messages or []:
for message_id, fields in entries:
await process_device_event(fields)
await redis.xack("device:events", "edge-workers", message_id)
ACK 应放在处理成功之后。若处理失败,消息会留在 pending 列表,由恢复逻辑重新认领。为了保证重复消费不造成副作用,业务处理应尽量设计为幂等,例如使用事件 ID、设备 ID 和业务版本号去重。
4. 恢复 pending 消息
python
async def reclaim_stale_messages(
redis: Redis,
consumer_name: str,
min_idle_ms: int = 60_000,
) -> None:
result = await redis.xautoclaim(
"device:events",
"edge-workers",
consumer_name,
min_idle_time=min_idle_ms,
start_id="0-0",
count=20,
)
entries = result[1]
for message_id, fields in entries or []:
await process_device_event(fields)
await redis.xack("device:events", "edge-workers", message_id)
min_idle_ms 要大于正常处理耗时的上限。如果设置过小,慢任务可能被多个消费者重复执行。现场还应监控 pending 数量、最老 pending 消息的停留时间和消费组 lag。
十、异步 Cluster 接入
redis-py 5.x 的异步 Cluster 客户端要求 startup_nodes 使用 ClusterNode 对象,不能使用字典。
python
from redis.asyncio.cluster import ClusterNode, RedisCluster
cluster = RedisCluster(
startup_nodes=[
ClusterNode("127.0.0.1", 7000),
ClusterNode("127.0.0.1", 7001),
],
decode_responses=True,
socket_timeout=2.0,
socket_connect_timeout=1.0,
)
Cluster 模式下还需要注意:
- key 按 hash slot 分布在不同节点;
- 多 key 命令和事务要求相关 key 位于同一 slot;
- 可用 hash tag 让相关 key 落在同一 slot,例如
device:{dev001}:snapshot; - hash tag 会限制数据分散度,只应用于确实需要的业务关联 key;
- 大 key、热点 key 和批量扫描在 Cluster 中更容易放大影响。
不要先按单机写法完成业务,再简单替换连接字符串迁移到 Cluster。路由、事务、Lua 脚本和批量命令都应提前纳入测试。
十一、安全与部署基线
边缘环境中的 Redis 经常与业务程序同机或同网段部署,更容易被旁路访问。安全配置应至少包含:
| 项目 | 建议 |
|---|---|
| 网络 | 仅监听业务网络,避免暴露到公网 |
| 认证 | 使用 ACL 最小权限账号,密码来自环境变量或密钥管理 |
| 传输 | 跨网段或跨主机访问优先启用 TLS |
| 命令权限 | 按客户端只授予所需命令 |
| 危险命令 | 严格控制 FLUSHALL、CONFIG、DEBUG 等能力 |
| 资源 | 限制内存、连接数和客户端输出缓冲 |
| 审计 | 记录关键配置变更和异常连接 |
不要在代码仓库、镜像环境变量或日志中保存明文凭据。
十二、监控与告警指标
Redis 接入后,至少监控以下指标:
| 类别 | 指标 | 关注点 |
|---|---|---|
| 连接 | 连接数、拒绝连接、池等待 | 连接泄漏、池配置过小 |
| 延迟 | 命令耗时、慢命令 | 大 key、热 key、阻塞命令 |
| 内存 | used_memory、碎片率、淘汰策略 | OOM 与数据被静默淘汰 |
| 持久化 | RDB/AOF 状态、最近落盘时间 | 与数据丢失窗口匹配 |
| Streams | lag、pending、最老 pending 时间 | 消费停滞 |
| 主从 / Cluster | 复制延迟、槽状态、故障转移 | 可用性与读一致性 |
常用检查命令:
bash
redis-cli --latency
redis-cli --bigkeys
redis-cli info clients
redis-cli info memory
redis-cli info persistence
redis-cli xinfo groups device:events
告警不要只看 Redis 自身。把"采集速率""消费速率""补传队列""上行成功率"和 Redis 指标放在一起,才能判断问题发生在现场设备、边缘程序还是中心服务。
十三、常见坑与修正
1. 每次请求新建 Redis 客户端
现象:连接数抖动、延迟升高、文件句柄耗尽。
修正:进程内共享客户端和连接池,并在应用关闭时调用 aclose()。
2. 把 Pipeline 当成事务
现象:认为批量命令一定原子,出现中间状态。
修正:普通 Pipeline 只减少往返;需要原子提交时使用 transaction=True,并处理 WatchError。
3. Pub/Sub 断线丢事件
现象:客户端重连后没有收到离线期间的消息。
修正:这是 Pub/Sub 的正常语义。关键事件使用 Streams 或消息队列,并记录消费水位。
4. 先 ACK 再处理失败
现象:业务处理失败但消息被确认,事件丢失。
修正:处理成功后 ACK,失败留给 pending 恢复机制,并保证处理幂等。
5. 忽略 Cluster hash slot
现象:多 key 命令在 Cluster 上报错。
修正:评估 key 是否需要 hash tag,重新设计业务分组和访问路径。
6. 把 Redis 当唯一持久化存储
现象:掉电或故障后关键账本无法恢复。
修正:明确 RDB/AOF 能接受的丢失窗口;不满足要求的数据落到 DB、时序库或日志存储。
十四、落地清单
上线前可以按这张清单快速检查:
- 客户端是否进程级共享,并在关闭时释放连接;
- 超时、重试、异常分类是否明确;
- 是否区分可重试和不可重试命令;
- Pipeline 与事务语义是否正确;
- Pub/Sub 是否只用于在线通知;
- Streams 是否有 ACK、pending 恢复和幂等处理;
- Cluster key 设计是否经过验证;
- 内存上限和淘汰策略是否符合业务预期;
- 慢命令、大 key、连接池、pending 是否进入监控;
- 关键数据是否有 DB 或专用存储兜底。
十五、TL;DR
- Redis 适合热状态、协调信息和轻量流数据,不应默认承担全部持久化职责。
- redis-py 5.x 异步客户端推荐
Redis.from_url()+ 进程级共享连接池。 - 关闭客户端使用
aclose(),并明确连接池归属。 - Pipeline 减少网络往返;事务需要
WATCH + MULTI + EXEC和重试处理。 - Pub/Sub 是在线广播,不保证离线补投。
- Streams 提供 pending 与 ACK,适合可确认消费的边缘事件。
- Cluster 使用
ClusterNode,并要提前验证多 key 命令和 hash slot。 - 监控要覆盖连接、内存、慢命令、持久化和 Streams 积压。
十六、下一步建议
- 为不同数据划定 Redis、DB 和时序库的边界。
- 统一异步客户端生命周期与超时重试策略。
- 把高频读写路径改为 Pipeline 或批量提交。
- 为 Streams 补齐 ACK、pending 恢复和幂等测试。
- 建立内存、连接、慢命令和消费积压的日常观测。
在 Zenova EdgeOS 的工业边缘运行链路中,Redis 连接池水位、慢命令、Streams 积压和消费确认状态可以纳入统一的运行时观测与告警体系,帮助现场及时发现采集阻塞或消费停滞。