ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

redis-py服务控制与状态监控实战:从辅助函数到巡检脚本

redis-py服务控制与状态监控实战:从辅助函数到巡检脚本 第六篇了。前面几篇我把 redis-py 的字符串、哈希、列表、集合、管道、事务这些业务命令都过了一遍。按理说这种时候最该静下心来写的反而是那些“不产生业务数据”的命令——服务控制与状态监控。原因很简单很多人用 redis-py 写业务代码写得飞起真到了线上报警、实例卡顿、内存告警的时候只剩两种姿势——掏出 Redis Desktop Manager 点点点或者抱着 redis-cli 一条条敲。这不是技术能力问题是你手里没有一套属于自己团队的辅助函数库没有把 Redis 的服务控制与状态监控能力沉淀成可复用的代码。这篇文章我就把 redis-py 里跟服务生命周期、实例级数据操作、状态信息采集、慢查询诊断、内存评估相关的辅助函数全部拆开讲。重点不是罗列文档而是告诉你每个操作背后有哪些坑、返回结构长什么样、什么时候该用、什么时候千万别用。最后我会给一个可以直接抄走的巡检脚本骨架落地难度比你想象的小得多。1. 服务控制与状态监控在 redis-py 里的真实位置1.1 不要把它当成 redis-cli 的克隆体很多人写 redis-py 代码时习惯把它当成 redis-cli 的“同款封装”觉得r.info()就是控制台敲INFOr.config_get()就是敲CONFIG GET。方向上没错但理解太粗了。redis-py 在这些管理命令上做了几件 cli 不会替你做的事把服务端返回的文本协议解析成 Python 原生类型。DBSIZE返回的是数字INFO返回的是嵌套 dictCLIENT LIST返回的是 list of dict而不是一坨要自己 split 的字符串。把 Redis 的“错误返回”统一成异常。比如对不存在的 key 执行MEMORY USAGEcli 会返回一个 nilredis-py 直接给你None对没有权限执行的命令直接抛ResponseError。连接层面的细节被隐藏了。你调用bgsave()时不需要关心这条命令该走读连接还是写连接连接池会帮你安排。所以redis-py 的管理类 API 不是简单的命令转发而是一套已经做了类型归一化和异常收敛的接口。我们要做的是在这之上再包一层让它更贴近自己的运维场景。1.2 值得封装的辅助函数清单先拉一张总表后面每一类我都会单独展开。分类主要函数典型用途服务生命周期save()bgsave()bgrewriteaof()shutdown()持久化、重启、备份实例级数据操作flushdb()flushall()swapdb()select()randomkey()清理数据、切换库、抽样状态采集ping()dbsize()info()lastsave()time()健康检查、巡检、时钟校准配置管理config_get()config_set()config_rewrite()config_resetstat()在线调参、配置持久化慢查询与连接slowlog_get()slowlog_len()slowlog_reset()client_list()client_kill()client_getname()慢命令定位、连接治理内存诊断memory_usage()memory_stats()memory_doctor()大 key 排查、碎片率评估这张表整体呈现出 redis-py 管理面 API 的全貌下面我会一层一层拆开讲。你会发现真正麻烦的不是“函数怎么调”而是“返回值怎么用”“边界条件怎么处理”。2. 服务生命周期控制save、bgsave、shutdown 的封装与坑2.1 save 和 bgsave 的真实选择逻辑SAVE是同步持久化Redis 主进程会阻塞直到 RDB 文件写完才恢复对外服务。BGSAVE是 fork 一个子进程去写 RDB主进程继续服务请求。从 redis-py 的角度看两个方法都返回True看起来人畜无害但生产环境绝对不能乱调save()。我见过一个真实案例有人写了个定时任务每天凌晨用redis-py调一次save()做备份理由是“这样保险”。结果实例在主进程阻塞的几秒里堆积了大量请求超时告警一堆。后来改成了bgsave()问题立刻消失。封装的时候我的建议是def safe_bgsave(client, wait_for_saveFalse, timeout60): 触发后台持久化。 wait_for_saveTrue 时会轮询 lastsave() 直到时间戳变化确认保存完成。 before client.lastsave() ok client.bgsave() if not ok: return False if not wait_for_save: return True deadline time.time() timeout while time.time() deadline: if client.lastsave() before: return True time.sleep(0.5) return False这里有个容易被忽略的点BGSAVE执行之后不能只看返回值。BGSAVE的返回值只能说明命令被接收了子进程是否成功生成 RDB 文件需要通过lastsave()的时间戳变化或者INFO persistence里的rdb_last_bgsave_status来确认。上面的封装用lastsave()做轮询是个轻量可靠的方案。对于 AOF 场景对应的触发函数是bgrewriteaof()。它同样返回True但 AOF 重写可能耗时较长也需要轮询INFO persistence里的aof_last_bgrewrite_status来确认结果。2.2 shutdown客户端必须处理断连shutdown()是所有管理命令里最“危险”的一个。redis-py 的签名是shutdown(nosaveFalse)对应 Redis 原生命令SHUTDOWN和SHUTDOWN NOSAVE。调用之后会发生什么Redis 服务端会关闭连接然后进程退出。这个时候你手里的 redis-py 连接会抛出一个ConnectionError因为服务端把 socket 关了。很多人没意识到这一点封装的时候不做异常处理程序直接崩掉。一个相对安全的重启前准备函数可以这样写def safe_shutdown(client, saveFalse, timeout5): 安全关闭 Redis 实例。 saveTrue 对应 SHUTDOWN SAVEsaveFalse 对应 SHUTDOWN NOSAVE。 if save: try: client.save() except Exception as e: print(fsave failed before shutdown: {e}) try: client.shutdown(nosavenot save) except ConnectionError: # 服务端正常关闭连接这是预期行为 print(connection closed as expected) except Exception as e: print(funexpected error during shutdown: {e})关键点在于把ConnectionError当成预期路径来处理而不是把它打进异常告警里。否则每次正常重启都会收到一条假的“连接错误”报警。2.3 在线调完配置以后记得 config_rewrite说一个我踩过不少次的坑config_set()只改内存配置不改磁盘上的 redis.conf。如果实例重启配置会回到老值。想要持久化必须再调一次config_rewrite()。def apply_config(client, key, value, rewriteTrue): old client.config_get(key) client.config_set(key, value) if rewrite: client.config_rewrite() new client.config_get(key) return {old: old, new: new}这里要注意不是所有配置项都支持CONFIG REWRITE比如requirepass这类安全相关配置Redis 在交互输入时有特殊处理config_rewrite()会拒绝写入。封装的时候建议把ResponseError捕获住不要把整个巡检脚本搞挂。3. 实例级数据操作flushdb、flushall、swapdb、select 的安全边界3.1 flushdb / flushall 的同步刷新与异步刷新flushdb()清当前库flushall()清所有库。这是数据恢复领域最经典的“手滑”操作。redis-py 从 4.x 开始统一了异步参数flushdb(asynchronousFalse)flushall(asynchronousFalse)。在 Redis 6.2 及以上版本设成True会执行FLUSHDB ASYNC/FLUSHALL ASYNC由后台线程释放内存避免主线程卡死。老版本 redis-py 里还有单独的async_flushdb()、async_flushall()方法如果你是升级上来的老项目先看清楚版本别一上来就传asynchronous结果莫名报错。我封装这类“毁灭级”操作时一定会加确认令牌def flush_all(client, token: str, asynchronousFalse): if token ! YES-CLEAR-ALL: raise ValueError(token mismatch, operation aborted) client.flushall(asynchronousasynchronous)在自动化运维平台里这层保护极其重要。宁可让操作者多确认一步也别让自己成为生产事故的制造者。3.2 swapdb 的原子交换swapdb(db1, db2)是我比较偏爱的冷门命令。它原子地交换两个 db 的全部数据时间复杂度 O(1)。在灰度发布、切流场景下很好用新数据先写到 db1验证没问题后把 db1 和 db0 交换实现“热切换”发现问题再 swap 回来几乎无损。redis-py 里的调用方式就是client.swapdb(0, 1)返回True。封装时可以记录交换前后的dbsize()作为审计信息def swap_db(client, db_a, db_b, trackTrue): size_a client.dbsize() if track and db_a 0 else None size_b client.dbsize() if track and db_b 0 else None client.swapdb(db_a, db_b) return {swapped: (db_a, db_b), size_before_swap: {db_a: size_a, db_b: size_b}}别觉得这个封装没用。一旦出问题你至少知道交换前的 db 规模能判断是不是数据量异常导致的。3.3 select 在连接池里的经典陷阱这是很多人栽过跟头的地方。redis-py 里select(db)是存在的但它和你在业务代码里“切库”的直觉不一样。redis-py 的连接池在初始化连接时通过connection_kwargs里的db参数来决定新连接默认选哪个库。如果你用Redis(hostlocalhost, db0)创建客户端然后手动调了一次select(1)会发生什么手动select只对当前从池子里取到的这一条连接生效这条连接归还到连接池之后池子里还可能有其他连接仍然停留在 db 0。你接下来的命令可能走 db 0也可能走 db 1行为完全看连接池的脸色。所以我的结论很直接不要用select()来切换业务库。要访问另一个库就创建一个新的 Redis 实例db 参数传对应的库号连接池可以复用同一个ConnectionPoolpool redis.ConnectionPool(hostlocalhost, port6379) client_db0 redis.Redis(connection_poolpool, db0) client_db1 redis.Redis(connection_poolpool, db1)这样每条命令在取连接时就知道该选哪个库干干净净没有状态残留。4. 状态监控辅助函数info、config_get、dbsize、lastsave 的组合用法4.1 info 的 section 参数与解析策略info()是 Redis 状态监控的核心入口。不带参数时它返回所有 section数据量大网络传输和解析成本都不小带上 section 参数后只返回指定部分效率提升明显。redis-py 4.x/5.x 中info()返回的是按 section 嵌套的 dict取值时要先定位到 sectioninfo_all client.info() # 所有 info_mem client.info(memory) # 只看内存 info_rep client.info(replication) # 只看主从注意新老版本解析结构的差异。老版本里字段可能是扁平的直接info[used_memory_human]就能取到新版本里需要info[Memory][used_memory_human]。如果你接手的是老代码一不小心就 KeyError。我习惯于封装一个带默认值的访问方法def get_info_metric(client, section, key, defaultNone): info client.info(section) return info.get(section, {}).get(key, default)然后就可以这样用used_mem get_info_metric(client, Memory, used_memory_human, unknown) connected_clients get_info_metric(client, Clients, connected_clients, 0)结合热搜词里的“redis缓存治理”日常巡检最该盯的其实是INFO stats里的keyspace_hits和keyspace_misses算一下缓存命中率。低于某个阈值说明缓存设计可能有问题而不是去盲目加容量。4.2 config_get / config_set / config_rewrite 的配置管理组合config_get(pattern)支持通配符。config_get(maxmemory*)能把maxmemory和maxmemory-policy一起查出来。返回值永远是个 dict这是 redis-py 做了解析的结果别当成 list 处理。配置管理的封装里我建议大家至少记录“变更前后对比”def set_config(client, key, value, rewriteTrue): before client.config_get(key) client.config_set(key, value) after client.config_get(key) if rewrite: try: client.config_rewrite() except redis.ResponseError as e: print(fconfig rewrite failed: {e}, will not apply after restart) return {key: key, before: before, after: after}你可能会问为什么config_get(maxmemory)返回的 value 是字符串而不是数字因为协议层面所有配置值都是字符串redis-py 为了保持一致性没有做类型强转。如果你需要比较数值记得自己int()转换否则100mb 90mb这种字符串比较会得出错误结果。4.3 dbsize、lastsave、time 的巡检意义这三个函数在监控里的作用经常被小看。dbsize()返回当前库的 key 总量单位是 int。它是判断数据倾斜、key 增长趋势的第一指标。注意它是精确值不是近似值大实例上执行有瞬时开销巡检频率别太高。lastsave()返回最后一次成功生成 RDB 文件的时间戳Unix 秒。如果这个时间离现在太久说明持久化可能出了问题。拿它跟time()返回的服务端时间做差值就能算出“距离上次持久化已经过去多久”。time()返回服务端当前时间是一个[seconds, microseconds]的列表。用它和客户端本地时间做对比可以判断是否存在时钟偏移。Redis 主从复制、过期 key 清理都对时钟敏感时间不同步会引发一堆诡异问题。组合起来的巡检逻辑大概是server_time client.time() last_save_ts client.lastsave() ts_now server_time[0] server_time[1] / 1000000 staleness ts_now - last_save_ts if staleness 3600: print(fpersistence stale for {staleness}s)5. 慢查询定位与客户端连接排查slowlog_get、client_list 的实战价值5.1 slowlog 的耗时单位是微秒不是毫秒Redis 慢查询日志SLOWLOG GET记录的是超过slowlog-log-slower-than阈值的命令。redis-py 里用slowlog_get(numNone)读取返回一个 list每条记录通常包含这些字段id慢查询记录编号start_timeUnix 秒duration命令执行耗时单位是微秒command命令及其参数组成的列表client_addr、client_name客户端来源最容易踩的坑就是把duration当成毫秒去告警结果阈值设小了告警刷屏。Redis 的 slowlog 单位官方就是微秒1,000,000 微秒才等于 1 秒。如果你习惯看毫秒要自己除以 1000。一段实用的慢查询封装def get_recent_slowlogs(client, limit10, slow_ms100): logs client.slowlog_get(limit) result [] for item in logs: duration_ms item[duration] / 1000.0 if duration_ms slow_ms: result.append({ id: item[id], start_time: item[start_time], duration_ms: round(duration_ms, 2), command: .join( arg.decode() if isinstance(arg, bytes) else str(arg) for arg in item[command] ), client_addr: item.get(client_addr), }) return result正常情况下 Redis 单个命令耗时都是微秒级能进 slowlog 的命令已经值得警惕了。如果频繁出现KEYS、HGETALL大哈希、SMEMBERS大集合说明业务侧代码有问题光加慢查询日志解决不了得回去改数据结构设计。5.2 client_list定位连接来源与闲置连接client_list()返回的是 list of dict每条对应一个客户端连接字段名在不同版本略有差异但常见的addr、name、age、idle、db、cmd、tot_mem基本都有。idle字段表示连接空闲秒数。如果空闲时间非常长说明有连接泄漏或者用了连接池但池大小配得过大。实践中我写过这样的辅助函数def kill_idle_connections(client, max_idle_seconds300, whitelistNone): whitelist whitelist or set() killed [] for conn in client.client_list(): addr conn.get(addr, ) if addr in whitelist: continue idle_sec int(conn.get(idle, 0) or 0) if idle_sec max_idle_seconds: client.client_kill(addr) killed.append(addr) return killed注意client_kill()的参数在不同版本有变化老版本传地址字符串新版本 redis-py 推荐用 filter 方式比如client_kill(filteraddr, addr...)。封装前先确认你用的 redis-py 版本。很多公司排查“连接数被打满”问题时第一反应是扩充maxclients其实更应该先跑一遍client_list()看看是否有大量闲置连接长期占用。对长连接型应用连接池空转问题是常态。5.3 实际排查链路慢命令背后往往是数据结构问题我处理过一次线上实例 CPU 飙高的排查。第一步用slowlog_get(20)拿到最近 20 条慢命令发现大量SMEMBERS操作操作对象是一个大集合。第二步用client_list()定位到这些命令来自一个推荐服务。第三步用memory_usage()评估该 key 的大小果然占了近 300MB。最后是业务侧把集合拆成多个小 keyCPU 立刻回落。整个排查过程没有用到任何黑科技就是围绕 slowlog、client_list、memory_usage 这三个辅助函数反复组合。这也是我在文章开头强调“沉淀自己的辅助函数库”的原因——排查能力的高低其实就是你把这些函数组合起来的能力。6. 内存诊断与容量评估memory_usage、memory_stats 的封装实践6.1 memory_usage 评估单 key 内存memory_usage(key, samples5)是 Redis 4.0 引入的命令用来估算某个 key 占用的内存字节数。samples参数在 key 是集合类型list、set、zset、hash时有效值越大估算越精确开销也越大。返回None表示 key 不存在。注意它返回的是字节数不是人类友好格式。封装时我喜欢顺手转一下def memory_usage_human(client, key, samples5): size client.memory_usage(key, samplessamples) if size is None: return None for unit in (B, KB, MB, GB): if size 1024 or unit GB: return f{size:.2f}{unit} size / 1024配合randomkey()可以做一个“随机抽样”的大 key 扫描def sample_large_keys(client, sample_count1000, threshold_mb10): threshold threshold_mb * 1024 * 1024 large [] for _ in range(sample_count): key client.randomkey() if key is None: break size client.memory_usage(key) if size and size threshold: large.append((key, size)) return large这个方案比KEYS *安全得多但randomkey()在超大库上的随机性足够做初步筛查精细的大 key 治理还是建议用scan_iter()配合memory_usage()并且在业务低峰期执行。6.2 memory_stats 与 info memory 的分工memory_stats()返回的是内存分配的统计信息字段像total.allocated、peak.allocated、startup.allocated、replication.backlog、clients.normal、clients.slaves之类。它和info(memory)的区别在于info memory偏整体运行指标memory_stats偏分配出处分解。实际巡检里我会这样算内存碎片率stats client.memory_stats() total_allocated stats.get(total.allocated, 0) info_mem client.info(memory) used_memory info_mem.get(Memory, {}).get(used_memory, 0) if total_allocated and used_memory: frag_ratio total_allocated / used_memory print(fmemory fragment ratio: {frag_ratio:.2f})碎片率长期高于 1.5 或者低于 0.8都是值得关注的信号。前者说明内存碎片多后者说明可能出现内存异常占用或过度压缩。6.3 大 key 删除的正确姿势很多新人在发现大 key 后第一反应是del这个行为很危险。删除一个几 GB 的 key 时主线程会阻塞线上服务直接抖一下。正确做法是慢慢删除如果 values 是 list/set/hash用ltrim、srem、hdel分批删。Redis 4.0 之后可以直接用UNLINK命令redis-py 里对应unlink(key)它是异步释放内存的不会阻塞主线程。配合记忆里的“redis分布式锁”和“redis面试题”这些背景面试时常考的就是“如何安全删除大 key”。面试官想听到的回答就是UNLINK以及为什么不能直接DEL。这个细节写代码的时候一样适用。7. 把辅助函数组装成巡检脚本一个可落地的 mini 方案7.1 巡检脚本骨架下面这个脚本可以定时执行把 Redis 实例的健康状态落成日志。它把我前面讲到的辅助函数组合成了一个最小可用集合。import redis import json import time from datetime import datetime class RedisHealthCheck: def __init__(self, connection_params, timeout3): self.client redis.Redis( hostconnection_params[host], portconnection_params[port], passwordconnection_params.get(password), dbconnection_params.get(db, 0), socket_connect_timeouttimeout, socket_timeouttimeout, ) def run(self): result { ts: datetime.now().isoformat(), ping: self._ping(), basic: self._basic_info(), memory: self._memory_info(), slowlogs: self._slowlogs(), clients: self._client_stats(), persistence: self._persistence_info(), } return result def _ping(self): try: return self.client.ping() except redis.RedisError: return False def _basic_info(self): info self.client.info(Server) return { redis_version: info.get(Server, {}).get(redis_version), uptime_in_seconds: info.get(Server, {}).get(uptime_in_seconds), } def _memory_info(self): mem self.client.info(Memory) return { used_memory_human: mem.get(Memory, {}).get(used_memory_human), peak_memory_human: mem.get(Memory, {}).get(used_memory_peak_human), maxmemory_human: mem.get(Memory, {}).get(maxmemory_human), } def _slowlogs(self): logs self.client.slowlog_get(5) return [{ duration_ms: round(item[duration] / 1000.0, 2), command: .join( arg.decode() if isinstance(arg, bytes) else str(arg) for arg in item[command] ), } for item in logs] def _client_stats(self): clients self.client.client_list() total len(clients) idle_gt_300 sum( 1 for c in clients if int(c.get(idle, 0) or 0) 300 ) return {total: total, idle_gt_300: idle_gt_300} def _persistence_info(self): try: info_ps self.client.info(persistence) return { rdb_last_bgsave_status: info_ps.get(Persistence, {}).get(rdb_last_bgsave_status), aof_last_bgrewrite_status: info_ps.get(Persistence, {}).get(aof_last_bgrewrite_status), } except redis.ResponseError: return {error: persistence section unavailable}这个结构很简单但它把最核心的几个指标都覆盖了连通性、版本、运行时长、内存占用、慢查询、连接闲置、持久化状态。7.2 巡检脚本的风险控制和执行策略巡检脚本本身也不能瞎写有三条红线第一socket_timeout 一定要设置。如果 Redis 实例卡死没有超时控制的脚本会永远等下去连带监控系统一起挂掉。第二避免在巡检脚本里执行重量级命令。info()尽量带 sectionslowlog_get()限制条数client_list()在连接数很大的实例上有解析开销不要秒级执行。第三不要用巡检脚本代替真实的监控系统。这类脚本的价值在于故障定位和快速现场留痕真正的指标采集还是交给专业的监控平台。我还建议把巡检结果和告警通知打通。脚本每次运行把结果序列化成 JSON推到日志系统出现异常时能立刻看到故障时刻的现场快照。7.3 后续可以怎么扩展这套辅助函数库再往下走可以扩展的方向不少。比如加上哨兵或多实例的支持从SENTINEL或集群的CLUSTER INFO里拉取节点状态再比如把配置变更记录落到独立表方便审计还可以把slowlog历史做周期性归档用于追踪性能退化趋势。就拿多实例巡检来说最省事的办法是在上面RedisHealthCheck的基础上套一层循环instances [ {host: 10.0.0.1, port: 6379}, {host: 10.0.0.2, port: 6379}, ] for params in instances: try: result RedisHealthCheck(params).run() print(json.dumps(result, ensure_asciiFalse)) except Exception as e: print(json.dumps({host: params[host], error: str(e)}))这种组合方式没有把复杂的东西暴露给外面就是一个个类方法堆叠但是可读性、可维护性都很好。说实话这篇文章里没有一条命令是文档里查不到的。真正值钱的是你在什么场景下选择哪条命令以及你把这些命令组合成辅助函数时踩过的坑。我在生产上把上面的逻辑整理成了一个health_check模块每次预警都会自动拉一份信息快照再决定要不要人工介入。你可以从最小的一行dbsize()开始逐步把自己对 Redis 的服务控制与状态监控经验也沉淀成能复用的代码。
返回列表