应用缓存:本地缓存、Redis 与一致性
今天从哪里开始
昨天把查询本身查清了,今天同一件商品又被浏览了一百次,而商品资料几乎没有变化。能否少查几次数据库?先提出这个问题,再讨论缓存,而不把装上 Redis 当成目标。
先把订单报表的计算工作分给不同执行方式,再用单进程字典看懂缓存命中与回填,比较多个进程共享 Redis 的情况。最后处理改价和故障:展示可以接受多旧的数据,下单又应该信哪一份价格?
今天要学会什么
multiprocessing.Process、spawn/main、ProcessPoolExecutor、序列化与 GIL 适用范围;缓存命中、TTL、容量、改价失效。新增能力都落到:缓存一致性记录与订单 CSV 串行/线程/进程汇总工具。
- 命中、回源、TTL 与容量
- 本地与共享缓存
- Cache-Aside
- 改价失效、陈旧窗口与故障
课堂结束时,用自己的项目检查:
- 能做出来:读取商城导出 CSV,输出三种执行方式一致的状态汇总;改价后页面刷新且下单仍使用数据库价格。
- 能查出问题:针对“缓存库存作为交易依据、无限容量、缓存和会话故障混用”中的风险,说明输入、预期与实际差异;失败后核对数据没有被误改。
- 能解释修改:提交代码、测试和修改前后结果,讲清自己接受或拒绝了哪一处 AI 建议。
运营要一份订单汇总:先判断慢在哪里
商品重复读取可以减少数据库访问;历史订单报表却可能在拿到数据后仍要反复解析金额、校验并按状态累计。前者是重复 I/O,后者是 CPU 计算。缓存、线程和进程各有不同用途,我先测量再选。
今天补一项商城运营工具:读取订单中心导出的 CSV,按订单状态输出订单数与订单金额合计。列名沿用现有页面的 订单号、金额(元)、订单状态。取消订单单独成组,不能把所有组金额相加宣称实际收入;页面导出的是当前页,也不能把这份文件冒充全库统计。
我沿用文件、CSV、生成器和 Decimal 旧知识,先写纯函数 summarize_batch(rows),每行一张订单,金额从文本构造。正常值 0.10 + 0.20 应为 0.30;负数、NaN、超过两位小数、未知状态应报错,不能悄悄跳过坏记录输出“完整报表”。先有串行正确结果,再换执行方式。
新进程有自己的内存:multiprocessing 与 spawn
启动新进程不是再开一条共享字典的线程。下面先观察最小事实,保存为文件执行,不粘进交互终端:
from multiprocessing import get_context
import os
def inspect_batch(rows):
rows.append("child-only")
print("子进程", os.getpid(), rows)
if __name__ == "__main__":
original = ["O1"]
process = get_context("spawn").Process(target=inspect_batch, args=(original,))
process.start()
process.join()
print("父进程", os.getpid(), original, "exitcode", process.exitcode)multiprocessing 管理独立进程;Process.start/join 启动并等待,exitcode 帮助判断子进程是否正常退出。父进程的列表不会因子进程 append 自动改变,结果要显式传回。
Windows 的 spawn 启动新解释器并导入模块,因此入口保护 if __name__ == "__main__": 防止导入时重复创建进程。工作函数放模块顶层,参数和返回值必须能被序列化并传送。数据库连接、已打开文件、锁和 lambda 不能随意作为进程池参数;文件在父进程读,传普通行数据。不要在每个子进程重复启动 Web 服务或创建全局连接。
ProcessPoolExecutor:同一规则,分别运行三次
这是执行方式的最小示例。sum_amounts 是简化金额计算,不代替包含输入校验和状态分组的项目函数。
from concurrent.futures import ProcessPoolExecutor
from multiprocessing import get_context
from decimal import Decimal
def sum_amounts(values):
return sum((Decimal(value) for value in values), Decimal("0.00"))
if __name__ == "__main__":
batches = [["129.00", "0.10"], ["0.20"]]
with ProcessPoolExecutor(max_workers=2, mp_context=get_context("spawn")) as pool:
totals = list(pool.map(sum_amounts, batches))
print(sum(totals, Decimal("0.00")))进程池复用子进程,map 将每批参数传给顶层函数,再把结果传回父进程。主进程统一合并,避免依赖子进程修改全局汇总字典。遍历结果时仍可能收到子进程的异常;不能只提交不收结果。
我的项目练习使用同一 summarize_batch,分别通过普通 map、ThreadPoolExecutor 和 ProcessPoolExecutor 执行,再用同一合并规则得出结果。用 perf_counter 记录包含建池与回收的耗时,固定数据、批大小、工作者数量和 Python 版本。每种方式至少复跑三次并报告范围;结果完全相等是性能比较的前提。
Python 3.12 默认 CPython 的 GIL 使纯 Python CPU 工作通常不能靠线程获得多核执行;独立进程可以尝试利用多核,却增加启动、传输和合并成本。小报表用进程更慢是合理结果。不得加 sleep 或无业务计算“证明加速”,也不把运行一次的快慢写成生产承诺。
学生在 report_exercise.py 补 CSV 读取、中文状态映射、重复订单拒绝、纯计算与执行方式 TODO,运行 python check_report.py,再运行 python run_report.py orders_sample.csv 完成文件到结果。默认样本是三张虚构订单;更大样本必须标注教学生成数据并保留规则。真实页面导出的 CSV 另行执行,同一文件同时跑三种模式。当前最小工具一次读入有限文件,海量文件需要继续复用 W02 的分批读取与有界提交方案,不宣称无限量处理。
我怎样为商城选择执行方式
| 任务 | 首先选什么 | 为什么与边界 |
|---|---|---|
| 很小的订单汇总 | 串行 | 简单,建池成本可能高于计算 |
| 同步 HTTP 客户端批查物流 | 有上限线程池 | 等网络时让其他请求推进;逐项超时与失败仍要处理 |
| 已有异步客户端的多路查询 | W02 学过的 asyncio | await 让出执行机会;阻塞函数不能直接塞进事件循环 |
| 大批量纯 Python 汇总 | 测试进程池 | CPU 工作可跨进程;传输与启动成本须一起测 |
| 重复读取商品详情 | 缓存 | 避免重复工作,须接受明确的陈旧范围 |
这张表用于提出方案,不代替测量。下面回到商品查询,把“少做重复工作”和“让不同工作重叠”分开验证。
同一个商品为什么查了一百次数据库
我先重复查询商品详情,记录商品 SELECT 次数、响应耗时和错误率。价格展示允许短暂陈旧的范围需要明确;订单创建则必须使用权威价格和库存。缓存保存可重新获取的结果,MySQL 仍是业务事实来源。
哪些信息能容忍短暂陈旧,哪些必须以交易时的数据为准?
展示信息可在明确期限内接受陈旧;本课下单价格和库存以数据库事务为准。缓存是否适用由业务容忍度决定,不应缓存一切。
缓存的四个基本动作
| 动作 | 含义 | 商品例子 |
|---|---|---|
| 命中 | 缓存中有仍可使用的结果 | 直接返回 P1001 的详情 |
| 未命中 | 没有该键或已经到期 | 向数据库查询 |
| 回填 | 把数据源结果写入缓存 | 保存详情并设置 TTL |
| 失效 | 主动移除已不应继续使用的结果 | 改价提交后删除商品缓存 |
TTL 是生存时间;容量上限控制内存占用;LRU 根据最近使用情况选择淘汰对象。TTL 与 LRU 回答不同问题。一个没有上限的字典可以解释命中,却不足以直接作为长期运行的缓存。
本地缓存:先用函数和字典看懂生命周期
cache = {}
def read_product(product_id, load_from_db):
if product_id in cache:
return cache[product_id]
value = load_from_db(product_id)
cache[product_id] = value
return value这是说明命中与回填的项目片段,依赖调用者传入查询函数;没有 TTL、容量和线程保护,不能原样作为最终实现。随后观察课堂 local_cache.py 机制演示,逐项观察到期边界、LRU 淘汰和两个缓存对象相互独立。
OrderedDict 保留访问顺序,move_to_end 把最近命中移到末尾,popitem(last=False) 淘汰最旧项。monotonic 衡量经过的时间,测试注入时钟避免靠真实睡眠猜边界。锁保护一次“读取、判断、调整顺序”的复合操作;它不能让两个进程共享内存。
为什么引入 Redis 共享缓存
单个进程的缓存有效了,现在启动第二个进程:它没有第一个进程的字典。只有出现跨进程共享的需求,才需要比较独立缓存服务的收益和代价。
两个应用进程各有本地字典,更新一个不会自动更新另一个。Redis 作为独立服务,让多个进程访问相同键。它增加网络调用、序列化、配置和服务故障成本,并非所有查询加 Redis 都会更快。
Redis 在这里是共享服务,单个 Redis 实例仍有故障边界;“分布式缓存”不等于已经部署了高可用集群。课堂先验证共享行为,集群运维留作进阶。
本地缓存和 Redis 缓存最关键的区别是什么?
本地缓存属于一个进程,快但多个进程各有副本;Redis 是独立服务,多实例可共享,同时增加网络延迟、连接与故障处理成本。
第一次连接 Redis
课堂实验服务仅绑定本机端口。已有 Docker 基础后,在已发放的独立 Redis 实验材料中执行 docker compose -f compose.redis.yaml up -d,再执行 python demos/redis_cache_probe.py;本日 TODO 包不包含教师完整演示,由讲师按段演示或仅下发该机制文件。配置通过 LAB_REDIS_URL 提供;默认 redis://127.0.0.1:16379/2,其中末尾数字是实验逻辑库。外部服务器还需访问控制和传输保护,课堂本机设置不能直接照搬。
缓存键使用 业务命名空间:对象:版本:对象ID,例如 commerce:product:v1:P1001。金额序列化为字符串,读取后恢复 Decimal;避免 JSON 数字转换偷偷引入 float。键不能包含密码、令牌或完整个人信息。
Cache-Aside:由应用决定什么时候查数据库
flowchart LR Q[商品详情请求] --> C{Redis 命中} C -->|是| R[返回详情] C -->|否| D[读取 MySQL] D --> S[设置值和 TTL] S --> R
Redis 不会自动去查 MySQL。应用发起缓存查询、在未命中时回源并回填。当前项目只缓存商品详情;订单、库存和运营筛选列表保留数据库查询,先把一个边界验证清楚。
Cache-Aside(旁路缓存)的读取顺序和 TTL(过期时间)分别是什么?
应用先查缓存,未命中再查数据库并回填;TTL 限制条目存活时间。它不能保证与数据库瞬时一致,改价删除缓存仍需认识并发回填旧值的窗口。
商品改价:先提交数据库,再删除旧缓存
读取变快以后,运营把商品改价。缓存中仍是旧价格,现在沿着“写数据库—删旧缓存—下次重新读取”的顺序,逐步检查哪一步失败会留下什么结果。
管理员通过 PATCH /products/{product_id}/price 提交两位小数字符串。数据库提交成功后删除对应详情缓存;之后读取重新回填。先删缓存再写库会留下读者回填旧值的窗口,因此不能颠倒基本顺序。
提交后删除也不能保证强一致:删除可能失败,之前开始的读请求也可能回填旧值。课堂方案用短 TTL 限制陈旧时间,记录失效失败;需要更严格新鲜度的系统还需版本校验或可靠失效机制。下单始终从数据库读取权威价格,在事务里处理库存,不依赖缓存显示值。
改价后删缓存能否保证永远没有旧价格?
不能。并发旧读取可能在删除后回填旧值,TTL 只能约束陈旧时间窗口。课程商品展示接受有限陈旧,下单金额和库存始终以数据库事务为准。
缓存故障与会话故障要分开
商品缓存设置短连接和读取超时,失败时按规则回源,不能无限等待。数据库也有承载上限,大批回源时应结合入口限流,不能把“回源”理解为无限兜底。
如果 Redis 还保存登录会话,整台 Redis 停止后受保护请求应拒绝执行;不能为了页面可用而跳过身份检查。做缓存降级实验时,先单独注入缓存客户端失败,避免把会话故障混为一谈。
缓存服务故障时,商品浏览和员工操作能用同一种放行策略吗?
不能。商品展示可在控制负载下回源数据库;身份会话无法确认时应拒绝受保护操作,不能因缓存故障绕过权限。
Redis 故障时所有功能都应该回源放行吗?
不能混用策略。商品详情可回数据库,但身份会话无法确认时必须拒绝受保护操作;回源也要考虑数据库承载能力。
三类风险先会识别
| 现象 | 风险名称 | 第一层处理思路 |
|---|---|---|
| 大量请求查询不存在的键 | 穿透 | 校验输入;按业务评估短暂空值缓存及其失效 |
| 一个热门键过期后大量请求同时回源 | 击穿 | 请求合并或受控回源;分布式锁留作进阶 |
| 大量键同时到期,或缓存整体不可用 | 雪崩 | 分散过期、限制流量、明确降级与恢复 |
今天要求解释与复现一个边界,不要求实现所有高级方案。
用实验决定是否保留缓存
固定数据、并发、请求序列与运行环境,分别记录冷缓存和热缓存的查询次数、命中率、p50/p95、错误率与样本数。只有少量商品时缓存未必更快,结果可以是不值得引入。比较时不能把缓存命中和不同查询混为同一基线。
参考:Redis 客户端、Cache-Aside、Python functools。
学生自己动手,AI 只能改你允许改的地方
我先保存当前可运行版本,再完成“改价后页面刷新且下单仍使用数据库价格”。让 AI 一次只修改负责该行为的文件;修改前预测正常与失败结果,检查差异,再运行回归。每人需要解释一处接受或拒绝的建议。
错误与边界怎样验收
今天重点检查:缓存库存作为交易依据、无限容量、缓存和会话故障混用。保留输入、预期、实际输出和修复后的结果,不能只贴全绿截图。未验证的外部依赖如实记为待验证。
留给下一阶段
今天只缓存允许短暂陈旧的商品详情,价格和库存的交易判断仍在数据库。下一天 Redis 将承载另一类数据——登录会话;那种数据失效时不能沿用商品缓存的放行策略。