redis.json 83 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070
  1. [
  2. {
  3. "id": "01",
  4. "title": "连接 Redis、选择类型并设置 TTL",
  5. "group": "连接与缓存模型",
  6. "summary": "在隔离的 Redis 7 练习实例中操作字符串、哈希和集合,建立可辨认的键名与过期规则。",
  7. "objectives": [
  8. "通过环境变量配置带密码的 Redis 连接",
  9. "区分字符串、哈希与集合的操作",
  10. "解释 TTL 的 -1、-2 和覆盖写入行为"
  11. ],
  12. "concepts": [
  13. {
  14. "title": "缓存副本与业务真源",
  15. "body": "本模块以 PostgreSQL notes_lab 为笔记真源,Redis 只保存可重新生成的副本。缓存命中减少数据库读取;缓存丢失应影响性能而不应删除笔记。计数器、锁和消息队列的正确性要求不同,不能自动套用可随时丢弃的缓存策略。"
  16. },
  17. {
  18. "title": "数据类型决定命令",
  19. "body": "字符串可保存序列化 JSON;Hash 保存多个字段;Set 表示无重复成员。不同类型有各自命令,GET 一个 Hash 会返回 WRONGTYPE。这里不使用额外 RedisJSON 模块,JSON 文本由 Python 编解码。"
  20. },
  21. {
  22. "title": "TTL 是数据生命期",
  23. "body": "TTL 正数表示剩余秒数,-1 表示存在但没有过期时间,-2 表示键不存在。SET 默认覆盖值并移除原 TTL,除非显式设置新期限或保留期限。缓存写入通常把值和 EX/PX 放在同一条 SET 中,避免写值成功却未设置过期。"
  24. }
  25. ],
  26. "steps": [
  27. {
  28. "title": "启动只绑定本机端口的练习实例",
  29. "body": "需要已安装并运行 Docker、Python 3.11+。下面是 Linux / macOS Bash,在 notes-service 执行,只创建本机专用 notes-redis。config 文件含练习密码,目录只供当前用户访问;挂载单个配置文件供容器读取。生产应按凭据管理和 TLS 方案部署,不能直接使用这份本机配置。",
  30. "code": "mkdir -p redis-lab\nchmod 700 redis-lab\nexport REDIS_PASSWORD=\"$(python3 -c 'import secrets; print(secrets.token_urlsafe(32))')\"\npython3 - <<'PY'\nimport os\nfrom pathlib import Path\npassword = os.environ['REDIS_PASSWORD']\np = Path('redis-lab/redis.conf')\np.write_text('bind 0.0.0.0\\nprotected-mode yes\\nport 6379\\n'\n f'requirepass {password}\\n'\n 'save \"\"\\nappendonly no\\nmaxmemory 128mb\\nmaxmemory-policy allkeys-lru\\n')\np.chmod(0o644)\nPY\nprintf 'redis-lab/\\n' >> .gitignore\ndocker run --name notes-redis -d -p 127.0.0.1:6380:6379 \\\n -v \"$PWD/redis-lab/redis.conf:/usr/local/etc/redis/redis.conf:ro\" \\\n redis:7 redis-server /usr/local/etc/redis/redis.conf\nexport REDIS_HOST=127.0.0.1 REDIS_PORT=6380\npython -m pip install 'redis>=5.3,<6'\npython -m pip freeze > requirements-lock.txt",
  31. "lang": "bash"
  32. },
  33. {
  34. "title": "建立后续复用的客户端",
  35. "body": "保存为 redis_client.py。REDIS_PASSWORD 必须存在,忘记设置时应尽早失败。客户端启动不会立即验证连通,main 中 PING 才实际访问。这里使用 redis-py 5.3 系列稳定 API,Redis 7 不支持的较新命令不在示例中使用。",
  36. "code": "import os\nimport redis\n\nr = redis.Redis(\n host=os.environ.get(\"REDIS_HOST\", \"127.0.0.1\"),\n port=int(os.environ.get(\"REDIS_PORT\", \"6380\")),\n password=os.environ[\"REDIS_PASSWORD\"],\n db=0, decode_responses=True,\n socket_connect_timeout=0.2, socket_timeout=0.2,\n health_check_interval=30, max_connections=16,\n)\n\nif __name__ == \"__main__\":\n print(\"ping\", r.ping())\n print(\"version\", r.info(\"server\")[\"redis_version\"])\n r.close()",
  37. "lang": "python",
  38. "filename": "redis_client.py"
  39. },
  40. {
  41. "title": "操作三种数据类型",
  42. "body": "保存为 redis_types.py,执行 python redis_types.py。所有键使用 notes:lab: 前缀,仅删除本课明确列出的三个键。反复练习不用 FLUSHALL;不同业务键不能共用同一个名字。",
  43. "code": "from redis_client import r\n\nkeys = [\"notes:lab:types:title\", \"notes:lab:types:hash\", \"notes:lab:types:tags\"]\nr.delete(*keys)\nr.set(keys[0], \"first note\", ex=60)\nr.hset(keys[1], mapping={\"title\": \"first note\", \"pinned\": \"false\"})\nr.expire(keys[1], 60)\nr.sadd(keys[2], \"python\", \"api\", \"python\")\nr.expire(keys[2], 60)\nprint(r.get(keys[0]))\nprint(r.hgetall(keys[1]))\nprint(sorted(r.smembers(keys[2])))\nprint([r.type(k) for k in keys])\nr.delete(*keys)",
  44. "lang": "python",
  45. "filename": "redis_types.py"
  46. },
  47. {
  48. "title": "验证 TTL 与覆盖写入",
  49. "body": "保存为 redis_ttl.py 并运行。等待只发生在专用 1 秒练习键,不用长时间阻塞服务。SET 后 TTL 为 -1 的一幕用于认识风险;最终删除该练习键。",
  50. "code": "import time\nfrom redis_client import r\n\nk = \"notes:lab:ttl-probe\"\nr.set(k, \"v1\", ex=1)\nprint(\"expires\", r.ttl(k))\ntime.sleep(1.1)\nprint(\"missing\", r.ttl(k))\nr.set(k, \"v2\", ex=60)\nr.set(k, \"v3\")\nprint(\"no expiration\", r.ttl(k))\nr.set(k, \"v4\", ex=60)\nprint(\"restored expiration\", r.ttl(k))\nr.delete(k)",
  51. "lang": "python",
  52. "filename": "redis_ttl.py"
  53. }
  54. ],
  55. "checks": [
  56. "PING 返回 True,服务版本属于 Redis 7。",
  57. "字符串、Hash、Set 的值和类型符合预期,Set 中 python 只出现一次。",
  58. "能复现 TTL=-2 与 TTL=-1,所有长期缓存写入都明确设置期限。"
  59. ],
  60. "pitfalls": [
  61. {
  62. "symptom": "NOAUTH 或 AuthenticationError",
  63. "cause": "客户端未提供 REDIS_PASSWORD,或重开终端后环境变量丢失。",
  64. "fix": "从本机受保护配置恢复正确密码到当前环境,确认客户端指向 6380;不把密码贴入日志。"
  65. },
  66. {
  67. "symptom": "WRONGTYPE Operation against a key holding the wrong kind of value",
  68. "cause": "相同键名先被写成一种类型,后被另一类命令操作。",
  69. "fix": "用 TYPE 检查,修改键名命名空间;仅清理确认属于当前实验的键。"
  70. }
  71. ],
  72. "exercise": {
  73. "task": "写入一个 30 秒 JSON 文本缓存,并证明覆盖写入时必须重新处理 TTL。",
  74. "hint": "JSON 先 json.dumps,再 SET ex=30;不要分开 SET 与 EXPIRE。",
  75. "answer": "r.set(\"notes:lab:json-probe\", json.dumps({\"schema\":1,\"id\":1}), ex=30)。再次 r.set 同键时仍带 ex=30,或明确使用 keepttl=True;读后用 json.loads。SET 未指定过期且未 KEEPTTL 会移除已有 TTL。"
  76. },
  77. "refs": [
  78. {
  79. "label": "Redis:数据类型",
  80. "url": "https://redis.io/docs/latest/develop/data-types/"
  81. },
  82. {
  83. "label": "Redis:SET",
  84. "url": "https://redis.io/docs/latest/commands/set/"
  85. },
  86. {
  87. "label": "Redis:TTL",
  88. "url": "https://redis.io/docs/latest/commands/ttl/"
  89. },
  90. {
  91. "label": "redis-py 5.3:连接参数",
  92. "url": "https://redis.readthedocs.io/en/v5.3.0/connections.html"
  93. }
  94. ],
  95. "production": {
  96. "scenario": "笔记详情需要缓存,但重启 Redis 不能影响 PostgreSQL 中的真实笔记。",
  97. "decisions": [
  98. "键名包含应用、环境与数据用途。",
  99. "固定开发端口并设置认证。",
  100. "缓存写入与 TTL 在同一条命令中完成。"
  101. ],
  102. "failureDrill": "在专用键上移除 TTL 并观察 -1,再修复为带 EX 的 SET。",
  103. "acceptance": [
  104. "客户端没有明文硬编码密码。",
  105. "练习不使用 FLUSHALL。",
  106. "能从键名和 TYPE 识别数据用途。"
  107. ]
  108. }
  109. },
  110. {
  111. "id": "02",
  112. "title": "实现 Cache-Aside 与带版本的 JSON 缓存",
  113. "group": "读取与一致性",
  114. "summary": "先查 Redis,未命中再读 PostgreSQL,验证第一次回源与第二次命中的完整路径。",
  115. "objectives": [
  116. "实现显式命中、未命中和回填",
  117. "把数据库类型转成稳定 JSON 结构",
  118. "用 schema 区分缓存格式版本与业务数据版本"
  119. ],
  120. "concepts": [
  121. {
  122. "title": "Cache-Aside 的读路径",
  123. "body": "应用先 GET;不存在时查询数据库,再把结果 SET 到缓存。Redis 不会自动知道 PostgreSQL 更新。先把基本路径做对,后续逐课处理失效、并发回填和故障降级。"
  124. },
  125. {
  126. "title": "两种版本解决不同问题",
  127. "body": "缓存外层 schema=1 表示 JSON 格式协议,键名 v1 允许将来迁移格式。note.version 来自数据库,表示业务记录的修改版本。改 JSON 字段不等于修改业务记录,两者不要混为一个值。"
  128. },
  129. {
  130. "title": "缓存值也需要验证",
  131. "body": "反序列化 JSON 后还要检查结构和编号,坏缓存应被当作未命中处理。数据库时间转换为带时区 ISO 8601,不用 pickle 反序列化来自缓存的任意对象。本课缓存只包含列表出的笔记字段。"
  132. }
  133. ],
  134. "steps": [
  135. {
  136. "title": "准备一条 PostgreSQL 笔记",
  137. "body": "先完成数据库模块第 07 课,确保 notes 含 version。沿用 PG 环境变量连接 notes_lab,REDIS_PASSWORD 使用本模块第 01 课密码。在 Python 交互或保存以下脚本运行一次,记下编号并在 Bash 设置 export NOTE_ID=实际编号。",
  138. "code": "import psycopg\n\nwith psycopg.connect(\"\") as conn:\n row = conn.execute(\n \"INSERT INTO notes(title,content) VALUES (%s,%s) RETURNING id\",\n (\"cache-lab\", \"database is the source\"),\n ).fetchone()\n print(\"NOTE_ID=\", row[0])",
  139. "lang": "python",
  140. "filename": "seed_cache_note.py"
  141. },
  142. {
  143. "title": "编写数据库读取与缓存编码",
  144. "body": "保存为 note_cache.py。load_note 使用明确列名,把时间转换成字符串。此教学函数每次回源新建数据库连接,生产接入时用 API + 数据库模块的连接池,不能照搬成高并发连接策略。key 中花括号包围编号,为同一笔记相关键预留同槽标签;当前实例仍是单机 Redis。",
  145. "code": "import json\nimport psycopg\nfrom psycopg.rows import dict_row\nfrom redis_client import r\n\ndef key(note_id):\n return f\"notes:lab:v1:note:{{{note_id}}}\"\n\ndef load_note(note_id):\n with psycopg.connect(\"\", row_factory=dict_row) as conn:\n row = conn.execute(\n \"SELECT id,title,content,pinned,created_at,updated_at,version FROM notes WHERE id=%s\",\n (note_id,),\n ).fetchone()\n if row is not None:\n row[\"created_at\"] = row[\"created_at\"].isoformat()\n row[\"updated_at\"] = row[\"updated_at\"].isoformat()\n return row\n\ndef encode(note):\n return json.dumps({\"schema\": 1, \"note\": note}, ensure_ascii=False)\n\ndef decode(raw, note_id):\n data = json.loads(raw)\n note = data[\"note\"]\n if data[\"schema\"] != 1 or not isinstance(note, dict) or note.get(\"id\") != note_id:\n raise ValueError(\"invalid cached note\")\n if not isinstance(note.get(\"title\"), str) or not isinstance(note.get(\"version\"), int):\n raise ValueError(\"invalid cached fields\")\n return note\n\ndef get_note(note_id):\n raw = r.get(key(note_id))\n if raw is not None:\n try:\n return decode(raw, note_id), \"cache\"\n except (ValueError, KeyError, TypeError):\n r.delete(key(note_id))\n note = load_note(note_id)\n if note is not None:\n r.set(key(note_id), encode(note), ex=60)\n return note, \"database\"",
  146. "lang": "python",
  147. "filename": "note_cache.py"
  148. },
  149. {
  150. "title": "对照两次读取来源",
  151. "body": "保存为 cache_read.py。只清除当前练习笔记的一个缓存键。第一次来源 database,第二次 cache,内容应相等,TTL 在 1 到 60 之间。没有记录时返回 None,本课尚未缓存不存在的结果。",
  152. "code": "import os\nfrom redis_client import r\nfrom note_cache import key, get_note\n\nnote_id = int(os.environ[\"NOTE_ID\"])\nr.delete(key(note_id))\nfirst, source1 = get_note(note_id)\nsecond, source2 = get_note(note_id)\nassert first == second and first is not None\nprint(source1, source2, r.ttl(key(note_id)))\nprint(first)",
  153. "lang": "python",
  154. "filename": "cache_read.py"
  155. },
  156. {
  157. "title": "注入坏格式并验证自动回源",
  158. "body": "先写入不可解析文本,再写入不受支持的 schema。每次调用都应返回 database 和合法笔记,然后缓存被合法 JSON 替换。Redis 网络错误尚未在此函数内处理,第 06 和 09 课补齐有界降级。",
  159. "code": "import os\nfrom redis_client import r\nfrom note_cache import key, get_note\n\nnote_id = int(os.environ[\"NOTE_ID\"])\nfor bad in [\"not-json\", '{\"schema\":99,\"note\":{\"id\":1}}']:\n r.set(key(note_id), bad, ex=60)\n note, source = get_note(note_id)\n print(source, note[\"id\"])\n assert source == \"database\"",
  160. "lang": "python",
  161. "filename": "cache_bad_format.py"
  162. }
  163. ],
  164. "checks": [
  165. "第一次返回 database,第二次返回 cache,业务字段一致。",
  166. "缓存含 schema 和 note.version,时间为明确时区字符串。",
  167. "坏 JSON 和未知 schema 不直接导致业务解码崩溃,回源后得到有效内容。"
  168. ],
  169. "pitfalls": [
  170. {
  171. "symptom": "json.dumps 报 datetime is not JSON serializable",
  172. "cause": "直接把数据库 datetime 对象塞进缓存。",
  173. "fix": "在固定序列化边界转换为 ISO 8601,或使用受控模型序列化;不要随意 str 整个对象。"
  174. },
  175. {
  176. "symptom": "修改代码增加字段后旧缓存导致 KeyError",
  177. "cause": "缓存格式没有版本和校验,部署后仍读取旧结构。",
  178. "fix": "提升缓存 schema 或键名前缀,允许旧键自然过期;读取方明确接受的结构。"
  179. }
  180. ],
  181. "exercise": {
  182. "task": "让记录不存在时也能返回明确来源,并说明为什么不能把数据库异常当成不存在。",
  183. "hint": "None 表示成功查询且无行;连接异常代表没有完成查询。",
  184. "answer": "load_note 返回 None 时 get_note 返回 (None,\"database\")。捕获不到数据库时不能伪造 None,否则会把故障错误缓存成不存在;应向调用方返回可区分的服务错误,后续负缓存只处理确定的无行结果。"
  185. },
  186. "refs": [
  187. {
  188. "label": "Redis:GET",
  189. "url": "https://redis.io/docs/latest/commands/get/"
  190. },
  191. {
  192. "label": "Redis:Python 客户端",
  193. "url": "https://redis.io/docs/latest/develop/clients/redis-py/"
  194. },
  195. {
  196. "label": "Python:json",
  197. "url": "https://docs.python.org/3/library/json.html"
  198. }
  199. ],
  200. "production": {
  201. "scenario": "同一笔记被反复读取,希望降低数据库访问同时保留格式升级能力。",
  202. "decisions": [
  203. "PostgreSQL 成功读取后才回填。",
  204. "缓存格式版本和业务版本分离。",
  205. "异常与不存在使用不同结果。"
  206. ],
  207. "failureDrill": "给单个练习键写入坏 JSON 和未知 schema,检查回源和重新编码。",
  208. "acceptance": [
  209. "命中来源可观测。",
  210. "无任意对象反序列化。",
  211. "缓存值不包含未设计对外展示的敏感字段。"
  212. ]
  213. }
  214. },
  215. {
  216. "id": "03",
  217. "title": "提交后失效缓存,并复现旧值回填竞态",
  218. "group": "读取与一致性",
  219. "summary": "把失效放到事务提交之后,按固定时序制造旧快照覆盖,明确最终一致性的边界。",
  220. "objectives": [
  221. "正确安排数据库提交与缓存删除的顺序",
  222. "复现先读旧值后回填的竞态",
  223. "设计失效失败后的重试与一致性验收"
  224. ],
  225. "concepts": [
  226. {
  227. "title": "先提交数据库,再失效缓存",
  228. "body": "数据库提交成功后删除详情缓存,下一次读取从真源重建。如果在提交前删除,另一个读请求可能读到尚未更新的数据库旧值并重新缓存。提交后删除减少这一类窗口,但不消除所有并发竞态。"
  229. },
  230. {
  231. "title": "旧读取可以晚于新写入回填",
  232. "body": "读者 A 在写者提交前读到 v1,写者 B 提交 v2 并删除缓存,A 随后把 v1 回填。即使删除发生在提交后,缓存仍可能短暂陈旧。TTL 只限制一次缓存副本的存活时间,不应被表述为完整一致性证明。"
  233. },
  234. {
  235. "title": "失效失败是待补偿工作",
  236. "body": "PostgreSQL 和 Redis 没有共享一个本地事务。数据库已提交、DEL 失败时不能声称数据库回滚。生产可在同一数据库事务写 Outbox,由后台可靠重试失效,结合短 TTL;要求立即读取自己写入结果时直接返回数据库 RETURNING 数据或绕过缓存。"
  237. }
  238. ],
  239. "steps": [
  240. {
  241. "title": "实现带版本检查的更新与提交后删除",
  242. "body": "保存为 cache_write.py。expected_version 来自读取结果。上下文退出才表示提交成功,随后才删 Redis。示例返回 invalidated 标志供练习观察;生产失效失败应写入可靠补偿流程并告警,这里没有实现 Outbox,不能当成完整可靠投递。",
  243. "code": "import psycopg\nfrom redis.exceptions import RedisError\nfrom note_cache import key\nfrom redis_client import r\n\ndef rename_note(note_id, title, expected_version):\n with psycopg.connect(\"\") as conn:\n row = conn.execute(\n \"UPDATE notes SET title=%s,version=version+1,updated_at=now() \"\n \"WHERE id=%s AND version=%s RETURNING id,title,version\",\n (title, note_id, expected_version),\n ).fetchone()\n if row is None:\n raise ValueError(\"not found or version conflict\")\n try:\n r.delete(key(note_id))\n invalidated = True\n except RedisError:\n invalidated = False\n return {\"id\": row[0], \"title\": row[1], \"version\": row[2], \"invalidated\": invalidated}",
  244. "lang": "python",
  245. "filename": "cache_write.py"
  246. },
  247. {
  248. "title": "验证成功修改后的首次回源",
  249. "body": "保存为 cache_invalidate.py 并运行。先预热旧值,再以当前版本更新。更新后键不存在,下一次 get_note 返回数据库的新标题与新版本。重复运行时从数据库读取当前版本,不假定总是 1。",
  250. "code": "import os\nfrom note_cache import get_note, key, load_note\nfrom cache_write import rename_note\nfrom redis_client import r\n\nn = int(os.environ[\"NOTE_ID\"])\nbefore = load_note(n)\nr.delete(key(n))\nget_note(n)\nprint(rename_note(n, \"cache03-new-title\", before[\"version\"]))\nassert r.get(key(n)) is None\nafter, source = get_note(n)\nprint(source, after[\"title\"], after[\"version\"])\nassert source == \"database\" and after[\"version\"] == before[\"version\"] + 1",
  251. "lang": "python",
  252. "filename": "cache_invalidate.py"
  253. },
  254. {
  255. "title": "按确定时序重现旧值晚回填",
  256. "body": "本脚本将两个并发请求的步骤按顺序手工调度,结果不依赖线程调度或 sleep。只操作 NOTE_ID 专用笔记。A 先读取旧值并暂停,B 完成提交和失效,A 最后回填旧值;读取命中旧版本,数据库版本更高。",
  257. "code": "import os\nfrom note_cache import load_note, get_note, encode, key\nfrom cache_write import rename_note\nfrom redis_client import r\n\nn = int(os.environ[\"NOTE_ID\"])\nr.delete(key(n))\nsnapshot_a = load_note(n) # A: 已回源,尚未回填\nwritten_b = rename_note(n, \"cache03-race-new\", snapshot_a[\"version\"]) # B\nr.set(key(n), encode(snapshot_a), ex=5) # A: 旧值晚到\ncached, source = get_note(n)\nfresh = load_note(n)\nprint(\"cached\", source, cached[\"version\"], \"database\", fresh[\"version\"])\nassert cached[\"version\"] < fresh[\"version\"]\nr.delete(key(n)) # 清理本次故障注入\nassert get_note(n)[0][\"version\"] == fresh[\"version\"]",
  258. "lang": "python",
  259. "filename": "cache_stale_refill.py"
  260. },
  261. {
  262. "title": "制定可检查的失效策略",
  263. "body": "为 notes-service 的详情、列表、标签聚合分别列出受一次编辑影响的键。详情可精确删除;列表缓存需要版本化命名空间或短 TTL,不能用 KEYS 模糊删除整个实例。将下列记录保存为开发验收清单,后续 Outbox 模块实现重试。",
  264. "code": "事件:note.updated,字段 note_id / committed_version / event_id\n写入:业务 UPDATE 与 outbox INSERT 在同一个 PostgreSQL 事务\n消费:按事件删除详情键及明确的派生键,成功后记录处理状态\n重复:DEL 对不存在键仍可安全重放\n失败:有限退避后进入待处理队列并告警,不丢弃失效事件\n读取:写接口返回数据库实际版本;强一致读取显式绕过缓存\n观测:失效失败数、最老未处理事件年龄、回源率、旧版本样本",
  265. "lang": "text"
  266. }
  267. ],
  268. "checks": [
  269. "DEL 只出现在数据库事务成功退出后。",
  270. "固定时序实验能得到缓存版本小于数据库版本,清理后恢复。",
  271. "设计文档区分提交成功与失效成功,并给失效失败安排可靠补偿。"
  272. ],
  273. "pitfalls": [
  274. {
  275. "symptom": "在异常里删缓存,用户仍看到旧值",
  276. "cause": "失效顺序没有跟随真实提交结果,或遗漏派生列表键。",
  277. "fix": "围绕事务提交定义事件,列出所有派生缓存;不要以请求函数是否执行完代替提交状态。"
  278. },
  279. {
  280. "symptom": "采用延迟双删后宣称缓存强一致",
  281. "cause": "延迟长短无法覆盖任意暂停、网络延迟和进程故障。",
  282. "fix": "把延迟删除视为减少窗口的措施;依据业务选择可接受陈旧、可靠事件或直接读真源。"
  283. }
  284. ],
  285. "exercise": {
  286. "task": "数据库更新已提交但 Redis 断开时,接口能否返回“数据库操作已回滚”?应记录什么?",
  287. "hint": "数据库提交和 Redis 失效是两个独立结果。",
  288. "answer": "不能报告回滚。应保留实际提交版本,记录失效待补偿事件、event_id 和失败原因,采用 Outbox 等可靠机制重试。写响应可直接返回已提交数据,强一致读取绕过缓存;不要盲目重试创建造成重复记录。"
  289. },
  290. "refs": [
  291. {
  292. "label": "Redis:DEL",
  293. "url": "https://redis.io/docs/latest/commands/del/"
  294. },
  295. {
  296. "label": "Redis:客户端缓存与失效",
  297. "url": "https://redis.io/docs/latest/develop/reference/client-side-caching/"
  298. },
  299. {
  300. "label": "PostgreSQL 16:事务",
  301. "url": "https://www.postgresql.org/docs/16/tutorial-transactions.html"
  302. }
  303. ],
  304. "production": {
  305. "scenario": "编辑提交成功后用户立即刷新,后台又可能存在较慢的并发读取。",
  306. "decisions": [
  307. "提交后删除详情缓存。",
  308. "用确定性时序测试陈旧回填。",
  309. "可靠失效事件与直接读写结果满足不同一致性需求。"
  310. ],
  311. "failureDrill": "仅对 NOTE_ID 练习笔记模拟旧读取在新提交之后回填,比较两个版本并清理故障值。",
  312. "acceptance": [
  313. "没有宣称 Cache-Aside 天然强一致。",
  314. "失效故障不会伪装成数据库回滚。",
  315. "派生缓存清单和重试责任明确。"
  316. ]
  317. }
  318. },
  319. {
  320. "id": "04",
  321. "title": "阻止缓存穿透:输入校验与短期负缓存",
  322. "group": "热点与流量保护",
  323. "summary": "对确定不存在的编号缓存短期空结果,并避免把故障或权限拒绝当作全局不存在。",
  324. "objectives": [
  325. "区分格式非法、不存在和无权限",
  326. "为不存在结果设计明确编码和短 TTL",
  327. "限制攻击者制造的负缓存键数量"
  328. ],
  329. "concepts": [
  330. {
  331. "title": "穿透是大量请求持续落到真源",
  332. "body": "查询从不存在的编号时,普通 Cache-Aside 每次都得到空结果而不回填,会重复访问数据库。输入范围校验先拦掉无意义请求,负缓存减少同一个合法但不存在编号的重复查询。"
  333. },
  334. {
  335. "title": "空结果必须有明确标记",
  336. "body": "Redis GET 返回 None 表示缓存键不存在,不等于业务记录不存在。负缓存使用 found=false;正缓存使用 found=true 和 note,读取时分别处理。数据库连接失败绝不能被编码为 found=false。"
  337. },
  338. {
  339. "title": "权限不能被共享缓存省略",
  340. "body": "记录缓存只是数据副本,不代替鉴权。多租户应用应先完成主体与租户校验,并把需要隔离的租户放进键名。单用户课程暂不引入租户字段;不能把某个用户的 403 缓存成所有用户都不存在。"
  341. }
  342. ],
  343. "steps": [
  344. {
  345. "title": "实现正负结果独立编码",
  346. "body": "保存为 negative_cache.py。单独使用 v1:lookup 命名空间,不与第 02 课 JSON 结构混用。正结果 60 秒,确定无行 5 秒。类型校验拒绝 bool 作为整数编号;上限 2^63-1 对应数据库 bigint 的正数范围。",
  347. "code": "import json\nfrom redis_client import r\nfrom note_cache import load_note\n\ndef lookup_key(note_id):\n return f\"notes:lab:v1:lookup:{{{note_id}}}\"\n\ndef get_with_negative(note_id):\n if type(note_id) is not int or not 1 <= note_id <= 2**63 - 1:\n raise ValueError(\"invalid note id\")\n k = lookup_key(note_id)\n raw = r.get(k)\n if raw is not None:\n try:\n data = json.loads(raw)\n if data[\"schema\"] != 1 or type(data[\"found\"]) is not bool:\n raise ValueError(\"bad cache\")\n if data[\"found\"] is False:\n return None, \"negative-cache\"\n note = data[\"note\"]\n if not isinstance(note, dict) or note.get(\"id\") != note_id:\n raise ValueError(\"bad note\")\n return note, \"cache\"\n except (ValueError, KeyError, TypeError):\n r.delete(k)\n note = load_note(note_id) # 异常继续向外传递,不伪造不存在\n found = note is not None\n payload = {\"schema\": 1, \"found\": found, \"note\": note}\n r.set(k, json.dumps(payload), ex=60 if found else 5)\n return note, \"database\"",
  348. "lang": "python",
  349. "filename": "negative_cache.py"
  350. },
  351. {
  352. "title": "选择确认不存在的编号并查询两次",
  353. "body": "在独立练习库选择 max(id)+100000,先确认没有该记录。这里只做课堂样本选择,不给线上攻击者提供枚举工具。第一次 database、第二次 negative-cache,数据库错误没有进入这条成功路径。",
  354. "code": "import psycopg\nfrom negative_cache import get_with_negative, lookup_key\nfrom redis_client import r\n\nwith psycopg.connect(\"\") as conn:\n missing_id = conn.execute(\"SELECT coalesce(max(id),0)+100000 FROM notes\").fetchone()[0]\nr.delete(lookup_key(missing_id))\nprint(get_with_negative(missing_id))\nprint(get_with_negative(missing_id))\nprint(\"ttl\", r.ttl(lookup_key(missing_id)))\nr.delete(lookup_key(missing_id))",
  355. "lang": "python",
  356. "filename": "negative_probe.py"
  357. },
  358. {
  359. "title": "让非法编号在访问存储之前失败",
  360. "body": "保存并运行。bad 值都会触发 ValueError;后续 API 接入时由参数模型先执行同等校验。单个非法值的验证不要发起 Redis 或 PostgreSQL 请求。",
  361. "code": "from negative_cache import get_with_negative\n\nfor bad in [0, -1, True, \"12\", 2**63]:\n try:\n get_with_negative(bad)\n except ValueError as exc:\n print(repr(bad), str(exc))\n else:\n raise AssertionError(\"invalid id reached storage\")",
  362. "lang": "python",
  363. "filename": "negative_invalid.py"
  364. },
  365. {
  366. "title": "把创建与更新失效覆盖到负缓存",
  367. "body": "将 lookup_key 对应键加入提交后失效清单。新建时数据库返回 id 后再删除同一编号的负缓存;更新与删除时同时失效详情和 lookup 两种命名空间。下面函数只演示失效动作,调用必须放在业务事务提交后。",
  368. "code": "from redis_client import r\nfrom note_cache import key\nfrom negative_cache import lookup_key\n\ndef invalidate_note_caches(note_id):\n return r.delete(key(note_id), lookup_key(note_id))\n\n# 调用位置:数据库提交成功后,或可靠 Outbox 消费函数中。\n# 不在这里捕获错误后无声丢弃。",
  369. "lang": "python",
  370. "filename": "cache_invalidate_keys.py"
  371. }
  372. ],
  373. "checks": [
  374. "无行结果只回源一次,短期第二次返回 negative-cache。",
  375. "非法编号在存储访问之前被拒绝。",
  376. "新建、修改和删除的失效清单包含负缓存键,数据库错误不写入负缓存。"
  377. ],
  378. "pitfalls": [
  379. {
  380. "symptom": "新建笔记后短时间仍显示不存在",
  381. "cause": "创建前留下负缓存,创建后没有失效。",
  382. "fix": "用数据库返回的真实 id 删除对应负缓存,并设置较短负 TTL。"
  383. },
  384. {
  385. "symptom": "缓存里出现海量随机不存在编号",
  386. "cause": "攻击者每次换编号,短 TTL 也无法阻止瞬时键膨胀。",
  387. "fix": "在入口鉴权和限流,限制参数与访问范围;观察负缓存创建率和内存,不依赖负缓存单独抗攻击。"
  388. }
  389. ],
  390. "exercise": {
  391. "task": "如果增加布隆过滤器,返回“可能存在”和“确定不在过滤器”各意味着什么?",
  392. "hint": "过滤器有误判,且构建及更新是否完整决定能否用来拒绝查询。",
  393. "answer": "可能存在仍需查缓存或数据库。确定不在过滤器只有在过滤器完整且新增写入同步维护时才可拒绝;过滤器延迟会误拒绝新记录,因此需要回源或版本化切换策略。它不能代替权限检查与数据库真源。"
  394. },
  395. "refs": [
  396. {
  397. "label": "Redis:过期键",
  398. "url": "https://redis.io/docs/latest/commands/expire/"
  399. },
  400. {
  401. "label": "Redis:布隆过滤器",
  402. "url": "https://redis.io/docs/latest/develop/data-types/probabilistic/bloom-filter/"
  403. },
  404. {
  405. "label": "Redis:SCAN",
  406. "url": "https://redis.io/docs/latest/commands/scan/"
  407. }
  408. ],
  409. "production": {
  410. "scenario": "重复访问不存在笔记的请求增加,数据库空查询消耗开始明显。",
  411. "decisions": [
  412. "格式校验先于缓存读取。",
  413. "只缓存确定的无行结果,负 TTL 短于正常缓存。",
  414. "负缓存结合入口限流与容量监控。"
  415. ],
  416. "failureDrill": "对确定不存在的练习编号连续读取,再提交非法编号,比较来源和存储请求。",
  417. "acceptance": [
  418. "不存在与故障分开。",
  419. "没有以负缓存绕过鉴权。",
  420. "新增路径失效负缓存。"
  421. ]
  422. }
  423. },
  424. {
  425. "id": "05",
  426. "title": "抑制热点击穿:带令牌的单飞回填",
  427. "group": "热点与流量保护",
  428. "summary": "让同一热点键过期时只有少量请求回源,使用原子令牌检查防止旧持有者误删新锁。",
  429. "objectives": [
  430. "用 SET NX PX 合并同一键的回源",
  431. "通过 Lua 原子校验令牌和释放",
  432. "给竞争者等待与锁过期设计明确结果"
  433. ],
  434. "concepts": [
  435. {
  436. "title": "单飞用于压低重复工作",
  437. "body": "同一个热点缓存失效时,许多请求同时回源会压垮数据库。短期填充锁让一个请求重建,其他请求短暂等待缓存或收到忙响应。它服务于缓存效率,不承诺跨故障条件下业务副作用严格只执行一次。"
  438. },
  439. {
  440. "title": "释放时检查所有者令牌",
  441. "body": "每次竞争生成随机 token,并用 SET lock token NX PX 同时设置所有权和租期。释放必须在一段 Lua 中比较 token 再删除;先 GET 后 DEL 存在两条命令之间被新持有者接手的窗口。Redis 7 示例使用 Lua,不使用较新版本的条件删除命令。"
  442. },
  443. {
  444. "title": "过期意味着可能出现另一位持有者",
  445. "body": "进程暂停或回源过慢会让锁先过期。旧持有者恢复后不能删除或发布覆盖新持有者结果。下面脚本把校验、回填与释放放在一次 Lua 中;数据库并发更新仍有上一课的陈旧回填问题,单飞并不自动解决缓存一致性。"
  446. }
  447. ],
  448. "steps": [
  449. {
  450. "title": "保存安全释放脚本",
  451. "body": "保存为 cache_singleflight.py,后续步骤继续向此文件追加。键名使用同一个 {note_id} 标签,便于将来多键脚本要求同槽;当前未部署 Redis Cluster。脚本只有固定数量短操作,不在 Redis 内做长循环或外部访问。",
  452. "code": "import secrets\nimport time\nfrom redis_client import r\nfrom note_cache import key, load_note, encode, decode\n\nclass CacheBusy(RuntimeError):\n pass\n\nrelease = r.register_script(\"\"\"\nif redis.call('GET', KEYS[1]) == ARGV[1] then\n return redis.call('DEL', KEYS[1])\nend\nreturn 0\n\"\"\")\n\npublish = r.register_script(\"\"\"\nif redis.call('GET', KEYS[1]) ~= ARGV[1] then return 0 end\nredis.call('SET', KEYS[2], ARGV[2], 'PX', ARGV[3])\nredis.call('DEL', KEYS[1])\nreturn 1\n\"\"\")",
  453. "lang": "python",
  454. "filename": "cache_singleflight.py"
  455. },
  456. {
  457. "title": "实现有限等待与二次读取",
  458. "body": "追加到 cache_singleflight.py。获得锁后再读一次缓存,避免等待途中别人已经回填。竞争失败最多等 0.3 秒,每 20 毫秒看一次结果;耗尽后 CacheBusy,应映射为受控 503 或按策略返回允许的旧值。不能让所有竞争者超时后同时无限回源。",
  459. "code": "def singleflight_note(note_id, loader=load_note):\n cache_key, lock_key = key(note_id), key(note_id) + \":fill\"\n raw = r.get(cache_key)\n if raw is not None:\n return decode(raw, note_id)\n token = secrets.token_urlsafe(24)\n if r.set(lock_key, token, nx=True, px=2000):\n try:\n raw = r.get(cache_key)\n if raw is not None:\n return decode(raw, note_id)\n note = loader(note_id)\n if note is None:\n return None\n stored = publish(keys=[lock_key, cache_key], args=[token, encode(note), 60000])\n if not stored:\n raise CacheBusy(\"fill lease expired\")\n return note\n finally:\n release(keys=[lock_key], args=[token])\n deadline = time.monotonic() + 0.3\n while time.monotonic() < deadline:\n time.sleep(0.02)\n raw = r.get(cache_key)\n if raw is not None:\n return decode(raw, note_id)\n raise CacheBusy(\"cache fill still running\")",
  460. "lang": "python",
  461. "filename": "cache_singleflight.py"
  462. },
  463. {
  464. "title": "并发验证回源次数",
  465. "body": "保存为 cache_singleflight_probe.py。8 个线程同时读取同一笔记,模拟回源增加 80 毫秒。正常本机负载下 calls 应为 1;若发生 Redis 超时或调度超过等待期限,应把失败视为实际故障记录,不删除断言掩盖问题。专用记录必须存在,运行前清理详情和填充锁。",
  466. "code": "import os\nimport threading\nimport time\nfrom concurrent.futures import ThreadPoolExecutor\nfrom note_cache import key, load_note\nfrom redis_client import r\nfrom cache_singleflight import singleflight_note\n\nn = int(os.environ[\"NOTE_ID\"])\nr.delete(key(n), key(n) + \":fill\")\nguard = threading.Lock()\ncalls = 0\n\ndef counted_loader(note_id):\n global calls\n with guard:\n calls += 1\n time.sleep(0.08)\n return load_note(note_id)\n\nwith ThreadPoolExecutor(max_workers=8) as workers:\n rows = list(workers.map(lambda _: singleflight_note(n, counted_loader), range(8)))\nassert all(row is not None and row[\"id\"] == n for row in rows)\nassert calls == 1\nprint(\"database loads\", calls)",
  467. "lang": "python",
  468. "filename": "cache_singleflight_probe.py"
  469. },
  470. {
  471. "title": "验证旧令牌不能释放新锁",
  472. "body": "保存为 cache_lock_token.py。只对本课 token-probe 键模拟替换所有者,原令牌释放返回 0,键仍为 new-owner。这个实验直接设置新 token 以固定复现所有权变化;真实场景通常由原锁过期后竞争产生。",
  473. "code": "from redis_client import r\nfrom cache_singleflight import release\n\nk = \"notes:lab:token-probe\"\nr.set(k, \"old-owner\", px=2000)\nr.set(k, \"new-owner\", px=2000)\nassert release(keys=[k], args=[\"old-owner\"]) == 0\nassert r.get(k) == \"new-owner\"\nassert release(keys=[k], args=[\"new-owner\"]) == 1\nprint(\"ownership check passed\")",
  474. "lang": "python",
  475. "filename": "cache_lock_token.py"
  476. }
  477. ],
  478. "checks": [
  479. "正常热点并发实验只回源一次,其余读取缓存结果。",
  480. "旧 token 不能删除新 token 的锁。",
  481. "等待、租期、Redis 超时各有上限,锁过期后不发布旧持有者结果。"
  482. ],
  483. "pitfalls": [
  484. {
  485. "symptom": "finally 中 DEL 删除了别人的锁",
  486. "cause": "原锁过期后另一请求获得锁,旧请求无条件删除。",
  487. "fix": "使用 Lua 比较 token 并原子删除,不能把 GET 与 DEL 分开发。"
  488. },
  489. {
  490. "symptom": "把缓存锁用于扣款并宣称绝不重复",
  491. "cause": "租期过期、进程暂停或异步复制故障会破坏单持有者假设。",
  492. "fix": "业务正确性使用数据库约束、幂等键或具备 fencing 校验的资源;缓存锁只减少重复回源。"
  493. }
  494. ],
  495. "exercise": {
  496. "task": "把 loader 延迟改为 2.2 秒,预测原持有者和竞争者结果,并说明生产应如何处理。",
  497. "hint": "填充租期 2 秒,竞争者等待 0.3 秒;二者是不同预算。",
  498. "answer": "竞争者先收到 CacheBusy;原持有者回源完成后 publish 因 token 已过期返回 0,也抛出 CacheBusy。应该限制回源耗时、选择合适租期并监控失败;若需要续租必须验证所有者且限定总时间,不能无限延长。"
  499. },
  500. "refs": [
  501. {
  502. "label": "Redis:分布式锁与单实例令牌",
  503. "url": "https://redis.io/docs/latest/develop/clients/patterns/distributed-locks/"
  504. },
  505. {
  506. "label": "Redis:SET",
  507. "url": "https://redis.io/docs/latest/commands/set/"
  508. },
  509. {
  510. "label": "Redis:EVAL",
  511. "url": "https://redis.io/docs/latest/commands/eval/"
  512. }
  513. ],
  514. "production": {
  515. "scenario": "热门笔记缓存到期后突然出现大量相同详情请求。",
  516. "decisions": [
  517. "先用进程内合并或单实例填充锁降低重复回源。",
  518. "令牌检查与发布、释放原子执行。",
  519. "竞争失败有界等待,不把所有失败者直接推向数据库。"
  520. ],
  521. "failureDrill": "在本机令牌键替换所有者,再延长 loader 到超过租期,检查旧持有者行为。",
  522. "acceptance": [
  523. "旧持有者不能删新锁。",
  524. "数据库回源次数有度量。",
  525. "不把缓存锁当作生产业务唯一性保证。"
  526. ]
  527. }
  528. },
  529. {
  530. "id": "06",
  531. "title": "应对缓存雪崩:TTL 抖动、受控预热与降级",
  532. "group": "热点与流量保护",
  533. "summary": "分散批量过期时间,限制冷启动回源并发,在 Redis 失效时保护 PostgreSQL。",
  534. "objectives": [
  535. "给 TTL 加有界随机偏移",
  536. "用限速小批次预热热点数据",
  537. "让缓存降级服从数据库并发预算"
  538. ],
  539. "concepts": [
  540. {
  541. "title": "雪崩影响一批键或整个实例",
  542. "body": "同一时刻大量键到期,或 Redis 整体不可用,会把原本被缓存吸收的流量推向数据库。TTL 抖动分散到期时刻,不能解决实例故障;需要回源限流和服务端负载保护。"
  543. },
  544. {
  545. "title": "降级也有容量上限",
  546. "body": "缓存可绕过不代表数据库能接住全量请求。为回源设置并发预算、等待上限、SQL 超时和入口限流,超出容量返回明确的暂不可用结果。不能在数据库也失败时伪造空笔记。"
  547. },
  548. {
  549. "title": "预热从热点开始",
  550. "body": "预热最常访问的有限集合,并控制批次大小、并发和速率。全库一起加载会抢占生产数据库资源和缓存容量。旧值兜底只适用于允许陈旧的字段,权限、删除状态和敏感信息不能随意继续返回。"
  551. }
  552. ],
  553. "steps": [
  554. {
  555. "title": "观察有界 TTL 抖动分布",
  556. "body": "保存为 cache_jitter.py。生成 100 个专用小键,TTL 均在 45 到 75 秒,真实设置后可能因执行消耗略减 1 秒。只清理本脚本创建的键,不扫描并删除其他缓存。",
  557. "code": "import random\nfrom collections import Counter\nfrom redis_client import r\n\nkeys = [f\"notes:lab:jitter:{i}\" for i in range(100)]\nttls = [60 + random.randint(-15, 15) for _ in keys]\nwith r.pipeline(transaction=False) as pipe:\n for k, ttl in zip(keys, ttls):\n pipe.set(k, \"sample\", ex=ttl)\n pipe.execute()\nprint(\"min/max\", min(ttls), max(ttls))\nprint(\"buckets\", sorted(Counter(ttls).items()))\nr.delete(*keys)",
  558. "lang": "python",
  559. "filename": "cache_jitter.py"
  560. },
  561. {
  562. "title": "小批次预热最近笔记",
  563. "body": "保存为 cache_warm.py。仅预热最多 20 条,顺序处理并在每条之间等待 50 毫秒;目的是控制负载,不是追求最快跑完。真正热点应来自访问统计。本例直接用已有 load_note,生产切换到连接池并增加任务取消。",
  564. "code": "import random\nimport time\nimport psycopg\nfrom note_cache import key, load_note, encode\nfrom redis_client import r\n\nwith psycopg.connect(\"\") as conn:\n ids = [row[0] for row in conn.execute(\"SELECT id FROM notes ORDER BY id DESC LIMIT 20\")]\nfor note_id in ids:\n note = load_note(note_id)\n if note is not None:\n r.set(key(note_id), encode(note), ex=60 + random.randint(-15, 15))\n time.sleep(0.05)\nprint(\"warmed\", len(ids))",
  565. "lang": "python",
  566. "filename": "cache_warm.py"
  567. },
  568. {
  569. "title": "实现有并发上限的回源降级",
  570. "body": "保存为 cache_fallback.py。该函数直接实现读路径,不调用可能已经回源的 get_note 后再次回源,避免重复数据库查询。Redis GET/SET 失败可以绕过,PostgreSQL 异常仍交给调用者。Semaphore 只约束本进程;多进程预算需要总量规划。同步代码不能直接阻塞 async 路由。",
  571. "code": "import random\nimport threading\nfrom redis.exceptions import RedisError\nfrom redis_client import r\nfrom note_cache import key, load_note, encode, decode\n\ndb_slots = threading.BoundedSemaphore(4)\n\nclass SourceBusy(RuntimeError):\n pass\n\ndef get_available(note_id, loader=load_note):\n try:\n raw = r.get(key(note_id))\n if raw is not None:\n try:\n return decode(raw, note_id), \"cache\"\n except (ValueError, KeyError, TypeError):\n pass\n except RedisError:\n pass # 生产记录 redis_read_error 计数,不记录密码\n if not db_slots.acquire(timeout=0.05):\n raise SourceBusy(\"database fallback capacity exhausted\")\n try:\n note = loader(note_id)\n finally:\n db_slots.release()\n if note is not None:\n try:\n r.set(key(note_id), encode(note), ex=60 + random.randint(-15, 15))\n except RedisError:\n pass # 生产记录 redis_fill_error 计数\n return note, \"database\"",
  572. "lang": "python",
  573. "filename": "cache_fallback.py"
  574. },
  575. {
  576. "title": "停掉本机缓存并验证仍可读取真源",
  577. "body": "先在终端执行 docker stop notes-redis,仅影响本课专用实例;运行下面脚本,预期来源 database。完成后 docker start notes-redis 并再运行两次,后续可恢复 cache。不要在服务正在高负载时无保护地执行全局故障演练。",
  578. "code": "import os\nfrom cache_fallback import get_available\n\nnote, source = get_available(int(os.environ[\"NOTE_ID\"]))\nassert note is not None\nprint(source, note[\"id\"], note[\"version\"])\n\n# Shell 完成后恢复:docker start notes-redis\n# 本课 Redis 未开启持久化,重启后的缓存需要重新构建。",
  579. "lang": "python",
  580. "filename": "cache_outage_probe.py"
  581. }
  582. ],
  583. "checks": [
  584. "TTL 分布有上下界,批量键没有全部同刻过期。",
  585. "预热数量和速率有限,未把全库一次性加载。",
  586. "专用 Redis 停止时仍读到 PostgreSQL 数据,回源超出预算时返回受控错误。"
  587. ],
  588. "pitfalls": [
  589. {
  590. "symptom": "缓存断开后数据库连接数瞬间暴涨",
  591. "cause": "降级没有并发限制,重试与每次新建连接叠加。",
  592. "fix": "按进程规划回源预算,使用池和短等待,超过容量快速拒绝;避免多层重试。"
  593. },
  594. {
  595. "symptom": "抖动后出现零或负 TTL",
  596. "cause": "随机偏移超过基础期限。",
  597. "fix": "明确最小 TTL 并测试范围,例如 max(1,base+jitter),业务过期上限也要符合要求。"
  598. }
  599. ],
  600. "exercise": {
  601. "task": "在 Redis 停止时用 12 个线程和每次 0.2 秒的 loader 调用 get_available,预测 SourceBusy 的原因。",
  602. "hint": "本进程只有 4 个回源名额,等待最多 0.05 秒。",
  603. "answer": "前 4 个请求可占用回源名额,其余请求可能在名额释放前超时并得到 SourceBusy。实际数量由调度决定,验收应看峰值回源并发不超过 4、等待有界、恢复后正常,而非硬编码某个固定失败数。"
  604. },
  605. "refs": [
  606. {
  607. "label": "Redis:客户端生产使用",
  608. "url": "https://redis.io/docs/latest/develop/clients/redis-py/produsage/"
  609. },
  610. {
  611. "label": "Redis:EXPIRE",
  612. "url": "https://redis.io/docs/latest/commands/expire/"
  613. },
  614. {
  615. "label": "Redis:内存淘汰",
  616. "url": "https://redis.io/docs/latest/develop/reference/eviction/"
  617. }
  618. ],
  619. "production": {
  620. "scenario": "发布或 Redis 重启后大量缓存同时为空,数据库必须保持可用。",
  621. "decisions": [
  622. "到期抖动、限速预热与故障降级分别处理不同触发条件。",
  623. "数据库回源有硬并发预算。",
  624. "允许陈旧数据的范围由业务定义。"
  625. ],
  626. "failureDrill": "仅停止 notes-redis,使用有限并发读取并测量回源峰值,随后恢复实例。",
  627. "acceptance": [
  628. "不会无限回源或无限等待。",
  629. "数据库错误仍可见。",
  630. "故障恢复后命中率逐步恢复而非立即全量预热。"
  631. ]
  632. }
  633. },
  634. {
  635. "id": "07",
  636. "title": "使用 Pipeline、WATCH 与 Lua 保证正确的原子操作",
  637. "group": "原子操作与容量",
  638. "summary": "比较批量发送、乐观事务和 Lua,在 Redis 7 中实现带过期窗口的原子计数限流。",
  639. "objectives": [
  640. "区别流水线减少往返与事务原子执行",
  641. "处理 WATCH 发现的并发修改",
  642. "用短 Lua 脚本组合 INCR 和到期设置"
  643. ],
  644. "concepts": [
  645. {
  646. "title": "Pipeline 首先减少网络往返",
  647. "body": "transaction=False 的 pipeline 批量发送命令,不让多条业务操作自动变成一个不可分割动作。redis-py pipeline 默认启用事务,教学中明确写参数以免混淆。大批次会占内存和延长等待,应限制规模。"
  648. },
  649. {
  650. "title": "WATCH 是提交前的乐观检查",
  651. "body": "WATCH 键后读取当前值,MULTI 与 EXEC 提交修改;被其他客户端改动时 redis-py 抛 WatchError,应用重新读取并有限重试。Redis 事务与 PostgreSQL 不同,执行阶段某条命令出错不会撤销已经成功的其他命令。"
  652. },
  653. {
  654. "title": "Lua 组合命令时先验证输入",
  655. "body": "短 Lua 脚本在执行期间不被其他命令穿插。原子执行不代表发生运行时错误会回滚此前的写入。参数与数据类型应在变更前校验,脚本必须短,不能用长循环阻塞所有客户端。"
  656. }
  657. ],
  658. "steps": [
  659. {
  660. "title": "批量发送并理解返回位置",
  661. "body": "保存为 redis_pipeline.py。transaction=False 只减少往返,返回列表按命令顺序排列。专用键全部带 TTL,并在验收后显式删除。若一次批量中途网络断开,不能仅凭客户端异常判断所有命令都没执行。",
  662. "code": "from redis_client import r\n\nkeys = [f\"notes:lab:pipeline:{i}\" for i in range(3)]\nwith r.pipeline(transaction=False) as pipe:\n for i, k in enumerate(keys):\n pipe.set(k, str(i), ex=60)\n for k in keys:\n pipe.get(k)\n result = pipe.execute()\nprint(result)\nassert result == [True, True, True, \"0\", \"1\", \"2\"]\nr.delete(*keys)",
  663. "lang": "python",
  664. "filename": "redis_pipeline.py"
  665. },
  666. {
  667. "title": "制造 WATCH 冲突并重读",
  668. "body": "保存为 redis_watch.py。第一次 watch 后,使用客户端的另一次命令修改该键,确保 EXEC 抛 WatchError;第二次重新 watch 和读取,应从值 10 增加到 11。最多尝试 3 次,示例没有无限循环。",
  669. "code": "from redis import WatchError\nfrom redis_client import r\n\nk = \"notes:lab:watch-counter\"\nr.set(k, 0, ex=60)\nfor attempt in range(3):\n with r.pipeline() as pipe:\n try:\n pipe.watch(k)\n current = int(pipe.get(k) or 0)\n if attempt == 0:\n r.set(k, 10, ex=60) # 另一次命令制造冲突\n pipe.multi()\n pipe.set(k, current + 1, ex=60)\n pipe.execute()\n break\n except WatchError:\n print(\"conflict, retry from read\")\nelse:\n raise RuntimeError(\"retry budget exhausted\")\nassert r.get(k) == \"11\"\nr.delete(k)",
  670. "lang": "python",
  671. "filename": "redis_watch.py"
  672. },
  673. {
  674. "title": "实现原子计数与固定期限",
  675. "body": "保存为 redis_limiter.py。脚本验证限制与窗口后 INCR,首次计数同时 PEXPIRE,避免 INCR 成功但 EXPIRE 未发出的永久计数器。窗口从首次请求开始,不是精确滑动窗口。网络超时重试可能再次计数,业务必须允许保守限流或使用独立请求幂等方案。",
  676. "code": "from redis_client import r\n\nlimit_script = r.register_script(\"\"\"\nlocal limit = tonumber(ARGV[1])\nlocal window = tonumber(ARGV[2])\nif not limit or limit < 1 or limit % 1 ~= 0 then\n return redis.error_reply('invalid limit')\nend\nif not window or window < 1 or window % 1 ~= 0 then\n return redis.error_reply('invalid window')\nend\nlocal count = redis.call('INCR', KEYS[1])\nif count == 1 then redis.call('PEXPIRE', KEYS[1], window) end\nlocal allowed = 0\nif count <= limit then allowed = 1 end\nreturn {allowed, count, redis.call('PTTL', KEYS[1])}\n\"\"\")\n\ndef allow_request(subject):\n # subject 必须是鉴权后得到的稳定主体 ID,不能是任意超长输入。\n k = f\"notes:lab:limit:{{{subject}}}\"\n allowed, count, ttl_ms = limit_script(keys=[k], args=[3, 1000])\n return bool(allowed), count, ttl_ms",
  677. "lang": "python",
  678. "filename": "redis_limiter.py"
  679. },
  680. {
  681. "title": "验证窗口内拒绝和到期恢复",
  682. "body": "保存为 redis_limiter_probe.py。前 3 次允许,第 4 次拒绝且返回正的剩余毫秒;约 1.1 秒后新窗口允许。生产映射 429,并根据 TTL 向上取整计算 Retry-After;权限鉴定接口与普通缓存故障时的放行策略必须分别设计。",
  683. "code": "import time\nfrom redis_client import r\nfrom redis_limiter import allow_request\n\nk = \"notes:lab:limit:{probe-user}\"\nr.delete(k)\nresults = [allow_request(\"probe-user\") for _ in range(4)]\nprint(results)\nassert [x[0] for x in results] == [True, True, True, False]\nassert results[-1][2] > 0\ntime.sleep(1.1)\nassert allow_request(\"probe-user\")[0] is True\nr.delete(k)",
  684. "lang": "python",
  685. "filename": "redis_limiter_probe.py"
  686. }
  687. ],
  688. "checks": [
  689. "Pipeline 返回顺序与命令顺序一致,批量大小有限。",
  690. "WATCH 实验先失败再从新值重试,不覆盖外部修改。",
  691. "限流前三次允许、第四次拒绝、窗口到期恢复,计数键存在时带 TTL。"
  692. ],
  693. "pitfalls": [
  694. {
  695. "symptom": "误把 transaction=False 的 Pipeline 当成原子业务操作",
  696. "cause": "减少网络往返与命令不可交错是不同保证。",
  697. "fix": "需要检查并更新用 WATCH,需要短组合操作用 Lua;明确事务选项。"
  698. },
  699. {
  700. "symptom": "INCR 后进程崩溃留下永不过期限流键",
  701. "cause": "EXPIRE 是另一条命令,没来得及发出。",
  702. "fix": "把首次计数和到期设置放进短脚本;对专用命名空间监控 TTL=-1 异常。"
  703. }
  704. ],
  705. "exercise": {
  706. "task": "窗口边界附近为什么可能在很短时间内通过接近两倍限制的请求?",
  707. "hint": "上一窗口末尾和下一窗口开头分别拥有各自额度。",
  708. "answer": "固定窗口允许边界突发:旧窗口末尾用完额度,过期后新窗口立即重新计数。需要更平滑限制时可选择滑动窗口或令牌桶,评估精度、状态量和脚本成本;不能把本例当作精确的任意 1 秒最多 3 次。"
  709. },
  710. "refs": [
  711. {
  712. "label": "Redis:Pipeline 与事务",
  713. "url": "https://redis.io/docs/latest/develop/clients/redis-py/transpipe/"
  714. },
  715. {
  716. "label": "Redis:事务语义",
  717. "url": "https://redis.io/docs/latest/develop/using-commands/transactions/"
  718. },
  719. {
  720. "label": "Redis:Lua 脚本",
  721. "url": "https://redis.io/docs/latest/develop/programmability/eval-intro/"
  722. },
  723. {
  724. "label": "Redis:INCR",
  725. "url": "https://redis.io/docs/latest/commands/incr/"
  726. }
  727. ],
  728. "production": {
  729. "scenario": "API 需要在多进程之间共享请求计数,并避免计数和过期配置分离。",
  730. "decisions": [
  731. "流水线、事务和 Lua 各用于明确的操作边界。",
  732. "脚本短且输入受限。",
  733. "限流故障策略不沿用普通数据缓存的无条件放行。"
  734. ],
  735. "failureDrill": "在专用键制造 WATCH 冲突与窗口超额请求,验证有限重试和原子期限。",
  736. "acceptance": [
  737. "没有永久计数键。",
  738. "事务错误不被错误描述为自动回滚。",
  739. "超时重试与边界突发的业务含义明确。"
  740. ]
  741. }
  742. },
  743. {
  744. "id": "08",
  745. "title": "控制缓存容量与淘汰,隔离任务 Broker",
  746. "group": "原子操作与容量",
  747. "summary": "在专用容量实例制造淘汰,观察命中率和内存,再区分可丢缓存与不可随意丢弃的队列数据。",
  748. "objectives": [
  749. "读取内存、淘汰和命中指标",
  750. "根据数据用途选择 allkeys-lru 或 noeviction 等策略",
  751. "解释为什么逻辑数据库不能替代实例隔离"
  752. ],
  753. "concepts": [
  754. {
  755. "title": "maxmemory 不是进程总内存上限",
  756. "body": "Redis 的 maxmemory 控制参与淘汰计算的内存范围,进程 RSS、复制缓冲、持久化重写等还需要额外预算。容器内存上限应留余量,不能把 maxmemory 与容器限制设成完全相同。"
  757. },
  758. {
  759. "title": "淘汰与到期不同",
  760. "body": "到期按 TTL 删除,淘汰在内存压力下依据策略移除键。allkeys-lru 可从所有键中近似选择较久未使用的键;noeviction 在不足时拒绝某些需要新增内存的写命令。没有任何策略保证缓存永不丢失。"
  761. },
  762. {
  763. "title": "缓存实例不承载任务可靠性",
  764. "body": "任务 Broker、结果存储、锁与缓存可能要求不同持久化、淘汰和资源策略。逻辑 DB 0 与 DB 1 仍共享同一进程内存、淘汰配置和故障域。队列数据应按任务系统要求使用独立实例,noeviction 也只是拒写,不会自动让队列可靠。"
  765. }
  766. ],
  767. "steps": [
  768. {
  769. "title": "启动单独容量实验实例",
  770. "body": "以下 Linux Bash 只创建 notes-redis-capacity,宿主机 6381,与业务练习缓存 6380 分开。读取现有本机配置并只修改 maxmemory。不要对共享 Redis 执行 CONFIG SET 作为临时实验。镜像实例仍使用已有 REDIS_PASSWORD。",
  771. "code": "python3 - <<'PY'\nfrom pathlib import Path\nsource = Path('redis-lab/redis.conf').read_text()\nPath('redis-lab/capacity.conf').write_text(source.replace('maxmemory 128mb', 'maxmemory 8mb'))\nPY\ndocker run --name notes-redis-capacity -d -p 127.0.0.1:6381:6379 \\\n -v \"$PWD/redis-lab/capacity.conf:/usr/local/etc/redis/redis.conf:ro\" \\\n redis:7 redis-server /usr/local/etc/redis/redis.conf",
  772. "lang": "bash"
  773. },
  774. {
  775. "title": "有限写入制造容量压力",
  776. "body": "保存为 redis_capacity.py。最多写 2,000 个约 8KB 字符串,带 10 分钟 TTL;请求数和内存实验都有上限。运行前核对端口 6381。记录写入前后 evicted_keys 差值,不能用实例启动以来累计值直接推算本次淘汰。",
  777. "code": "import os\nimport redis\n\nclient = redis.Redis(host=\"127.0.0.1\", port=6381, password=os.environ[\"REDIS_PASSWORD\"],\n decode_responses=True, socket_timeout=1, socket_connect_timeout=1)\nbefore = client.info(\"stats\")\nfor i in range(2000):\n client.set(f\"notes:lab:capacity:{i}\", \"x\" * 8192, ex=600)\nafter = client.info(\"stats\")\nmemory = client.info(\"memory\")\nprint(\"evicted delta\", after[\"evicted_keys\"] - before[\"evicted_keys\"])\nprint(\"used / rss / max\", memory[\"used_memory\"], memory[\"used_memory_rss\"], memory[\"maxmemory\"])\nprint(\"remaining keys\", client.dbsize())\nprint(\"policy\", client.config_get(\"maxmemory-policy\"))\nclient.close()",
  778. "lang": "python",
  779. "filename": "redis_capacity.py"
  780. },
  781. {
  782. "title": "区分命中率与淘汰率",
  783. "body": "在容量实例读取 2,000 个明确键,统计本次命中和未命中。下面本地计数是这批请求的真实比例;INFO 的 keyspace_hits/misses 是实例全局累计值,做监控时用区间增量。不要用 KEYS * 在大实例全量扫描。",
  784. "code": "import os\nimport redis\n\nclient = redis.Redis(host=\"127.0.0.1\", port=6381, password=os.environ[\"REDIS_PASSWORD\"],\n decode_responses=True, socket_timeout=1)\nhits = sum(client.get(f\"notes:lab:capacity:{i}\") is not None for i in range(2000))\nprint(\"hits\", hits, \"misses\", 2000-hits, \"ratio\", hits/2000)\nstats = client.info(\"stats\")\nprint({k: stats[k] for k in [\"keyspace_hits\", \"keyspace_misses\", \"evicted_keys\", \"expired_keys\"]})\nclient.close()",
  785. "lang": "python",
  786. "filename": "redis_capacity_hits.py"
  787. },
  788. {
  789. "title": "形成缓存与 Broker 的独立配置",
  790. "body": "把下列配置记录加入部署设计。容量实验完成后执行 docker stop notes-redis-capacity,保留环境便于复查,不触碰 6380。若要演练 noeviction,应复制配置另建独立实例,以收到内存不足写入错误为预期,并验证任务生产端如何处理拒写。",
  791. "code": "普通笔记缓存:独立 cache 实例;明确 maxmemory;可用 allkeys-lru/LFU;值可从 PostgreSQL 重建\n任务 Broker:独立实例;按任务框架要求设置持久化与 noeviction;拒写必须使投递失败可见\n任务结果:独立 TTL 与保留期;持久业务状态放 PostgreSQL\n容量预算:容器限制 > maxmemory,并留复制、AOF 重写、碎片等余量\n告警:used_memory/maxmemory、RSS、evicted_keys 增量、命中率、写入拒绝数\n检查:DB 0 与 DB 1 仅键空间区分,不提供内存、CPU 或故障隔离",
  792. "lang": "text"
  793. }
  794. ],
  795. "checks": [
  796. "专用容量实例 evicted_keys 增长,键数量小于写入总数。",
  797. "命中率从本次读取统计,INFO 累计指标用增量解释。",
  798. "设计明确缓存与 Broker 分实例、不同持久化和拒写处理。"
  799. ],
  800. "pitfalls": [
  801. {
  802. "symptom": "Redis 尚未到 maxmemory,容器已经 OOM",
  803. "cause": "忽略 RSS、缓冲和持久化工作期间额外内存。",
  804. "fix": "对照容器指标和 used_memory_rss,留容量余量并压测重写与复制场景。"
  805. },
  806. {
  807. "symptom": "任务突然丢失,但 Redis 没报错",
  808. "cause": "队列与普通缓存共用允许淘汰所有键的实例。",
  809. "fix": "按 Broker 要求迁移到独立实例并禁止随意淘汰,同时完善任务确认、重投和持久业务状态。"
  810. }
  811. ],
  812. "exercise": {
  813. "task": "为什么把 Celery Broker 放 DB 1、缓存放 DB 0 仍可能互相影响?",
  814. "hint": "想清楚 maxmemory、CPU、进程退出和持久化属于哪一层。",
  815. "answer": "逻辑库共享同一 Redis 进程和服务器级配置,缓存写入可触发整体内存压力,CPU 长命令和实例故障也影响队列。需要独立实例与资源预算;不同 DB 编号只能减少键名碰撞。"
  816. },
  817. "refs": [
  818. {
  819. "label": "Redis:键淘汰",
  820. "url": "https://redis.io/docs/latest/develop/reference/eviction/"
  821. },
  822. {
  823. "label": "Redis:INFO",
  824. "url": "https://redis.io/docs/latest/commands/info/"
  825. },
  826. {
  827. "label": "Redis:内存优化",
  828. "url": "https://redis.io/docs/latest/operate/oss_and_stack/management/optimization/memory-optimization/"
  829. }
  830. ],
  831. "production": {
  832. "scenario": "缓存规模增长且后台任务开始共用 Redis,需要避免缓存压力导致任务异常。",
  833. "decisions": [
  834. "实验容量实例独立于其他课程环境。",
  835. "按进程 RSS 与服务负载规划余量。",
  836. "可丢数据与可靠任务隔离。"
  837. ],
  838. "failureDrill": "在 6381 专用实例有限写入大值,观察淘汰与命中变化,完成后停止该实例。",
  839. "acceptance": [
  840. "未改动 6380 缓存策略。",
  841. "队列拒写和任务恢复有处理路径。",
  842. "告警使用区间指标与业务影响而非单一内存数值。"
  843. ]
  844. }
  845. },
  846. {
  847. "id": "09",
  848. "title": "配置连接池、超时与熔断并记录缓存指标",
  849. "group": "连接与故障恢复",
  850. "summary": "让 Redis 故障快速暴露,短期跳过持续失败的访问,并在恢复后用探测重新启用缓存。",
  851. "objectives": [
  852. "分别设置连接、读写和池等待超时",
  853. "实现进程内有限状态熔断示例",
  854. "记录命中、回源、错误与等待指标"
  855. ],
  856. "concepts": [
  857. {
  858. "title": "超时需要放进请求总预算",
  859. "body": "连接超时、socket 超时、池等待和重试时间会叠加。API 总预算只有一秒时,不能给每层都设置一秒再重试三遍。写命令超时可能已经执行,重试 INCR、队列入队等操作需要额外语义设计。"
  860. },
  861. {
  862. "title": "熔断短期跳过已知失败依赖",
  863. "body": "连续 Redis 连接错误达到阈值后短暂打开熔断;冷却后只允许一个探测请求,其余请求继续走受控回源。恢复探测成功才关闭。下面是线程安全的进程内教学实现,不是跨所有进程同步的全局开关。"
  864. },
  865. {
  866. "title": "命中率不是唯一目标",
  867. "body": "高命中率也可能返回陈旧数据,低命中率也可能是新内容正常访问。至少同时观察缓存调用延迟、连接错误、回源并发、数据库延迟、失效失败与版本新鲜度。计数标签不能使用每个 note_id,否则监控基数失控。"
  868. }
  869. ],
  870. "steps": [
  871. {
  872. "title": "显式配置池与关闭顺序",
  873. "body": "保存为 redis_pool_client.py。BlockingConnectionPool 让借不到连接时最多等 50 毫秒;connect/socket 各 100 毫秒,Retry(...,0) 禁止本层自动重试,避免示例将预算偷偷翻倍。同步客户端供同步代码使用,async 路由选择 redis.asyncio 并 await。",
  874. "code": "import os\nimport redis\nfrom redis.backoff import NoBackoff\nfrom redis.retry import Retry\n\npool = redis.BlockingConnectionPool(\n host=os.environ.get(\"REDIS_HOST\", \"127.0.0.1\"),\n port=int(os.environ.get(\"REDIS_PORT\", \"6380\")),\n password=os.environ[\"REDIS_PASSWORD\"],\n decode_responses=True, max_connections=8, timeout=0.05,\n socket_connect_timeout=0.1, socket_timeout=0.1,\n health_check_interval=30, retry=Retry(NoBackoff(), 0),\n)\nclient = redis.Redis(connection_pool=pool)\n\nif __name__ == \"__main__\":\n try:\n print(client.ping())\n finally:\n client.close()\n pool.disconnect()",
  875. "lang": "python",
  876. "filename": "redis_pool_client.py"
  877. },
  878. {
  879. "title": "实现线程安全的短期熔断器",
  880. "body": "保存为 redis_breaker.py。只包裹 Redis 操作;业务 SQL 和 JSON 解析错误不能自动判作 Redis 断网。AUTH 等配置错误应报警修配置,不用无限探测掩盖。调用方对返回的 bypass 标志进入第 06 课的有界回源流程。",
  881. "code": "import threading\nimport time\nfrom redis.exceptions import AuthenticationError, ConnectionError, TimeoutError\n\nclass RedisBreaker:\n def __init__(self, threshold=3, cooldown=2):\n self.threshold, self.cooldown = threshold, cooldown\n self.failures = 0\n self.open_until = 0.0\n self.probing = False\n self.generation = 0\n self.lock = threading.Lock()\n\n def call(self, operation):\n now = time.monotonic()\n with self.lock:\n if self.open_until > now or self.probing:\n return None, \"bypass\"\n if self.open_until:\n self.probing = True\n self.generation += 1\n generation = self.generation\n try:\n value = operation()\n except AuthenticationError:\n with self.lock:\n if generation == self.generation:\n self.probing = False\n raise # 配置错误直接上报,不伪装成普通缓存未命中\n except (ConnectionError, TimeoutError):\n with self.lock:\n if generation == self.generation:\n self.failures += 1\n if self.probing or self.failures >= self.threshold:\n self.open_until = time.monotonic() + self.cooldown\n self.generation += 1\n self.probing = False\n return None, \"redis-error\"\n except BaseException:\n with self.lock:\n if generation == self.generation:\n self.probing = False\n raise\n with self.lock:\n if generation == self.generation:\n self.failures, self.open_until, self.probing = 0, 0.0, False\n return value, \"redis\"",
  882. "lang": "python",
  883. "filename": "redis_breaker.py"
  884. },
  885. {
  886. "title": "不用停服务验证状态转移",
  887. "body": "保存为 redis_breaker_probe.py。注入固定抛错函数,前 3 次真实失败,第 4 次直接 bypass;冷却后成功探测,下一次正常执行。此脚本先验证顺序行为;实现中的 generation 用于丢弃旧一代在途请求对新状态的覆盖,并发测试还需验证半开阶段只启动一个探测。",
  888. "code": "import time\nfrom redis.exceptions import ConnectionError\nfrom redis_breaker import RedisBreaker\n\nbreaker = RedisBreaker(threshold=3, cooldown=0.1)\ncalls = 0\ndef broken():\n global calls\n calls += 1\n raise ConnectionError(\"simulated unavailable\")\n\nfor _ in range(4):\n print(breaker.call(broken))\nassert calls == 3\ntime.sleep(0.11)\nassert breaker.call(lambda: \"PONG\") == (\"PONG\", \"redis\")\nassert breaker.call(lambda: \"cached\") == (\"cached\", \"redis\")\nprint(\"recovered\")",
  889. "lang": "python",
  890. "filename": "redis_breaker_probe.py"
  891. },
  892. {
  893. "title": "记录实际调用状态与延迟",
  894. "body": "保存为 cache_metrics_probe.py。故障实验只停止 notes-redis 后运行,再重启后运行。下面状态为教学输出,生产导出聚合 counter/histogram,不记录密码、完整值或每条笔记作为标签。Redis miss 是 value=None 且 status=redis,不能与 bypass 合并成命中。",
  895. "code": "import os\nimport time\nfrom note_cache import key\nfrom redis_pool_client import client, pool\nfrom redis_breaker import RedisBreaker\n\nbreaker = RedisBreaker()\ntry:\n for _ in range(5):\n started = time.perf_counter()\n value, status = breaker.call(lambda: client.get(key(int(os.environ[\"NOTE_ID\"]))))\n hit = status == \"redis\" and value is not None\n print({\"status\":status,\"hit\":hit,\"duration_ms\":round((time.perf_counter()-started)*1000,2)})\nfinally:\n client.close()\n pool.disconnect()",
  896. "lang": "python",
  897. "filename": "cache_metrics_probe.py"
  898. }
  899. ],
  900. "checks": [
  901. "池等待、连接、socket 超时与重试次数均显式设置。",
  902. "失败阈值、bypass、冷却探测与恢复能通过注入实验验证。",
  903. "指标区分命中、未命中、Redis 错误和主动绕过,不记录高基数或敏感数据。"
  904. ],
  905. "pitfalls": [
  906. {
  907. "symptom": "Redis 已故障,但每个请求仍等很久",
  908. "cause": "多层超时和自动重试叠加,没有总预算。",
  909. "fix": "画出一次请求的所有等待预算,限制重试所在层;以实测故障延迟验收。"
  910. },
  911. {
  912. "symptom": "一个并发成功请求把刚打开的熔断立即关闭",
  913. "cause": "多个已在途调用同时回写熔断状态,若删除 generation 检查就会出现旧请求覆盖新状态。",
  914. "fix": "保留 generation 校验:打开熔断或进入半开时推进代次,旧代次结果不改变新状态;补充并发探测测试或使用已有成熟组件。"
  915. }
  916. ],
  917. "exercise": {
  918. "task": "为什么缓存 GET 超时可以考虑回源,而任务入队超时不能直接断言“任务没入队”?",
  919. "hint": "响应丢失不等于服务器没执行命令。",
  920. "answer": "GET 是读取,失败后可按预算回源;入队命令可能已执行但响应丢失,直接再次入队可能重复。使用任务 ID、幂等消费、投递记录与状态核对,重试策略按操作语义设计。"
  921. },
  922. "refs": [
  923. {
  924. "label": "Redis:生产客户端使用",
  925. "url": "https://redis.io/docs/latest/develop/clients/redis-py/produsage/"
  926. },
  927. {
  928. "label": "Redis:Python 错误处理",
  929. "url": "https://redis.io/docs/latest/develop/clients/redis-py/error-handling/"
  930. },
  931. {
  932. "label": "redis-py 5.3:连接池与重试参数",
  933. "url": "https://redis.readthedocs.io/en/v5.3.0/connections.html"
  934. }
  935. ],
  936. "production": {
  937. "scenario": "Redis 网络故障时,API 需要在可接受延迟内切换到受控回源,并在恢复后重新命中。",
  938. "decisions": [
  939. "超时预算以完整请求为单位计算。",
  940. "进程内熔断只负责快速跳过 Redis,回源仍受容量限制。",
  941. "通过状态代次阻止在途旧响应覆盖新状态,并对半开探测做并发验收。"
  942. ],
  943. "failureDrill": "注入连续连接错误验证熔断转移,再用专用实例停止恢复观察实际延迟和来源。",
  944. "acceptance": [
  945. "故障延迟有上限。",
  946. "没有把所有 Redis 错误当成普通 miss。",
  947. "连接池在进程退出时关闭。"
  948. ]
  949. }
  950. },
  951. {
  952. "id": "10",
  953. "title": "演练重启、复制与持久化恢复",
  954. "group": "连接与故障恢复",
  955. "summary": "对比可重建缓存与开启 AOF 的实验实例,检查副本状态,并建立故障后的业务验收顺序。",
  956. "objectives": [
  957. "验证无持久化缓存重启后的回源",
  958. "在隔离实例验证 AOF 重启恢复",
  959. "理解异步复制不等于零数据丢失或自动高可用"
  960. ],
  961. "concepts": [
  962. {
  963. "title": "RDB 与 AOF 保存不同形式",
  964. "body": "RDB 保存某个时点的快照;AOF 记录写操作并按 fsync 策略落盘。appendfsync everysec 在故障下仍有近期数据丢失窗口。正常重启看到值还在,只证明该次正常路径,不能据此宣称断电零丢失。"
  965. },
  966. {
  967. "title": "复制不是自动故障转移",
  968. "body": "Redis 主从默认异步复制,主节点确认写入时副本可能尚未收到。WAIT 可等待副本确认复制,仍不把系统变成强一致存储。生产自动故障转移由 Sentinel、Cluster 或受管平台等承担,部署拓扑与客户端发现都需独立验证。"
  969. },
  970. {
  971. "title": "恢复验收从真源开始",
  972. "body": "先确认 PostgreSQL 数据完整与可用,再检查 Redis 连接、复制角色、命中和回源限流。缓存可丢时不必恢复每一个旧键,受控重建通常更直接。任务 Broker 恢复还要验收未确认任务与重复消费,不用缓存恢复结果替代任务验收。"
  973. }
  974. ],
  975. "steps": [
  976. {
  977. "title": "确认无持久化缓存重启后可回源",
  978. "body": "使用第 02 课 NOTE_ID 预热后,记录缓存存在。执行 docker restart notes-redis,仅重启专用本机实例,然后再次读取应先 database 再 cache。配置 save \"\" 与 appendonly no 禁用了持久化,但 PostgreSQL 的笔记仍然存在。",
  979. "code": "import os\nfrom note_cache import get_note, key\nfrom redis_client import r\n\nn = int(os.environ[\"NOTE_ID\"])\nprint(get_note(n)[1])\nprint(\"cache exists\", bool(r.exists(key(n))))\n# 终端执行 docker restart notes-redis 后,再运行:\n# print(get_note(n)[1]) -> database\n# print(get_note(n)[1]) -> cache",
  980. "lang": "python",
  981. "filename": "cache_restart_probe.py"
  982. },
  983. {
  984. "title": "创建独立 AOF 实例",
  985. "body": "以下 Linux Bash 创建 notes-redis-aof,端口 6382,命名卷 notes_redis_aof 保存 /data。由已有配置生成 AOF 配置,主业务缓存 6380 保持原样。正式镜像应记录具体 7.x 版本与摘要,本课程用 redis:7 表示兼容主版本。",
  986. "code": "python3 - <<'PY'\nfrom pathlib import Path\nsource = Path('redis-lab/redis.conf').read_text()\nsource = source.replace('appendonly no', 'appendonly yes\\nappendfsync everysec')\nPath('redis-lab/aof.conf').write_text(source)\nPY\ndocker run --name notes-redis-aof -d -p 127.0.0.1:6382:6379 \\\n -v \"$PWD/redis-lab/aof.conf:/usr/local/etc/redis/redis.conf:ro\" \\\n -v notes_redis_aof:/data \\\n redis:7 redis-server /usr/local/etc/redis/redis.conf",
  987. "lang": "bash"
  988. },
  989. {
  990. "title": "验证 AOF 状态与正常重启恢复",
  991. "body": "保存为 redis_aof_probe.py。第一次运行写入样本并输出 aof_enabled、aof_last_write_status;终端 docker restart notes-redis-aof 后仅执行读取部分,不再次写入掩盖丢失。样本 TTL 10 分钟,重启后剩余时间减少;超过期限消失符合语义。",
  992. "code": "import os\nimport redis\n\nclient = redis.Redis(host=\"127.0.0.1\",port=6382,password=os.environ[\"REDIS_PASSWORD\"],\n decode_responses=True,socket_timeout=1)\nk = \"notes:lab:aof-probe\"\nclient.set(k,\"persist-me\",ex=600) # 只在首次运行执行,重启验证时移除此行\ninfo = client.info(\"persistence\")\nprint({name: info[name] for name in [\"aof_enabled\",\"aof_last_write_status\",\"aof_rewrite_in_progress\"]})\nprint(\"value\",client.get(k),\"ttl\",client.ttl(k))\nclient.close()",
  993. "lang": "python",
  994. "filename": "redis_aof_probe.py"
  995. },
  996. {
  997. "title": "建立只读副本并检查复制",
  998. "body": "为 6382 的 AOF 主实例创建本机 Docker 网络,添加无持久化副本 notes-redis-replica,宿主机 6383。配置中的 masterauth 使用同一练习密码;生产应采用专用 ACL 凭据和加密通道。副本首次同步可能需要等待,INFO replication 中 master_link_status=up 才进入验收。",
  999. "code": "docker network create notes-redis-lab\ndocker network connect notes-redis-lab notes-redis-aof\npython3 - <<'PY'\nimport os\nfrom pathlib import Path\nsource = Path('redis-lab/redis.conf').read_text()\nsource += 'replicaof notes-redis-aof 6379\\n'\nsource += f\"masterauth {os.environ['REDIS_PASSWORD']}\\nreplica-read-only yes\\n\"\nPath('redis-lab/replica.conf').write_text(source)\nPY\ndocker run --name notes-redis-replica --network notes-redis-lab -d \\\n -p 127.0.0.1:6383:6379 \\\n -v \"$PWD/redis-lab/replica.conf:/usr/local/etc/redis/redis.conf:ro\" \\\n redis:7 redis-server /usr/local/etc/redis/redis.conf",
  1000. "lang": "bash"
  1001. },
  1002. {
  1003. "title": "核对复制、主节点故障和恢复边界",
  1004. "body": "保存为 redis_replica_probe.py。WAIT 需要在同一连接跟随写入,pipeline(transaction=False) 保持同一连接发送两个命令。返回副本确认数至少 1 后读取副本,正常条件下得到新值。随后只停止 notes-redis-aof,副本不会自动升主;检查链路转为 down 后恢复主实例。演练结束停止 aof 与 replica 两个专用实例。",
  1005. "code": "import os\nimport redis\n\noptions = dict(host=\"127.0.0.1\",password=os.environ[\"REDIS_PASSWORD\"],decode_responses=True,\n socket_timeout=2,socket_connect_timeout=1)\nprimary = redis.Redis(port=6382,**options)\nreplica = redis.Redis(port=6383,**options)\ntry:\n print(\"replica\",replica.info(\"replication\")[\"master_link_status\"])\n with primary.pipeline(transaction=False) as pipe:\n pipe.set(\"notes:lab:replica-probe\",\"replicated\",ex=600)\n pipe.wait(1,1000)\n written,acknowledged = pipe.execute()\n print(\"acknowledged\",acknowledged)\n assert acknowledged >= 1\n print(\"replica value\",replica.get(\"notes:lab:replica-probe\"))\nfinally:\n primary.close()\n replica.close()\n\n# Linux 故障操作仅针对专用实例:docker stop notes-redis-aof\n# 检查副本 INFO replication;不会自动升主。\n# 恢复:docker start notes-redis-aof\n# 演练结束:docker stop notes-redis-aof notes-redis-replica",
  1006. "lang": "python",
  1007. "filename": "redis_replica_probe.py"
  1008. }
  1009. ],
  1010. "checks": [
  1011. "6380 重启清空缓存后,业务笔记能从 PostgreSQL 读取并重建。",
  1012. "6382 AOF 正常重启后样本仍可读取,TTL 正常减少,持久化状态无错误。",
  1013. "副本连接正常时能读取复制值,主节点停止后不误认为已实现自动故障转移。"
  1014. ],
  1015. "pitfalls": [
  1016. {
  1017. "symptom": "启动了副本就宣称零丢失高可用",
  1018. "cause": "忽略异步复制窗口、自动选主与客户端切换。",
  1019. "fix": "分别验证复制延迟、故障转移组件和客户端发现;关键业务状态保存在适合其可靠性要求的真源。"
  1020. },
  1021. {
  1022. "symptom": "重启恢复测试总成功,实际上每次都重新 SET",
  1023. "cause": "验收脚本在读取之前又写入样本,掩盖持久化失败。",
  1024. "fix": "将写入阶段与重启后只读验收分开,记录初始值、时间、TTL 和持久化状态。"
  1025. },
  1026. {
  1027. "symptom": "副本长期显示 master_link_status=down",
  1028. "cause": "Docker 网络、主实例名称或 masterauth 不匹配。",
  1029. "fix": "检查主实例是否加入 notes-redis-lab、名称是否一致,以及双方认证配置;不要通过关掉认证规避问题。"
  1030. }
  1031. ],
  1032. "exercise": {
  1033. "task": "为缓存实例故障写一份最短恢复顺序,并说明是否必须先恢复所有旧缓存键。",
  1034. "hint": "先保证真源和受控回源,再逐步恢复缓存。",
  1035. "answer": "确认 PostgreSQL 正常,启用回源限流;检查 Redis 实例身份、认证和配置;恢复连接后小批次预热热点并观察错误率与数据库延迟。可重建缓存不必恢复所有旧键。任务 Broker 需要额外检查积压、未确认任务和重复消费,不能沿用此简化流程。"
  1036. },
  1037. "refs": [
  1038. {
  1039. "label": "Redis:持久化",
  1040. "url": "https://redis.io/docs/latest/operate/oss_and_stack/management/persistence/"
  1041. },
  1042. {
  1043. "label": "Redis:复制",
  1044. "url": "https://redis.io/docs/latest/operate/oss_and_stack/management/replication/"
  1045. },
  1046. {
  1047. "label": "Redis:WAIT",
  1048. "url": "https://redis.io/docs/latest/commands/wait/"
  1049. },
  1050. {
  1051. "label": "Redis:Sentinel",
  1052. "url": "https://redis.io/docs/latest/operate/oss_and_stack/management/sentinel/"
  1053. }
  1054. ],
  1055. "production": {
  1056. "scenario": "缓存节点需要维护重启,团队必须知道数据会丢在哪里、服务如何继续以及恢复验收是什么。",
  1057. "decisions": [
  1058. "按数据用途决定是否持久化。",
  1059. "正常重启、异常断电与主从切换分开测试。",
  1060. "复制不是数据库真源和任务幂等性的替代。"
  1061. ],
  1062. "failureDrill": "仅重启或停止 6380/6382/6383 这些本机专用实例,分别记录回源、AOF 和复制状态,再恢复或停止实验环境。",
  1063. "acceptance": [
  1064. "真实笔记不丢失。",
  1065. "没有宣称 everysec 零丢失。",
  1066. "未部署自动选主时明确记录为手动恢复实验。"
  1067. ]
  1068. }
  1069. }
  1070. ]