<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
  <channel>
    <title>召田最帅boy</title>
    <link>https://www.hqxiaozou.top</link>
    <description>召田最帅boy 的个人博客：Java 后端开发、AI 应用实践、线上问题排查笔记，以及生活里的点点滴滴。</description>
    <language>zh-CN</language>
    <item>
      <title>炊想小程序帮你把一道菜配成一桌饭</title>
      <link>https://www.hqxiaozou.top/post/bhEgnGf1Nq6</link>
      <description>&lt;p&gt;&lt;strong&gt;更新于 2026-11-07。&lt;/strong&gt; 本文按新版界面整理，截图中的菜谱、账户和剩余次数为演示数据，实际内容以小程序内显示为准。&lt;/p&gt;&#xD;
&lt;blockquote&gt;&#xD;
&lt;p&gt;「今天吃什么？」不只是在想一道菜，还要想怎么配、缺什么、几个人吃、什么时候开始做。炊想把这些事放在一起：说一道菜，配成一桌饭。&lt;/p&gt;&#xD;
&lt;/blockquote&gt;&#xD;
&lt;h2 id="-"&gt;一、从今天这一餐开始&lt;/h2&gt;&#xD;
&lt;p&gt;打开炊想，首页围绕这一餐展开：上方是「搜菜名」和「搭配」，下面是偏好摘要，以及「找灵感」「用食材想菜」「安排菜单」三个入口。往下是「这顿试试」和「大家都在做」。点开推荐菜，就能看现成做法，不用登录，也不扣次数。「这顿试试」从站内菜谱中挑选，每 15 分钟更新一轮推荐；「全部菜谱」还可以按菜系、最新和最热继续挑。同一道菜的多个版本不会挤满列表。&lt;/p&gt;&#xD;
&lt;p&gt;自己有想法，就输入菜名；没有想法，点「找灵感」，先挑一道喜欢的。可以按「随便看看」「20 分钟内」「清淡一点」「下饭菜」筛选。查看和换一组都不扣次数，点中已有菜谱就直接看做法；需要按自己的口味重新搭配时，再确认使用相应次数。&lt;/p&gt;&#xD;
&lt;p&gt;&lt;img src="https://niu.hqxiaozou.top/img/a6cc652b-cf96-492d-bb4d-23b77cae53fb.png" alt="首页推荐与选菜" width="390" loading="lazy"&gt;&lt;/p&gt;&#xD;
&lt;p&gt;搜索框下方的「调整」先展示最常用的几项：&lt;strong&gt;几个人吃、有什么忌口、这餐谁来吃、能花多少时间做饭&lt;/strong&gt;。菜系、口味、饮食目标和厨具可以展开设置。家人口味档案选中后会一起用于搭配，当餐人员和做饭时间跟着对应厨房保存。&lt;/p&gt;&#xD;
&lt;p&gt;&lt;img src="https://niu.hqxiaozou.top/img/d3bd7c98-1a4f-439a-a5da-621f412cf353.png" alt="这一餐的偏好与灵感选菜" width="390" loading="lazy"&gt;&lt;/p&gt;&#xD;
&lt;h2 id="-"&gt;二、先看清做法，再决定这一桌怎么搭&lt;/h2&gt;&#xD;
&lt;p&gt;输入「红烧肉」「不辣的水煮鱼」或「空气炸锅鸡翅」，炊想会给出食材用量、分步做法、火候与时间、小贴士和营养参考，再附上成品示意图。&lt;/p&gt;&#xD;
&lt;p&gt;详情页上方集中放成品图、菜名、用时、份量、难度和菜系，下面用「食材」「做法」「配餐」快速定位内容。底部保留「收藏」「分享」「开始做饭」，读到哪里都能继续操作。&lt;/p&gt;&#xD;
&lt;p&gt;「一起上桌」先展示&lt;strong&gt;配菜和汤品&lt;/strong&gt;，主食与饮品可以展开查看。每一项都说明为什么这么配，看中哪道配菜也可以点进去继续看做法。&lt;/p&gt;&#xD;
&lt;p&gt;站内已有的菜会在输入时联想出来，「全部菜谱」也能按菜系、最新、最热浏览。现成做法点开就看，不扣搭配次数；同一道菜只展示一张代表卡片，进入详情后点击右上角的做法数量，在「选择做法」里切换版本，收藏和分享仍对应你选中的那一版。&lt;/p&gt;&#xD;
&lt;p&gt;&lt;img src="https://niu.hqxiaozou.top/img/c7e52c1e-a86e-4b54-aabb-513c8d4e1251.png" alt="现成菜谱与完整做法" width="390" loading="lazy"&gt;&lt;/p&gt;&#xD;
&lt;p&gt;新做法需要一点时间。等待时可以先去逛逛，首页会保留正在搭配的入口；切到微信聊天、断网或退出后回来，会继续查询原来的任务并查看结果，不会因为再次进入就重新生成、重复扣次数。菜单、按食材想菜和问做法也会保留相应的进行中入口；请求失败后按页面提示恢复或重试，生成失败不会扣掉本次对应额度。&lt;/p&gt;&#xD;
&lt;h2 id="-"&gt;三、不合口味，改一点就好&lt;/h2&gt;&#xD;
&lt;p&gt;详情里的「换个做法」有两种用法：&lt;/p&gt;&#xD;
&lt;ul&gt;&#xD;
&lt;li&gt;&lt;strong&gt;口味与人数&lt;/strong&gt;：改好偏好后重新搭配。&lt;/li&gt;&#xD;
&lt;li&gt;&lt;strong&gt;换一版做法&lt;/strong&gt;：参照刚才那一版，换一种做法和搭配，原来的版本保留。&lt;/li&gt;&#xD;
&lt;/ul&gt;&#xD;
&lt;p&gt;只想少辣、少油、换厨具或缩短准备时间，用「改一改」提出具体要求就行；也可以自己写一句「不要放糖」「多放点蒜」。改一改与搭配分别计次，免费每 24 小时 3 次，会员 30 次。&lt;/p&gt;&#xD;
&lt;p&gt;成品图不满意，可以点详情上方的「换图」，在「换一张配图」里说明想怎么改，例如「换个盘子，保留原来的摆盘」，也可以添加参考图。多张配图先切换到要修改的那一张，再继续调整；点图可以查看大图和图片操作。标注、蒙版与特殊格式是否可直接修改，以编辑面板提示为准。&lt;/p&gt;&#xD;
&lt;p&gt;自己搭的菜每道免费可换 2 次、会员 5 次。重做时原图仍可看，没换成功会保留原图并退回相应次数；首张图片没准备好时，按页面提供的补拍或刷新入口处理。图片用于示意，实际成品会受食材、用量和锅灶影响。&lt;/p&gt;&#xD;
&lt;p&gt;&lt;img src="https://niu.hqxiaozou.top/img/e1d5fe8d-7e83-44a7-be85-411e3a5dae06.png" alt="换一张配图：修改描述与参考图" width="390" loading="lazy"&gt;&lt;/p&gt;&#xD;
&lt;p&gt;做着做着有疑问，还可以点「问做法」：没有蚝油用什么替代、这一步怎么判断熟了、用量怎么调整，都能结合当前菜谱提问，并查看之前的问答。免费每 24 小时 3 问，会员 30 问。&lt;/p&gt;&#xD;
&lt;h2 id="-"&gt;四、用手边的食材想菜&lt;/h2&gt;&#xD;
&lt;p&gt;首页点「用食材想菜」，有三种添加方式：&lt;/p&gt;&#xD;
&lt;ul&gt;&#xD;
&lt;li&gt;&lt;strong&gt;手动输入&lt;/strong&gt;：用空格或逗号分开，或者点选常见食材，最多 12 样。&lt;/li&gt;&#xD;
&lt;li&gt;&lt;strong&gt;从库存选&lt;/strong&gt;：先在「我的厨房」记下食材，再勾选这次想用的。&lt;/li&gt;&#xD;
&lt;li&gt;&lt;strong&gt;拍照添加&lt;/strong&gt;：拍一张或从相册选一张食材照片，识别后自己核对。&lt;/li&gt;&#xD;
&lt;/ul&gt;&#xD;
&lt;p&gt;推荐结果会列出用了哪些、还缺哪些，以及大概要多久、难不难。站内已有做法的直接打开，没有的再确认搭配。推荐与搭配各算各的，免费每 24 小时可用 3 次，会员 30 次。&lt;/p&gt;&#xD;
&lt;p&gt;&lt;img src="https://niu.hqxiaozou.top/img/12356593-f37e-4715-88a9-266523b7255d.png" alt="用食材想菜" width="390" loading="lazy"&gt;&lt;/p&gt;&#xD;
&lt;p&gt;拍照识别免费每 24 小时 1 次、会员 5 次。照片先经过微信审核，通过后才进入识别；识别完要核对名称，录入库存时还要确认数量和单位，不能确定的数量自己填写，不会直接替你写进库存。&lt;/p&gt;&#xD;
&lt;h2 id="-"&gt;五、我的厨房，记住家里怎么吃&lt;/h2&gt;&#xD;
&lt;p&gt;「我的」现在优先展示收藏和最近看过的菜谱，上方保留「我的厨房」「我的菜单」「买菜清单」三个快捷入口。「我的厨房」分成&lt;strong&gt;口味档案、冰箱库存、做过的菜&lt;/strong&gt;三个页签：&lt;/p&gt;&#xD;
&lt;ul&gt;&#xD;
&lt;li&gt;&lt;strong&gt;家人口味&lt;/strong&gt;：记下每个人的忌口、口味和饮食目标，选对当餐人员，下次搭配就能用上。免费可存 2 份，会员 6 份。&lt;/li&gt;&#xD;
&lt;li&gt;&lt;strong&gt;冰箱库存&lt;/strong&gt;：记录食材、数量、单位和到期日期，临期与过期会有提示。免费可存 20 样，会员 100 样。&lt;/li&gt;&#xD;
&lt;li&gt;&lt;strong&gt;做过的菜&lt;/strong&gt;：做完记下口味和下次想调整的地方，方便以后再做。&lt;/li&gt;&#xD;
&lt;/ul&gt;&#xD;
&lt;p&gt;&lt;img src="https://niu.hqxiaozou.top/img/6d50aee8-493f-4618-a7a7-9f04a905cd17.png" alt="口味档案与冰箱库存" width="390" loading="lazy"&gt;&lt;/p&gt;&#xD;
&lt;p&gt;会员可以邀请家人一起使用厨房，最多 &lt;strong&gt;6 个账号&lt;/strong&gt;共享口味、库存、菜单和采购清单，也能分配谁买哪样菜。家庭操作使用对应厨房的权益和额度，不是每位成员各获得一套会员次数。创建者会员到期后，已有记录仍可查看、编辑和删除；新增共享内容与邀请家人需要有效会员。&lt;/p&gt;&#xD;
&lt;h2 id="-"&gt;六、先安排菜单，再合并买菜&lt;/h2&gt;&#xD;
&lt;p&gt;「安排菜单」支持两种场景：&lt;/p&gt;&#xD;
&lt;ul&gt;&#xD;
&lt;li&gt;&lt;strong&gt;日常菜单&lt;/strong&gt;：选择 3 / 5 / 7 天和人数，免费可以排晚餐，会员可安排一日三餐；尽量轮换主料和做法，也会考虑食材复用。&lt;/li&gt;&#xD;
&lt;li&gt;&lt;strong&gt;请客菜单&lt;/strong&gt;：选择人数、菜数和场合，主食另配、不占菜数，附上上桌顺序和备菜时间线。&lt;/li&gt;&#xD;
&lt;/ul&gt;&#xD;
&lt;p&gt;两种菜单合计，免费每 24 小时 1 份、会员 10 份，不占搭配次数。已有菜单可以「沿用这次选择」，也可以只换其中一道，不必整份重排；菜单换菜免费每 24 小时 1 次，会员 30 次。&lt;/p&gt;&#xD;
&lt;p&gt;&lt;img src="https://niu.hqxiaozou.top/img/f7c9b3f9-a755-4579-9808-4f4d540e01a7.png" alt="日常与请客菜单" width="390" loading="lazy"&gt;&lt;/p&gt;&#xD;
&lt;p&gt;菜单的采购项可以合并进个人买菜清单，同一份菜单重复导入不会重复累加。菜谱也可以逐道加入「这一餐」，需要买的食材合并后勾选、复制；个人与家庭清单在同一页切换，个人清单登录后会同步到账号。&lt;/p&gt;&#xD;
&lt;p&gt;&lt;img src="https://niu.hqxiaozou.top/img/d900527a-ce0b-4b60-b9fb-0a1ac9641325.png" alt="买菜清单：采购项与菜谱来源" width="390" loading="lazy"&gt;&lt;/p&gt;&#xD;
&lt;p&gt;出门买菜前，可以核对家里已有的食材。只会推荐同名、未过期、数量足够且单位可比较的库存，&lt;strong&gt;确认后标记为已备好，不会自动扣减库存&lt;/strong&gt;。实际用了多少，做完后再确认记录。&lt;/p&gt;&#xD;
&lt;h2 id="-"&gt;七、从一张清单，到一桌饭上桌&lt;/h2&gt;&#xD;
&lt;p&gt;一道菜直接点「开始做饭」，进入大字分步模式：可以左右翻步骤，或用底部「上一步 / 下一步」前进；需要计时的步骤单独倒计时，屏幕保持常亮，食材用量随时打开。计时过程中仍能看其他步骤，也能回到正在计时的那一步；中途返回后可以继续做。&lt;/p&gt;&#xD;
&lt;p&gt;多道菜可以一起安排顺序，准备工作、各道菜的步骤和计时放在一处。每个账号可免费体验一次多菜统筹，成功安排才计入，失败不计；会员可以持续使用，近期已经安排好的体验也能接着看。&lt;/p&gt;&#xD;
&lt;p&gt;&lt;img src="https://niu.hqxiaozou.top/img/67da55a6-5476-42bb-baf8-5cc6962406cd.png" alt="分步做菜与厨房记录" width="390" loading="lazy"&gt;&lt;/p&gt;&#xD;
&lt;p&gt;做完后可以记录口味，并确认实际食材用量。收藏、历史和做过的记录，让这道菜下次还能找得到。&lt;/p&gt;&#xD;
&lt;h2 id="-"&gt;八、免费、会员和搭配次数包&lt;/h2&gt;&#xD;
&lt;p&gt;「会员与次数」已经放到「我的」页面的独立入口，可直接看当前可用次数，不必再到设置里寻找。会员页分为「开通会员」和「补充次数」，完整权益对比可以展开查看。&lt;/p&gt;&#xD;
&lt;p&gt;现成菜谱、饮食目标、厨具、个人买菜清单、单菜做饭都能免费用。会员增加容量和各项额度，并提供三餐计划、多菜统筹及家庭共享：&lt;/p&gt;&#xD;
&lt;table&gt;&#xD;
&lt;thead&gt;&#xD;
&lt;tr&gt;&#xD;
&lt;th&gt;功能&lt;/th&gt;&#xD;
&lt;th&gt;免费&lt;/th&gt;&#xD;
&lt;th&gt;会员&lt;/th&gt;&#xD;
&lt;/tr&gt;&#xD;
&lt;/thead&gt;&#xD;
&lt;tbody&gt;&#xD;
&lt;tr&gt;&#xD;
&lt;td&gt;搭配 / 每 24 小时&lt;/td&gt;&#xD;
&lt;td&gt;3 次&lt;/td&gt;&#xD;
&lt;td&gt;30 次&lt;/td&gt;&#xD;
&lt;/tr&gt;&#xD;
&lt;tr&gt;&#xD;
&lt;td&gt;改一改 / 每 24 小时&lt;/td&gt;&#xD;
&lt;td&gt;3 次&lt;/td&gt;&#xD;
&lt;td&gt;30 次&lt;/td&gt;&#xD;
&lt;/tr&gt;&#xD;
&lt;tr&gt;&#xD;
&lt;td&gt;用食材想菜 / 每 24 小时&lt;/td&gt;&#xD;
&lt;td&gt;3 次&lt;/td&gt;&#xD;
&lt;td&gt;30 次&lt;/td&gt;&#xD;
&lt;/tr&gt;&#xD;
&lt;tr&gt;&#xD;
&lt;td&gt;日常与请客菜单合计 / 每 24 小时&lt;/td&gt;&#xD;
&lt;td&gt;1 份&lt;/td&gt;&#xD;
&lt;td&gt;10 份&lt;/td&gt;&#xD;
&lt;/tr&gt;&#xD;
&lt;tr&gt;&#xD;
&lt;td&gt;问做法 / 每 24 小时&lt;/td&gt;&#xD;
&lt;td&gt;3 问&lt;/td&gt;&#xD;
&lt;td&gt;30 问&lt;/td&gt;&#xD;
&lt;/tr&gt;&#xD;
&lt;tr&gt;&#xD;
&lt;td&gt;拍照识别 / 每 24 小时&lt;/td&gt;&#xD;
&lt;td&gt;1 次&lt;/td&gt;&#xD;
&lt;td&gt;5 次&lt;/td&gt;&#xD;
&lt;/tr&gt;&#xD;
&lt;tr&gt;&#xD;
&lt;td&gt;菜单换菜 / 每 24 小时&lt;/td&gt;&#xD;
&lt;td&gt;1 次&lt;/td&gt;&#xD;
&lt;td&gt;30 次&lt;/td&gt;&#xD;
&lt;/tr&gt;&#xD;
&lt;tr&gt;&#xD;
&lt;td&gt;口味档案&lt;/td&gt;&#xD;
&lt;td&gt;2 份&lt;/td&gt;&#xD;
&lt;td&gt;6 份&lt;/td&gt;&#xD;
&lt;/tr&gt;&#xD;
&lt;tr&gt;&#xD;
&lt;td&gt;库存食材&lt;/td&gt;&#xD;
&lt;td&gt;20 样&lt;/td&gt;&#xD;
&lt;td&gt;100 样&lt;/td&gt;&#xD;
&lt;/tr&gt;&#xD;
&lt;tr&gt;&#xD;
&lt;td&gt;自己搭的菜换图&lt;/td&gt;&#xD;
&lt;td&gt;每道菜 2 次&lt;/td&gt;&#xD;
&lt;td&gt;每道菜 5 次&lt;/td&gt;&#xD;
&lt;/tr&gt;&#xD;
&lt;tr&gt;&#xD;
&lt;td&gt;三餐与统筹&lt;/td&gt;&#xD;
&lt;td&gt;晚餐菜单、单菜做饭，多菜统筹体验一次&lt;/td&gt;&#xD;
&lt;td&gt;一日三餐、多菜统筹&lt;/td&gt;&#xD;
&lt;/tr&gt;&#xD;
&lt;tr&gt;&#xD;
&lt;td&gt;家庭共享&lt;/td&gt;&#xD;
&lt;td&gt;可受邀加入&lt;/td&gt;&#xD;
&lt;td&gt;可邀请家人，最多 6 个账号&lt;/td&gt;&#xD;
&lt;/tr&gt;&#xD;
&lt;/tbody&gt;&#xD;
&lt;/table&gt;&#xD;
&lt;p&gt;每 24 小时按滚动窗口恢复，不是在零点统一清空；容量和每道菜的换图次数另算。各功能分别计次，用完时会显示相应提示。&lt;/p&gt;&#xD;
&lt;table&gt;&#xD;
&lt;thead&gt;&#xD;
&lt;tr&gt;&#xD;
&lt;th&gt;商品&lt;/th&gt;&#xD;
&lt;th&gt;价格&lt;/th&gt;&#xD;
&lt;th&gt;内容&lt;/th&gt;&#xD;
&lt;/tr&gt;&#xD;
&lt;/thead&gt;&#xD;
&lt;tbody&gt;&#xD;
&lt;tr&gt;&#xD;
&lt;td&gt;月卡&lt;/td&gt;&#xD;
&lt;td&gt;¥18&lt;/td&gt;&#xD;
&lt;td&gt;30 天会员权益&lt;/td&gt;&#xD;
&lt;/tr&gt;&#xD;
&lt;tr&gt;&#xD;
&lt;td&gt;季卡&lt;/td&gt;&#xD;
&lt;td&gt;¥45&lt;/td&gt;&#xD;
&lt;td&gt;90 天会员权益&lt;/td&gt;&#xD;
&lt;/tr&gt;&#xD;
&lt;tr&gt;&#xD;
&lt;td&gt;搭配次数包&lt;/td&gt;&#xD;
&lt;td&gt;¥6 / ¥18&lt;/td&gt;&#xD;
&lt;td&gt;15 次 / 50 次搭配，一年内有效&lt;/td&gt;&#xD;
&lt;/tr&gt;&#xD;
&lt;/tbody&gt;&#xD;
&lt;/table&gt;&#xD;
&lt;p&gt;月卡与季卡权益相同，会员卡可以叠加、到期顺延。搭配先用免费额度，再用会员额度，最后用搭配次数包，包内快到期的次数优先使用。&lt;/p&gt;&#xD;
&lt;p&gt;&lt;strong&gt;搭配次数包只补充搭配次数&lt;/strong&gt;，不会开通会员功能，也不增加菜单、问答、拍照或换菜的额度。安卓和 iPhone 都可在小程序内购买，iPhone 需要微信 8.0.68 及以上版本。&lt;/p&gt;&#xD;
&lt;p&gt;&lt;img src="https://niu.hqxiaozou.top/img/c5413977-9d21-4c66-8a02-238aa41a2a71.png" alt="会员权益与搭配次数包" width="390" loading="lazy"&gt;&lt;/p&gt;&#xD;
&lt;h2 id="-"&gt;九、登录、订单与反馈&lt;/h2&gt;&#xD;
&lt;p&gt;无需注册或设置密码。在登录页阅读并勾选《用户协议与隐私说明》后，点「微信一键登录」即可使用；协议默认不勾选，未勾选时不会发起登录。这个微信以前用过，就回到原来的账号；第一次使用才会建号。仅看别人分享的菜谱、逛「全部菜谱」，不用先登录。&lt;/p&gt;&#xD;
&lt;p&gt;账号服务集中在「我的」右上角的齿轮按钮，打开「账户与设置」即可修改昵称、查看订单、阅读关于与说明、提交反馈或退出登录。&lt;/p&gt;&#xD;
&lt;p&gt;&lt;img src="https://niu.hqxiaozou.top/img/37b6db96-8553-4dcc-bef3-43ab258e04a8.png" alt="我的页面：会员入口、厨房菜单清单与收藏历史" width="390" loading="lazy"&gt;&lt;/p&gt;&#xD;
&lt;p&gt;在「我的」底部可以邀请好友、查看邀请记录。新朋友从分享入口进入并首次完成微信登录后，双方可以获得搭配奖励；奖励次数、有效期与上限以邀请页显示为准。&lt;/p&gt;&#xD;
&lt;p&gt;订单按待支付、已支付、已关闭分组，未完成的订单在有效期内可以继续处理。付款后若还在确认到账，先查看原订单；登录失效时按提示重新登录，再回来确认，不需要另下一单。已写的反馈会保留草稿，回复可在反馈记录中查看，详情页也有「反馈菜谱问题」入口。&lt;/p&gt;&#xD;
&lt;h2 id="-"&gt;十、立即体验&lt;/h2&gt;&#xD;
&lt;p&gt;&lt;img src="https://niu.hqxiaozou.top/img/chuixiang-mini-program-430.jpg" alt="炊想" width="320" loading="lazy"&gt;&lt;/p&gt;&#xD;
&lt;blockquote&gt;&#xD;
&lt;p&gt;微信扫码，或搜索「炊想」即可使用。本文界面与使用说明更新于 2026-11-07，具体可用功能、次数与商品价格以小程序内显示为准。&lt;/p&gt;&#xD;
&lt;/blockquote&gt;&#xD;
&lt;p&gt;做法、用量与热量仅供参考，食材过敏和特殊饮食请自行核对；涉及特殊健康需求时以医嘱为准。&lt;/p&gt;&#xD;
&lt;p&gt;&lt;strong&gt;今晚，你想做哪一道？&lt;/strong&gt;&lt;/p&gt;</description>
      <pubDate>Mon, 05 Oct 2026 03:52:10 GMT</pubDate>
    </item>
    <item>
      <title>博客接入 GPT Image 2.6，如何处理 GIF、连续改图和降级</title>
      <link>https://www.hqxiaozou.top/post/blog-gpt-image-26</link>
      <description>&lt;p&gt;给博客的留言助手加上 GPT Image 2.6 以后，联调发现了一个小尺寸 GIF 的问题。测试请求指定生成 &lt;code&gt;256×256&lt;/code&gt;、5 帧的动画，OpenAI 返回了 &lt;code&gt;400&lt;/code&gt;。当时应用把它归为“服务暂不可用”，菜谱后台还重试了一次。尺寸和帧数原封不动，再等半分钟当然也不会出图。&lt;/p&gt;
&lt;p&gt;这次接入涉及文章助手和留言助手。用到的是 GPT Image 2.6 Sunburst，默认 &lt;code&gt;quality=max&lt;/code&gt;、&lt;code&gt;complexity=high&lt;/code&gt;。接入时，我把请求意图、原格式保存和供应商降级分开处理。这三处都能通过具体的请求和返回结果来验证。&lt;/p&gt;
&lt;h2 id="一句把上一张改一下应该怎么执行"&gt;一句“把上一张改一下”应该怎么执行&lt;/h2&gt;
&lt;p&gt;留言里提到图片，有可能是在问“这张图为什么是空的”，也可能是在要求“把盘子换成蓝色”。如果看到“图片”就直接调用生图，文字问题会被误转成一次昂贵的请求。博客先用文字模型给出结构化判断，再由 Java 代码校验并执行。只有当前留言确实要求生成或编辑时，才会进入图片接口。&lt;/p&gt;
&lt;p&gt;下面是一条静态改图的执行计划，省略了审核分数等辅助字段。&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-json"&gt;{
  &amp;quot;action&amp;quot;: &amp;quot;edit&amp;quot;,
  &amp;quot;reference_indices&amp;quot;: [2],
  &amp;quot;image_options&amp;quot;: { &amp;quot;output_format&amp;quot;: &amp;quot;png&amp;quot; }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这段计划表示编辑候选列表中的第二张图。候选列表由服务端从这次留言的附件和被回复的图片构造，模型只能选编号，不能填一个外部 URL 让后端下载。计划还要通过审核、编号校验和额度检查，才会带着已读取的图片调用 &lt;code&gt;/v1/images/edits&lt;/code&gt;。纯文字生成走 &lt;code&gt;/v1/images/generations&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;连续改图时，每次回复都以用户所回复的那一轮结果为准。比如先把蓝茶壶改成红色，再回复红茶壶那条要求“加一片柠檬”。第二次编辑应从红茶壶出发。实测中，我核对了后一轮输入与前一轮输出的 SHA-256，并目检确认红色保留、柠檬出现。这两个检查分别回答了“选对了图吗”和“这次编辑做到了吗”。&lt;/p&gt;
&lt;p&gt;对局部编辑，接口还有两种引导方式。可以提交画有标记的透明 PNG，也可以提交透明 PNG 蒙版。应用要求引导图和目标原图像素尺寸一致，一次只接收其中一种；提交时把目标原图放在第一张。测试用同一张包含蓝圆与红方块的原图分别走两条路径，两次都把蓝圆改绿并保留了旁边的红方块。模型会重新渲染图像，需要像素级无损保留的场景仍要按输出检查。&lt;/p&gt;
&lt;h2 id="按原格式保存-gif-和-svg"&gt;按原格式保存 GIF 和 SVG&lt;/h2&gt;
&lt;p&gt;普通静态图通常用 PNG，照片也可以指定 JPEG 或 WebP。GIF 和 SVG 有独立约束。当前版本允许按文字生成这两种格式，直接编辑 GIF 或 SVG 则返回明确提示。SVG 生图只允许一张。官方接口返回 Base64，存储时按实际字节识别媒体类型，再用匹配的扩展名和 Content-Type 上传，避免浏览器收到名为 GIF 的静态图。&lt;/p&gt;
&lt;p&gt;&lt;img src="https://niu.hqxiaozou.top/img/aca3286c-9f97-4472-b42e-2bb030838642.gif?raw=1" alt="GPT Image 2.6 Sunburst 实测生成的九帧循环动画"&gt;&lt;/p&gt;
&lt;p&gt;这张 GIF 的画布是 &lt;code&gt;512×512&lt;/code&gt;，共 9 帧，每帧 200 毫秒，总时长 1.8 秒。发布验收不仅检查图片能打开，也读取了 GIF 帧结构。只检查 HTTP 200 或文件后缀，很难发现图床缩略处理已经把动画压成了首帧。&lt;/p&gt;
&lt;p&gt;SVG 的处理比改后缀多一步。服务端用 XML 解析器检查它是否是静态矢量，拒绝脚本、事件属性和外部资源等内容，再按 &lt;code&gt;image/svg+xml&lt;/code&gt; 保存。文章助手这次生成的是下面的叶片图标，线上验收读取到的是原始 SVG。&lt;/p&gt;
&lt;p&gt;&lt;img src="https://niu.hqxiaozou.top/img/4912a4ec-89ff-47d0-bc37-c785b76c89bc.svg?raw=1" alt="博客文章助手实际生成的青绿叶片 SVG"&gt;&lt;/p&gt;
&lt;p&gt;发布这篇文章时还发现了一层容易漏掉的转换。博客正文默认会给图床链接追加 WebP 缩略参数，源文件保存在图床上和浏览器实际加载到原格式是两回事。本文两张样图使用保留原文件的链接，验收时检查页面里的图片地址，再对照图床返回的 MIME 和字节。&lt;/p&gt;
&lt;p&gt;在请求里，&lt;code&gt;quality&lt;/code&gt; 控制渲染质量，&lt;code&gt;complexity&lt;/code&gt; 控制出图前的规划量。两者互相独立。站内默认用 &lt;code&gt;max&lt;/code&gt; 和 &lt;code&gt;high&lt;/code&gt;，尺寸仍按请求选择；提高画质不会把一张小图自动变成大图。SVG 还可以指定负责矢量绘制的文字模型，本项目把它固定为 GPT-6.1 Sol。&lt;/p&gt;
&lt;h2 id="gif-的限制发生在拼版阶段"&gt;GIF 的限制发生在拼版阶段&lt;/h2&gt;
&lt;p&gt;这个案例容易误判，因为 &lt;code&gt;256×256&lt;/code&gt; 对单帧来说看着完全合理。但模型需要把多帧排进一张画布再编码。实测 &lt;code&gt;256×256&lt;/code&gt;、5 帧时，上游尝试的 &lt;code&gt;1024×512&lt;/code&gt; 和 &lt;code&gt;512×1024&lt;/code&gt; 拼版都低于它当时的最小像素要求，因此返回 &lt;code&gt;image_generation_user_error&lt;/code&gt;，并把 &lt;code&gt;param&lt;/code&gt; 指向 &lt;code&gt;size&lt;/code&gt;。能否拼版取决于尺寸和帧数的组合，同一个单帧尺寸换一组帧数，校验结果也可能不同。&lt;/p&gt;
&lt;p&gt;下面是已经实测成功的一组请求参数。若只在意动画内容，可用自动尺寸，把拼版选择交给接口。&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-json"&gt;{
  &amp;quot;model&amp;quot;: &amp;quot;gpt-image-2.6-sunburst&amp;quot;,
  &amp;quot;prompt&amp;quot;: &amp;quot;一只橘猫在白色背景上慢慢眨眼，最后回到起始姿态，循环播放，无文字&amp;quot;,
  &amp;quot;output_format&amp;quot;: &amp;quot;gif&amp;quot;,
  &amp;quot;size&amp;quot;: &amp;quot;512x512&amp;quot;,
  &amp;quot;frame_count&amp;quot;: 9,
  &amp;quot;frame_duration_ms&amp;quot;: 200,
  &amp;quot;quality&amp;quot;: &amp;quot;max&amp;quot;,
  &amp;quot;complexity&amp;quot;: &amp;quot;high&amp;quot;,
  &amp;quot;n&amp;quot;: 1
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;问题修复后，应用根据上游的错误类型和参数定位问题，再给用户一条可操作的提示。它不会原样把上游错误正文转给用户，也不会因为某一组尺寸无效就把整条官方线路标记为不可用。回归测试先模拟参数错误，再提交合法请求，验证后续请求不会被冷却拦截；线上则分别验证了合法 GIF 和这组无效参数。&lt;/p&gt;
&lt;h2 id="降级先看结果能否等价交付"&gt;降级先看结果能否等价交付&lt;/h2&gt;
&lt;p&gt;普通静态图仍可沿用官方接口、Codex 反代和原供应商三层路径。实测时，我用受控额度错误模拟官方不可用，随后请求真实的备用路径。中间一层返回错误，原 2.5 供应商最终成功生成了静态图。&lt;/p&gt;
&lt;p&gt;带有明确格式和编辑约束的请求要单独判断。用户指定 GIF 时，一张 PNG 即使清晰，也少了动画。指定蒙版时，普通改图少了区域约束。如果备用接口无法保住这些要求，应用会提示该能力暂时不可用。审核拒绝和用户参数错误也会就此结束，不会换一家继续发同一个请求。&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;遇到的情况&lt;/th&gt;
&lt;th&gt;应用的处理&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;普通静态图遇到额度或可恢复的上游故障&lt;/td&gt;
&lt;td&gt;依次尝试已配置的备用供应商&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GIF 尺寸与帧数无法拼版&lt;/td&gt;
&lt;td&gt;提示使用自动尺寸或调整尺寸和帧数&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SVG、GIF、蒙版等需求无法由备用接口完成&lt;/td&gt;
&lt;td&gt;说明这项能力暂不可用&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;上游已出图，上传对象存储失败&lt;/td&gt;
&lt;td&gt;报告生成或存储失败，同一次调用不再向备用模型要一张新图&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;最后一行很容易被忽略。生成成功之后，存储和模型调用已经是两个阶段。把上传也包进“失败就换一家”的逻辑，图床抖动时会白白多花一次生成费用，还可能把一张完全不同的图交给用户。&lt;/p&gt;
&lt;h2 id="上线时怎么验收"&gt;上线时怎么验收&lt;/h2&gt;
&lt;p&gt;这次按用户最终拿到的内容验收，而不是只看接口状态。静态图和动图要能通过外链读回原始媒体，SVG 要用正确的 MIME 返回，连续改图要跟随明确选中的结果。还要至少测一次失败请求，看看它有没有误走重试、误降级，或者把旧图覆盖掉。&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;场景&lt;/th&gt;
&lt;th&gt;已观察到的结果&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;GIF 与 SVG 原格式&lt;/td&gt;
&lt;td&gt;实际读取到 9 帧 GIF；博客文章助手返回并可读取 SVG&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;连续两轮静态改图&lt;/td&gt;
&lt;td&gt;上一轮输出作为下一轮输入，保留红茶壶并加入柠檬&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;标注与蒙版&lt;/td&gt;
&lt;td&gt;两种路径分别修改目标蓝圆，保留相邻的红方块&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;无效 GIF 参数&lt;/td&gt;
&lt;td&gt;用户看到尺寸与帧数提示，服务不会因此进入冷却&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;官方额度故障注入&lt;/td&gt;
&lt;td&gt;普通静态图经过备用链路，由原 2.5 供应商出图&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;h2 id="参考资料"&gt;参考资料&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href="https://developers.openai.com/api/docs/models/gpt-image-2.6-sunburst"&gt;OpenAI GPT Image 2.6 Sunburst 模型说明&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://developers.openai.com/api/docs/guides/image-generation"&gt;OpenAI 图像生成与编辑指南&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://developers.openai.com/api/reference/resources/images/methods/generate"&gt;OpenAI Image API 生成接口&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;</description>
      <pubDate>Thu, 05 Nov 2026 08:19:39 GMT</pubDate>
    </item>
    <item>
      <title>Spring Boot 增量发布：131 MB 的 JAR，实际只上传约 1 MB</title>
      <link>https://www.hqxiaozou.top/post/spring-boot-nested-jar-delta-upload</link>
      <description>&lt;p&gt;最近一次给本站的 Spring Boot 聚合应用发版，最终 JAR 有 &lt;strong&gt;131,420,604 字节&lt;/strong&gt;。代码只改了部分业务逻辑，字体、图片和大多数依赖都没有变化，但直接用 SCP 上传，仍然要发送整个文件。&lt;/p&gt;&#xD;
&lt;p&gt;第一次按最外层 ZIP 条目做增量，传输量降到了约 20 MB。继续拆进发生变化的业务 JAR 后，最终只需要上传 &lt;strong&gt;559,640 字节的增量数据，以及 477,730 字节的重建清单&lt;/strong&gt;。&lt;/p&gt;&#xD;
&lt;p&gt;两者合计 1,037,370 字节，约为完整包的 &lt;strong&gt;0.79%&lt;/strong&gt;。服务器重建出的 JAR 与本地构建产物 SHA-256 完全一致，随后完成启动和业务检查。&lt;/p&gt;&#xD;
&lt;p&gt;这篇文章记录的是一次单 JAR 交付的优化：怎样复用旧包中已经存在的字节，以及怎样确认重建结果可以发布。&lt;/p&gt;&#xD;
&lt;h2 id="1-"&gt;1. 先把传输量算完整&lt;/h2&gt;&#xD;
&lt;p&gt;这次最终候选包的数据如下。表中的 MB 按十进制计算，1 MB 为 1,000,000 字节。&lt;/p&gt;&#xD;
&lt;table&gt;&#xD;
&lt;thead&gt;&#xD;
&lt;tr&gt;&#xD;
&lt;th&gt;项目&lt;/th&gt;&#xD;
&lt;th style="text-align:right"&gt;字节数&lt;/th&gt;&#xD;
&lt;th style="text-align:right"&gt;约合大小&lt;/th&gt;&#xD;
&lt;/tr&gt;&#xD;
&lt;/thead&gt;&#xD;
&lt;tbody&gt;&#xD;
&lt;tr&gt;&#xD;
&lt;td&gt;完整新 JAR&lt;/td&gt;&#xD;
&lt;td style="text-align:right"&gt;131,420,604&lt;/td&gt;&#xD;
&lt;td style="text-align:right"&gt;131.42 MB&lt;/td&gt;&#xD;
&lt;/tr&gt;&#xD;
&lt;tr&gt;&#xD;
&lt;td&gt;需要新上传的数据&lt;/td&gt;&#xD;
&lt;td style="text-align:right"&gt;559,640&lt;/td&gt;&#xD;
&lt;td style="text-align:right"&gt;0.56 MB&lt;/td&gt;&#xD;
&lt;/tr&gt;&#xD;
&lt;tr&gt;&#xD;
&lt;td&gt;重建清单&lt;/td&gt;&#xD;
&lt;td style="text-align:right"&gt;477,730&lt;/td&gt;&#xD;
&lt;td style="text-align:right"&gt;0.48 MB&lt;/td&gt;&#xD;
&lt;/tr&gt;&#xD;
&lt;tr&gt;&#xD;
&lt;td&gt;数据与清单合计&lt;/td&gt;&#xD;
&lt;td style="text-align:right"&gt;1,037,370&lt;/td&gt;&#xD;
&lt;td style="text-align:right"&gt;1.04 MB&lt;/td&gt;&#xD;
&lt;/tr&gt;&#xD;
&lt;tr&gt;&#xD;
&lt;td&gt;从服务器旧包复用的数据&lt;/td&gt;&#xD;
&lt;td style="text-align:right"&gt;130,860,964&lt;/td&gt;&#xD;
&lt;td style="text-align:right"&gt;130.86 MB&lt;/td&gt;&#xD;
&lt;/tr&gt;&#xD;
&lt;/tbody&gt;&#xD;
&lt;/table&gt;&#xD;
&lt;p&gt;这里有一个容易漏算的部分：&lt;strong&gt;清单本身也要上传&lt;/strong&gt;。如果只说“增量只有 560 KB”，就少算了接近一半的文件传输量。上表没有计入 SSH 协议和少量辅助脚本的开销。&lt;/p&gt;&#xD;
&lt;p&gt;传输数据减少约 99.2%，也不代表整个发版过程快了 99.2%。编译、重建文件、校验、应用重启和健康检查仍然需要时间。这个优化解决的是慢上行链路上的大文件传输。&lt;/p&gt;&#xD;
&lt;h2 id="2-20-mb"&gt;2. 为什么拆最外层还要传约 20 MB&lt;/h2&gt;&#xD;
&lt;p&gt;Spring Boot 可执行 JAR 本身是 ZIP，内部通常有这样的结构：&lt;/p&gt;&#xD;
&lt;pre&gt;&lt;code class="lang-text"&gt;application.jar&#xD;
├── META-INF/MANIFEST.MF&#xD;
├── org&lt;span class="hljs-regexp"&gt;/springframework/boot/loader/&lt;/span&gt;...&#xD;
└── BOOT-INF&#xD;
    ├── classes/...&#xD;
    └── lib&#xD;
        ├── business-a.jar&#xD;
        ├── business-b.jar&#xD;
        └── third-party-library.jar&#xD;
&lt;/code&gt;&lt;/pre&gt;&#xD;
&lt;p&gt;在多模块聚合工程里，业务模块也可能作为普通 JAR 放进 &lt;code&gt;BOOT-INF/lib&lt;/code&gt;。一个业务 JAR 里除了 class，还包含模板、脚本、字体等资源。&lt;/p&gt;&#xD;
&lt;p&gt;如果增量工具只认识最外层条目，那么修改 &lt;code&gt;business-a.jar&lt;/code&gt; 里的一个 class，就会把整个 &lt;code&gt;business-a.jar&lt;/code&gt; 视为新数据。未变化的第三方依赖可以复用，但业务 JAR 中那些没有变化的大资源，仍然会被重新发送。&lt;/p&gt;&#xD;
&lt;p&gt;所以第一层增量已经有效，却还不够细。&lt;/p&gt;&#xD;
&lt;p&gt;继续往里看，需要区分两层压缩方式。Spring Boot 2.7 的可执行包要求：&lt;strong&gt;嵌套 JAR 对应的外层 ZIP 条目使用 &lt;code&gt;STORED&lt;/code&gt;，内层 JAR 自己的条目仍然可以压缩&lt;/strong&gt;。这是 Loader 能按偏移读取嵌套内容的基础，见&lt;a href="https://docs.spring.io/spring-boot/docs/2.7.18/reference/html/executable-jar.html#appendix.executable-jar.restrictions"&gt;官方格式说明&lt;/a&gt;。&lt;/p&gt;&#xD;
&lt;pre&gt;&lt;code class="lang-text"&gt;外层 application.jar&#xD;
└── business-a.jar            外层条目：STORED&#xD;
    ├── Example.&lt;span class="hljs-keyword"&gt;class&lt;/span&gt;        内层条目：可以是 DEFLATED&#xD;
    └── &lt;span class="hljs-keyword"&gt;static&lt;/span&gt;/font.woff2     内层条目：可以是 DEFLATED&#xD;
&lt;/code&gt;&lt;/pre&gt;&#xD;
&lt;p&gt;外层没有再压缩这份业务 JAR，就可以把内层条目的位置换算为外层文件里的绝对偏移，然后直接复用它们的原始字节。&lt;/p&gt;&#xD;
&lt;h2 id="3-"&gt;3. 目标是还原新包，而不是重新打一个包&lt;/h2&gt;&#xD;
&lt;p&gt;把两个 JAR 全部解压，复制修改文件，再调用 ZIP 工具重新压缩，看起来很直接，但文件顺序、时间戳、extra 字段、压缩结果都可能改变。嵌套 JAR 的外层存储方式也需要保持正确。&lt;/p&gt;&#xD;
&lt;p&gt;这次采用的是另一种恢复方式：&lt;strong&gt;以已经构建完成的新 JAR 为唯一目标，逐段决定从哪里取得相同的字节。&lt;/strong&gt;&lt;/p&gt;&#xD;
&lt;p&gt;每一段只有两种来源：&lt;/p&gt;&#xD;
&lt;ul&gt;&#xD;
&lt;li&gt;&lt;code&gt;baseline&lt;/code&gt;：从服务器上的旧包读取。&lt;/li&gt;&#xD;
&lt;li&gt;&lt;code&gt;payload&lt;/code&gt;：从本次上传的增量文件读取。&lt;/li&gt;&#xD;
&lt;/ul&gt;&#xD;
&lt;p&gt;所有段按目标文件顺序连接，最后必须得到与本地新包完全相同的字节序列。服务器不需要再调用 Maven，也不需要重新压缩 JAR。&lt;/p&gt;&#xD;
&lt;h3 id="-"&gt;第一步：给旧包建立索引&lt;/h3&gt;&#xD;
&lt;p&gt;先记录旧包的整体 SHA-256，再记录条目的位置、长度以及摘要。对准备继续拆分的业务 JAR，再建立一层内层条目索引。&lt;/p&gt;&#xD;
&lt;p&gt;嵌套条目的标识可以写成：&lt;/p&gt;&#xD;
&lt;pre&gt;&lt;code class="lang-text"&gt;BOOT-INF/lib/business-a.jar!/&lt;span class="hljs-keyword"&gt;static&lt;/span&gt;/font.woff2&#xD;
&lt;/code&gt;&lt;/pre&gt;&#xD;
&lt;p&gt;文件名用于查找候选数据，不能单独作为复用依据。真正决定能否复用的，是对应字节的长度和摘要。&lt;/p&gt;&#xD;
&lt;h3 id="-"&gt;第二步：优先复用完整条目&lt;/h3&gt;&#xD;
&lt;p&gt;如果新旧条目的完整字节段相同，直接复制旧包中对应的整段。这样，大多数没有升级的依赖 JAR 不需要进一步解析。&lt;/p&gt;&#xD;
&lt;p&gt;对于发生变化的业务 JAR，只有确认其外层条目为 &lt;code&gt;STORED&lt;/code&gt;，才继续拆分内部内容。其他情况可以保守地把整个变化区域放进 payload。&lt;/p&gt;&#xD;
&lt;h3 id="-"&gt;第三步：比较内层的压缩数据&lt;/h3&gt;&#xD;
&lt;p&gt;一个 ZIP 本地条目的数据起点，可以表示为：&lt;/p&gt;&#xD;
&lt;pre&gt;&lt;code class="lang-text"&gt;条目数据起点 = 本地文件头起点 + 30 + 文件名长度 + extra 长度&#xD;
&lt;/code&gt;&lt;/pre&gt;&#xD;
&lt;p&gt;其中长度都以字节计算。内层数据在整个可执行包中的位置，则是“业务 JAR 的外层数据起点”加上“内层数据相对业务 JAR 的偏移”。&lt;/p&gt;&#xD;
&lt;p&gt;这里比较和复用的是&lt;strong&gt;压缩后的原始数据&lt;/strong&gt;。即使解压后的内容相同，重新压缩产生的字节也未必相同；若压缩字节不同，就上传新字节。&lt;/p&gt;&#xD;
&lt;p&gt;对于不能整段复用的条目，这次实现把新包中的本地文件头、条目尾部数据，以及中央目录等结构数据写进 payload。未变化的压缩数据则从旧包复制。包括 data descriptor 在内的条目尾部区域也不能漏掉。&lt;/p&gt;&#xD;
&lt;p&gt;中央目录直接使用新包里的原始字节，避免自己重写偏移和元数据。复用段与新增段最终必须覆盖整个新包，包括归档结尾。&lt;/p&gt;&#xD;
&lt;h3 id="-"&gt;第四步：生成重建清单&lt;/h3&gt;&#xD;
&lt;p&gt;清单的核心结构如下，摘要字段在实际文件中都是完整的 SHA-256，下面仅展示字段含义：&lt;/p&gt;&#xD;
&lt;pre&gt;&lt;code class="lang-json"&gt;{&#xD;
  &lt;span class="hljs-attr"&gt;"baselineSha"&lt;/span&gt;: &lt;span class="hljs-string"&gt;"旧包整体摘要"&lt;/span&gt;,&#xD;
  &lt;span class="hljs-attr"&gt;"sha"&lt;/span&gt;: &lt;span class="hljs-string"&gt;"新包整体摘要"&lt;/span&gt;,&#xD;
  &lt;span class="hljs-attr"&gt;"operations"&lt;/span&gt;: [&#xD;
    {&lt;span class="hljs-attr"&gt;"source"&lt;/span&gt;: &lt;span class="hljs-string"&gt;"baseline"&lt;/span&gt;, &lt;span class="hljs-attr"&gt;"offset"&lt;/span&gt;: &lt;span class="hljs-number"&gt;1024&lt;/span&gt;, &lt;span class="hljs-attr"&gt;"length"&lt;/span&gt;: &lt;span class="hljs-number"&gt;4096&lt;/span&gt;, &lt;span class="hljs-attr"&gt;"sha"&lt;/span&gt;: &lt;span class="hljs-string"&gt;"片段摘要"&lt;/span&gt;},&#xD;
    {&lt;span class="hljs-attr"&gt;"source"&lt;/span&gt;: &lt;span class="hljs-string"&gt;"payload"&lt;/span&gt;, &lt;span class="hljs-attr"&gt;"offset"&lt;/span&gt;: &lt;span class="hljs-number"&gt;0&lt;/span&gt;, &lt;span class="hljs-attr"&gt;"length"&lt;/span&gt;: &lt;span class="hljs-number"&gt;300&lt;/span&gt;, &lt;span class="hljs-attr"&gt;"sha"&lt;/span&gt;: &lt;span class="hljs-string"&gt;"片段摘要"&lt;/span&gt;}&#xD;
  ]&#xD;
}&#xD;
&lt;/code&gt;&lt;/pre&gt;&#xD;
&lt;p&gt;&lt;code&gt;offset&lt;/code&gt; 是在来源文件里的位置。操作在数组中的顺序，就是目标文件的写入顺序；它不要求旧包中的来源偏移连续。&lt;/p&gt;&#xD;
&lt;h2 id="4-"&gt;4. 三层校验分别解决什么问题&lt;/h2&gt;&#xD;
&lt;p&gt;只核对新包文件大小是不够的。传输截断、索引错误、旧版本不一致，都可能产生大小接近甚至相同的错误文件。&lt;/p&gt;&#xD;
&lt;p&gt;这次恢复过程保留了三层检查：&lt;/p&gt;&#xD;
&lt;table&gt;&#xD;
&lt;thead&gt;&#xD;
&lt;tr&gt;&#xD;
&lt;th&gt;检查&lt;/th&gt;&#xD;
&lt;th&gt;主要防止的问题&lt;/th&gt;&#xD;
&lt;/tr&gt;&#xD;
&lt;/thead&gt;&#xD;
&lt;tbody&gt;&#xD;
&lt;tr&gt;&#xD;
&lt;td&gt;旧包整体 SHA-256&lt;/td&gt;&#xD;
&lt;td&gt;服务器已经换过版本，清单仍基于旧基准&lt;/td&gt;&#xD;
&lt;/tr&gt;&#xD;
&lt;tr&gt;&#xD;
&lt;td&gt;每段长度与 SHA-256&lt;/td&gt;&#xD;
&lt;td&gt;偏移错误、来源错误、增量数据损坏&lt;/td&gt;&#xD;
&lt;/tr&gt;&#xD;
&lt;tr&gt;&#xD;
&lt;td&gt;重建后整体 SHA-256&lt;/td&gt;&#xD;
&lt;td&gt;段顺序错误、漏写元数据、组合结果不等于目标包&lt;/td&gt;&#xD;
&lt;/tr&gt;&#xD;
&lt;/tbody&gt;&#xD;
&lt;/table&gt;&#xD;
&lt;p&gt;单个片段正确，不代表组合顺序正确；目标文件可被 ZIP 工具打开，也不代表它就是准备发布的那一份产物。&lt;/p&gt;&#xD;
&lt;p&gt;这些摘要解决的是字节完整性和版本对应关系。清单与目标摘要本身仍需要通过可信的发布链路传递，不能把一份来历不明的清单当成真实性证明。&lt;/p&gt;&#xD;
&lt;p&gt;如果允许多人同时发布，还应把现役版本复核放进发布锁保护范围：准备切换时发现现役包已变化，就停止当前流程，重新确认基准与版本顺序。&lt;/p&gt;&#xD;
&lt;h2 id="5-"&gt;5. 服务器恢复的核心代码&lt;/h2&gt;&#xD;
&lt;p&gt;下面省略 ZIP 索引生成，只展示如何消费清单。它只依赖 Python 标准库，按固定大小读取片段，避免把大段依赖一次性装进内存。&lt;/p&gt;&#xD;
&lt;pre&gt;&lt;code class="lang-python"&gt;&lt;span class="hljs-keyword"&gt;import&lt;/span&gt; hashlib&#xD;
&lt;span class="hljs-keyword"&gt;import&lt;/span&gt; os&#xD;
&lt;span class="hljs-keyword"&gt;import&lt;/span&gt; tempfile&#xD;
&lt;span class="hljs-keyword"&gt;from&lt;/span&gt; pathlib &lt;span class="hljs-keyword"&gt;import&lt;/span&gt; Path&#xD;
&#xD;
BLOCK = &lt;span class="hljs-number"&gt;1024&lt;/span&gt; * &lt;span class="hljs-number"&gt;1024&lt;/span&gt;&#xD;
&#xD;
&lt;span class="hljs-function"&gt;&lt;span class="hljs-keyword"&gt;def&lt;/span&gt; &lt;span class="hljs-title"&gt;digest&lt;/span&gt;&lt;span class="hljs-params"&gt;(stream)&lt;/span&gt;:&lt;/span&gt;&#xD;
    stream.seek(&lt;span class="hljs-number"&gt;0&lt;/span&gt;)&#xD;
    result = hashlib.sha256()&#xD;
    &lt;span class="hljs-keyword"&gt;for&lt;/span&gt; chunk &lt;span class="hljs-keyword"&gt;in&lt;/span&gt; iter(&lt;span class="hljs-keyword"&gt;lambda&lt;/span&gt;: stream.read(BLOCK), &lt;span class="hljs-string"&gt;b''&lt;/span&gt;):&#xD;
        result.update(chunk)&#xD;
    &lt;span class="hljs-keyword"&gt;return&lt;/span&gt; result.hexdigest()&#xD;
&#xD;
&lt;span class="hljs-function"&gt;&lt;span class="hljs-keyword"&gt;def&lt;/span&gt; &lt;span class="hljs-title"&gt;rebuild&lt;/span&gt;&lt;span class="hljs-params"&gt;(old_path, payload_path, manifest, target_path)&lt;/span&gt;:&lt;/span&gt;&#xD;
    old_path = Path(old_path).resolve()&#xD;
    payload_path = Path(payload_path).resolve()&#xD;
    target = Path(target_path).resolve()&#xD;
    &lt;span class="hljs-keyword"&gt;if&lt;/span&gt; target &lt;span class="hljs-keyword"&gt;in&lt;/span&gt; (old_path, payload_path):&#xD;
        &lt;span class="hljs-keyword"&gt;raise&lt;/span&gt; ValueError(&lt;span class="hljs-string"&gt;'目标文件不能覆盖输入文件'&lt;/span&gt;)&#xD;
&#xD;
    temporary = &lt;span class="hljs-keyword"&gt;None&lt;/span&gt;&#xD;
    &lt;span class="hljs-keyword"&gt;try&lt;/span&gt;:&#xD;
        &lt;span class="hljs-keyword"&gt;with&lt;/span&gt; old_path.open(&lt;span class="hljs-string"&gt;'rb'&lt;/span&gt;) &lt;span class="hljs-keyword"&gt;as&lt;/span&gt; old, payload_path.open(&lt;span class="hljs-string"&gt;'rb'&lt;/span&gt;) &lt;span class="hljs-keyword"&gt;as&lt;/span&gt; payload:&#xD;
            &lt;span class="hljs-keyword"&gt;if&lt;/span&gt; digest(old) != manifest[&lt;span class="hljs-string"&gt;'baselineSha'&lt;/span&gt;]:&#xD;
                &lt;span class="hljs-keyword"&gt;raise&lt;/span&gt; ValueError(&lt;span class="hljs-string"&gt;'基准版本已经变化'&lt;/span&gt;)&#xD;
&#xD;
            &lt;span class="hljs-keyword"&gt;with&lt;/span&gt; tempfile.NamedTemporaryFile(&#xD;
                dir=target.parent, prefix=target.name + &lt;span class="hljs-string"&gt;'.'&lt;/span&gt;,&#xD;
                suffix=&lt;span class="hljs-string"&gt;'.partial'&lt;/span&gt;, delete=&lt;span class="hljs-keyword"&gt;False&lt;/span&gt;&#xD;
            ) &lt;span class="hljs-keyword"&gt;as&lt;/span&gt; output:&#xD;
                temporary = Path(output.name)&#xD;
                total = hashlib.sha256()&#xD;
                sources = {&lt;span class="hljs-string"&gt;'baseline'&lt;/span&gt;: old, &lt;span class="hljs-string"&gt;'payload'&lt;/span&gt;: payload}&#xD;
                &lt;span class="hljs-keyword"&gt;for&lt;/span&gt; operation &lt;span class="hljs-keyword"&gt;in&lt;/span&gt; manifest[&lt;span class="hljs-string"&gt;'operations'&lt;/span&gt;]:&#xD;
                    source = sources[operation[&lt;span class="hljs-string"&gt;'source'&lt;/span&gt;]]&#xD;
                    offset, remaining = operation[&lt;span class="hljs-string"&gt;'offset'&lt;/span&gt;], operation[&lt;span class="hljs-string"&gt;'length'&lt;/span&gt;]&#xD;
                    &lt;span class="hljs-keyword"&gt;if&lt;/span&gt; offset &amp;lt; &lt;span class="hljs-number"&gt;0&lt;/span&gt; &lt;span class="hljs-keyword"&gt;or&lt;/span&gt; remaining &amp;lt; &lt;span class="hljs-number"&gt;0&lt;/span&gt;:&#xD;
                        &lt;span class="hljs-keyword"&gt;raise&lt;/span&gt; ValueError(&lt;span class="hljs-string"&gt;'非法片段范围'&lt;/span&gt;)&#xD;
                    source.seek(offset)&#xD;
                    piece = hashlib.sha256()&#xD;
                    &lt;span class="hljs-keyword"&gt;while&lt;/span&gt; remaining:&#xD;
                        chunk = source.read(min(BLOCK, remaining))&#xD;
                        &lt;span class="hljs-keyword"&gt;if&lt;/span&gt; &lt;span class="hljs-keyword"&gt;not&lt;/span&gt; chunk:&#xD;
                            &lt;span class="hljs-keyword"&gt;raise&lt;/span&gt; ValueError(&lt;span class="hljs-string"&gt;'来源文件不完整'&lt;/span&gt;)&#xD;
                        piece.update(chunk)&#xD;
                        total.update(chunk)&#xD;
                        output.write(chunk)&#xD;
                        remaining -= len(chunk)&#xD;
                    &lt;span class="hljs-keyword"&gt;if&lt;/span&gt; piece.hexdigest() != operation[&lt;span class="hljs-string"&gt;'sha'&lt;/span&gt;]:&#xD;
                        &lt;span class="hljs-keyword"&gt;raise&lt;/span&gt; ValueError(&lt;span class="hljs-string"&gt;'片段摘要不一致'&lt;/span&gt;)&#xD;
                &lt;span class="hljs-keyword"&gt;if&lt;/span&gt; total.hexdigest() != manifest[&lt;span class="hljs-string"&gt;'sha'&lt;/span&gt;]:&#xD;
                    &lt;span class="hljs-keyword"&gt;raise&lt;/span&gt; ValueError(&lt;span class="hljs-string"&gt;'最终产物摘要不一致'&lt;/span&gt;)&#xD;
                output.flush()&#xD;
                os.fsync(output.fileno())&#xD;
&#xD;
        os.replace(temporary, target)&#xD;
    &lt;span class="hljs-keyword"&gt;finally&lt;/span&gt;:&#xD;
        &lt;span class="hljs-keyword"&gt;if&lt;/span&gt; temporary &lt;span class="hljs-keyword"&gt;is&lt;/span&gt; &lt;span class="hljs-keyword"&gt;not&lt;/span&gt; &lt;span class="hljs-keyword"&gt;None&lt;/span&gt; &lt;span class="hljs-keyword"&gt;and&lt;/span&gt; temporary.exists():&#xD;
            temporary.unlink()&#xD;
&lt;/code&gt;&lt;/pre&gt;&#xD;
&lt;p&gt;目标应当是暂存目录中的候选包。临时文件与目标文件位于同一个目录，关闭文件并通过整体校验后才替换目标路径。&lt;/p&gt;&#xD;
&lt;p&gt;文中的恢复函数已用这份真实的 131 MB 产物重新验证，得到相同的整体 SHA-256；错误基准、片段损坏、来源截断和段顺序错误也分别验证过，失败时保留原有候选文件并清理临时文件。&lt;/p&gt;&#xD;
&lt;p&gt;这里的 &lt;code&gt;os.replace&lt;/code&gt; 只负责一个文件的路径切换，不负责服务重启，也不代表 JAR、环境变量和代理配置之间具备跨文件事务。运行中的应用包不应直接作为这个函数的输出目标。&lt;/p&gt;&#xD;
&lt;h2 id="6-"&gt;6. 可复现构建能让增量更稳定&lt;/h2&gt;&#xD;
&lt;p&gt;如果同一份源代码每次构建都会改变大量时间戳和归档内容，能够复用的字节就会减少。项目中固定了归档输出时间：&lt;/p&gt;&#xD;
&lt;pre&gt;&lt;code class="lang-xml"&gt;&amp;lt;properties&amp;gt;&#xD;
    &lt;span class="xml"&gt;&lt;span class="hljs-tag"&gt;&amp;lt;&lt;span class="hljs-name"&gt;project.build.outputTimestamp&lt;/span&gt;&amp;gt;&lt;/span&gt;2026-01-01T00:00:00Z&lt;span class="hljs-tag"&gt;&amp;lt;/&lt;span class="hljs-name"&gt;project.build.outputTimestamp&lt;/span&gt;&amp;gt;&lt;/span&gt;&#xD;
&lt;span class="hljs-tag"&gt;&amp;lt;/&lt;span class="hljs-name"&gt;properties&lt;/span&gt;&amp;gt;&lt;/span&gt;&lt;/span&gt;&#xD;
&lt;/code&gt;&lt;/pre&gt;&#xD;
&lt;p&gt;这是 Maven 提供给相关构建插件的配置入口，插件本身也要支持可复现输出。它不是“一行配置就保证所有环境产物相同”：JDK、插件、依赖、生成文件和构建输入仍可能影响结果。可按 &lt;a href="https://maven.apache.org/guides/mini/guide-reproducible-builds.html"&gt;Maven 官方指南&lt;/a&gt;检查实际构建差异。&lt;/p&gt;&#xD;
&lt;p&gt;这次发布还从固定提交的干净源码归档构建，避免把工作目录里后续产生的修改混进候选包。增量量是否理想，最终仍然由真实生成的字节决定。&lt;/p&gt;&#xD;
&lt;h2 id="7-"&gt;7. 文件重建成功之后，还没有结束&lt;/h2&gt;&#xD;
&lt;p&gt;增量恢复通过整体摘要检查，只能说明“拿到了正确的文件”。接下来仍然走原有部署过程：保留现役版本备份、切换候选包、启动服务，失败时恢复。&lt;/p&gt;&#xD;
&lt;p&gt;本站这次发版还检查了实际聚合包里的依赖初始化、各应用的启动与代理链，以及页面是否完整返回。特别是最终 JAR 的运行时检查，不能完全由某个子模块的测试代替：聚合打包时，依赖版本可能重新参与选择。&lt;/p&gt;&#xD;
&lt;p&gt;这部分验证不会因为上传量从 131 MB 降到约 1 MB 就省略。&lt;/p&gt;&#xD;
&lt;h2 id="8-"&gt;8. 适用范围&lt;/h2&gt;&#xD;
&lt;p&gt;这套方法适合依赖和大资源变化较少、仍需要交付单个可执行 JAR、上传链路比较慢的场景。如果一次升级替换了大部分依赖或资源，增量接近全量很正常，完整上传反而更省事。&lt;/p&gt;&#xD;
&lt;p&gt;如果部署端已经有 rsync，先测量它的实际增量效果；容器部署也可以优先利用镜像分层。没有必要为了相近的收益再维护一套归档解析工具。&lt;/p&gt;&#xD;
&lt;p&gt;本文的索引思路针对已知构建链路产生的普通 ZIP/JAR。ZIP64、加密或分卷归档需要另外处理，不能把简化的偏移解析直接套上去。只对确认使用外层 &lt;code&gt;STORED&lt;/code&gt; 的业务 JAR 继续拆分，其他变化区域完整传输，是一个容易验证的边界。&lt;/p&gt;&#xD;
&lt;p&gt;这次优化保留了单 JAR 的交付方式，只把传输单位从“整个文件”细化到“已验证的字节段”。完整产物仍然保留，增量效果和发布正确性也分别有了可以核对的结果。&lt;/p&gt;</description>
      <pubDate>Wed, 28 Oct 2026 15:39:59 GMT</pubDate>
    </item>
    <item>
      <title>CompletableFuture 超时了，后台任务为什么还在跑</title>
      <link>https://www.hqxiaozou.top/post/completablefuture-timeout-cancellation</link>
      <description>&lt;p&gt;假设一个导出接口最多等两秒，超过时间就告诉用户“稍后再试”。接口日志按时结束了，线程池却没有空下来，几十秒后甚至还能看到“导出完成”。&lt;/p&gt;&#xD;
&lt;p&gt;于是又在超时分支里补了一句 &lt;code&gt;future.cancel(true)&lt;/code&gt;。这次 &lt;code&gt;isCancelled()&lt;/code&gt; 也变成了 &lt;code&gt;true&lt;/code&gt;，但后台工作照样继续。&lt;/p&gt;&#xD;
&lt;p&gt;问题出在这里：等待结果、标记结果和执行工作，是三个不同的过程。它们之间需要明确的连接，方法名里有一个 &lt;code&gt;cancel&lt;/code&gt;，并不代表所有过程都会一起结束。&lt;/p&gt;&#xD;
&lt;p&gt;这篇只讨论这个边界。前面的导出是示例场景；文中的对照代码在本地 &lt;strong&gt;Java 8u504&lt;/strong&gt; 上实际运行过，只修改内存标记，不访问网络、数据库或业务文件。&lt;/p&gt;&#xD;
&lt;h2 id="-"&gt;一、先把三个“结束”分开&lt;/h2&gt;&#xD;
&lt;p&gt;一次异步调用里，至少有三件事需要分别回答：&lt;/p&gt;&#xD;
&lt;table&gt;&#xD;
&lt;thead&gt;&#xD;
&lt;tr&gt;&#xD;
&lt;th&gt;观察对象&lt;/th&gt;&#xD;
&lt;th&gt;需要确认什么&lt;/th&gt;&#xD;
&lt;/tr&gt;&#xD;
&lt;/thead&gt;&#xD;
&lt;tbody&gt;&#xD;
&lt;tr&gt;&#xD;
&lt;td&gt;调用方&lt;/td&gt;&#xD;
&lt;td&gt;还在不在等结果？&lt;/td&gt;&#xD;
&lt;/tr&gt;&#xD;
&lt;tr&gt;&#xD;
&lt;td&gt;Future&lt;/td&gt;&#xD;
&lt;td&gt;当前是未完成、正常完成、异常完成，还是取消状态？&lt;/td&gt;&#xD;
&lt;/tr&gt;&#xD;
&lt;tr&gt;&#xD;
&lt;td&gt;实际工作&lt;/td&gt;&#xD;
&lt;td&gt;工作线程是否已经离开任务，资源是否已经释放？&lt;/td&gt;&#xD;
&lt;/tr&gt;&#xD;
&lt;/tbody&gt;&#xD;
&lt;/table&gt;&#xD;
&lt;p&gt;&lt;code&gt;get(timeout, unit)&lt;/code&gt; 约束的是这一次等待。等待超时会抛出 &lt;code&gt;TimeoutException&lt;/code&gt;，并不因此自动调用取消方法。另一个线程仍有可能继续执行任务并完成结果。&lt;/p&gt;&#xD;
&lt;p&gt;因此，下面这段代码只能说明调用方不再等了：&lt;/p&gt;&#xD;
&lt;pre&gt;&lt;code class="lang-java"&gt;&lt;span class="hljs-keyword"&gt;try&lt;/span&gt; {&#xD;
    &lt;span class="hljs-keyword"&gt;return&lt;/span&gt; result.&lt;span class="hljs-keyword"&gt;get&lt;/span&gt;(&lt;span class="hljs-number"&gt;2&lt;/span&gt;, TimeUnit.SECONDS);&#xD;
} &lt;span class="hljs-keyword"&gt;catch&lt;/span&gt; (TimeoutException ex) {&#xD;
    &lt;span class="hljs-keyword"&gt;return&lt;/span&gt; fallback;&#xD;
}&#xD;
&lt;/code&gt;&lt;/pre&gt;&#xD;
&lt;p&gt;它没有说明工作线程收到了什么指令，更没有说明下游请求已经停止。&lt;/p&gt;&#xD;
&lt;p&gt;另一个常见误读是 &lt;code&gt;isDone()&lt;/code&gt;。取消也属于 Future 的完成状态；把它作为“工作线程已经退出”的证据，会把两个不同的生命周期混起来。&lt;/p&gt;&#xD;
&lt;h2 id="-"&gt;二、一个能直接运行的对照实验&lt;/h2&gt;&#xD;
&lt;p&gt;实验分别通过两种入口提交同一段工作：&lt;/p&gt;&#xD;
&lt;ul&gt;&#xD;
&lt;li&gt;&lt;code&gt;CompletableFuture.runAsync(work, pool)&lt;/code&gt;；&lt;/li&gt;&#xD;
&lt;li&gt;普通单线程执行器的 &lt;code&gt;pool.submit(work)&lt;/code&gt;。&lt;/li&gt;&#xD;
&lt;/ul&gt;&#xD;
&lt;p&gt;为了避免用睡眠猜执行顺序，代码用三个 &lt;code&gt;CountDownLatch&lt;/code&gt; 控制现场：&lt;code&gt;started&lt;/code&gt; 确认工作已进入，&lt;code&gt;release&lt;/code&gt; 阻止工作继续，&lt;code&gt;exited&lt;/code&gt; 确认它已走到退出位置。&lt;code&gt;await()&lt;/code&gt; 是可中断等待，收到中断会抛出 &lt;code&gt;InterruptedException&lt;/code&gt;。&lt;/p&gt;&#xD;
&lt;p&gt;这点很重要：&lt;strong&gt;实验先确认任务已经开始，再取消。&lt;/strong&gt; 它不拿“任务还没被调度”这种情况来替代运行中取消。&lt;/p&gt;&#xD;
&lt;p&gt;保存为 &lt;code&gt;FutureCancellationLab.java&lt;/code&gt;：&lt;/p&gt;&#xD;
&lt;pre&gt;&lt;code class="lang-java"&gt;import java.util.concurrent.CompletableFuture;&#xD;
import java.util.concurrent.CountDownLatch;&#xD;
import java.util.concurrent.ExecutorService;&#xD;
import java.util.concurrent.Executors;&#xD;
import java.util.concurrent.Future;&#xD;
import java.util.concurrent.TimeUnit;&#xD;
import java.util.concurrent.TimeoutException;&#xD;
import java.util.concurrent.atomic.AtomicBoolean;&#xD;
&#xD;
&lt;span class="hljs-keyword"&gt;public&lt;/span&gt; &lt;span class="hljs-keyword"&gt;class&lt;/span&gt; &lt;span class="hljs-title"&gt;FutureCancellationLab&lt;/span&gt; {&#xD;
    &lt;span class="hljs-function"&gt;&lt;span class="hljs-keyword"&gt;private&lt;/span&gt; &lt;span class="hljs-keyword"&gt;static&lt;/span&gt; &lt;span class="hljs-keyword"&gt;void&lt;/span&gt; &lt;span class="hljs-title"&gt;await&lt;/span&gt;(&lt;span class="hljs-params"&gt;CountDownLatch latch&lt;/span&gt;) throws InterruptedException &lt;/span&gt;{&#xD;
        &lt;span class="hljs-keyword"&gt;if&lt;/span&gt; (!latch.&lt;span class="hljs-keyword"&gt;await&lt;/span&gt;(&lt;span class="hljs-number"&gt;5&lt;/span&gt;, TimeUnit.SECONDS)) {&#xD;
            &lt;span class="hljs-keyword"&gt;throw&lt;/span&gt; &lt;span class="hljs-keyword"&gt;new&lt;/span&gt; AssertionError(&lt;span class="hljs-string"&gt;"Lab timed out"&lt;/span&gt;);&#xD;
        }&#xD;
    }&#xD;
&#xD;
    &lt;span class="hljs-function"&gt;&lt;span class="hljs-keyword"&gt;private&lt;/span&gt; &lt;span class="hljs-keyword"&gt;static&lt;/span&gt; &lt;span class="hljs-keyword"&gt;void&lt;/span&gt; &lt;span class="hljs-title"&gt;runCase&lt;/span&gt;(&lt;span class="hljs-params"&gt;boolean completable&lt;/span&gt;) throws Exception &lt;/span&gt;{&#xD;
        String name = completable ? &lt;span class="hljs-string"&gt;"CompletableFuture"&lt;/span&gt; : &lt;span class="hljs-string"&gt;"Executor.submit"&lt;/span&gt;;&#xD;
        ExecutorService pool = Executors.newSingleThreadExecutor();&#xD;
        CountDownLatch started = &lt;span class="hljs-keyword"&gt;new&lt;/span&gt; CountDownLatch(&lt;span class="hljs-number"&gt;1&lt;/span&gt;);&#xD;
        CountDownLatch release = &lt;span class="hljs-keyword"&gt;new&lt;/span&gt; CountDownLatch(&lt;span class="hljs-number"&gt;1&lt;/span&gt;);&#xD;
        CountDownLatch exited = &lt;span class="hljs-keyword"&gt;new&lt;/span&gt; CountDownLatch(&lt;span class="hljs-number"&gt;1&lt;/span&gt;);&#xD;
        AtomicBoolean interrupted = &lt;span class="hljs-keyword"&gt;new&lt;/span&gt; AtomicBoolean();&#xD;
        AtomicBoolean effect = &lt;span class="hljs-keyword"&gt;new&lt;/span&gt; AtomicBoolean();&#xD;
&#xD;
        Runnable work = () -&amp;gt; {&#xD;
            started.countDown();&#xD;
            &lt;span class="hljs-keyword"&gt;try&lt;/span&gt; {&#xD;
                release.&lt;span class="hljs-keyword"&gt;await&lt;/span&gt;();&#xD;
                effect.&lt;span class="hljs-keyword"&gt;set&lt;/span&gt;(&lt;span class="hljs-literal"&gt;true&lt;/span&gt;); &lt;span class="hljs-comment"&gt;// Only an in-memory flag, no external writes.&lt;/span&gt;&#xD;
            } &lt;span class="hljs-keyword"&gt;catch&lt;/span&gt; (InterruptedException ex) {&#xD;
                interrupted.&lt;span class="hljs-keyword"&gt;set&lt;/span&gt;(&lt;span class="hljs-literal"&gt;true&lt;/span&gt;);&#xD;
                Thread.currentThread().interrupt();&#xD;
            } &lt;span class="hljs-keyword"&gt;finally&lt;/span&gt; {&#xD;
                exited.countDown();&#xD;
            }&#xD;
        };&#xD;
&#xD;
        &lt;span class="hljs-keyword"&gt;try&lt;/span&gt; {&#xD;
            Future&amp;lt;?&amp;gt; result = completable&#xD;
                    ? CompletableFuture.runAsync(work, pool)&#xD;
                    : pool.submit(work);&#xD;
            &lt;span class="hljs-keyword"&gt;await&lt;/span&gt;(started);&#xD;
&#xD;
            &lt;span class="hljs-keyword"&gt;try&lt;/span&gt; {&#xD;
                result.&lt;span class="hljs-keyword"&gt;get&lt;/span&gt;(&lt;span class="hljs-number"&gt;1&lt;/span&gt;, TimeUnit.MILLISECONDS);&#xD;
                &lt;span class="hljs-keyword"&gt;throw&lt;/span&gt; &lt;span class="hljs-keyword"&gt;new&lt;/span&gt; AssertionError(&lt;span class="hljs-string"&gt;"Expected a waiting timeout"&lt;/span&gt;);&#xD;
            } &lt;span class="hljs-keyword"&gt;catch&lt;/span&gt; (TimeoutException expected) {&#xD;
                System.&lt;span class="hljs-keyword"&gt;out&lt;/span&gt;.println(name + &lt;span class="hljs-string"&gt;" after get timeout: done="&lt;/span&gt; + result.isDone());&#xD;
            }&#xD;
&#xD;
            &lt;span class="hljs-keyword"&gt;if&lt;/span&gt; (!result.cancel(&lt;span class="hljs-literal"&gt;true&lt;/span&gt;)) {&#xD;
                &lt;span class="hljs-keyword"&gt;throw&lt;/span&gt; &lt;span class="hljs-keyword"&gt;new&lt;/span&gt; AssertionError(&lt;span class="hljs-string"&gt;"Expected cancellation to succeed"&lt;/span&gt;);&#xD;
            }&#xD;
            System.&lt;span class="hljs-keyword"&gt;out&lt;/span&gt;.println(name + &lt;span class="hljs-string"&gt;" after cancel: cancelled="&lt;/span&gt; + result.isCancelled()&#xD;
                    + &lt;span class="hljs-string"&gt;", done="&lt;/span&gt; + result.isDone());&#xD;
&#xD;
            release.countDown();&#xD;
            &lt;span class="hljs-keyword"&gt;await&lt;/span&gt;(exited);&#xD;
            System.&lt;span class="hljs-keyword"&gt;out&lt;/span&gt;.println(name + &lt;span class="hljs-string"&gt;" worker: interrupted="&lt;/span&gt; + interrupted.&lt;span class="hljs-keyword"&gt;get&lt;/span&gt;()&#xD;
                    + &lt;span class="hljs-string"&gt;", effect="&lt;/span&gt; + effect.&lt;span class="hljs-keyword"&gt;get&lt;/span&gt;());&#xD;
            &lt;span class="hljs-keyword"&gt;if&lt;/span&gt; (interrupted.&lt;span class="hljs-keyword"&gt;get&lt;/span&gt;() == completable || effect.&lt;span class="hljs-keyword"&gt;get&lt;/span&gt;() != completable) {&#xD;
                &lt;span class="hljs-keyword"&gt;throw&lt;/span&gt; &lt;span class="hljs-keyword"&gt;new&lt;/span&gt; AssertionError(&lt;span class="hljs-string"&gt;"Unexpected worker outcome"&lt;/span&gt;);&#xD;
            }&#xD;
        } &lt;span class="hljs-keyword"&gt;finally&lt;/span&gt; {&#xD;
            release.countDown();&#xD;
            pool.shutdownNow();&#xD;
            &lt;span class="hljs-keyword"&gt;if&lt;/span&gt; (!pool.awaitTermination(&lt;span class="hljs-number"&gt;5&lt;/span&gt;, TimeUnit.SECONDS)) {&#xD;
                &lt;span class="hljs-keyword"&gt;throw&lt;/span&gt; &lt;span class="hljs-keyword"&gt;new&lt;/span&gt; AssertionError(&lt;span class="hljs-string"&gt;"Worker did not exit"&lt;/span&gt;);&#xD;
            }&#xD;
        }&#xD;
    }&#xD;
&#xD;
    &lt;span class="hljs-function"&gt;&lt;span class="hljs-keyword"&gt;public&lt;/span&gt; &lt;span class="hljs-keyword"&gt;static&lt;/span&gt; &lt;span class="hljs-keyword"&gt;void&lt;/span&gt; &lt;span class="hljs-title"&gt;main&lt;/span&gt;(&lt;span class="hljs-params"&gt;String[] args&lt;/span&gt;) throws Exception &lt;/span&gt;{&#xD;
        System.&lt;span class="hljs-keyword"&gt;out&lt;/span&gt;.println(&lt;span class="hljs-string"&gt;"java.version="&lt;/span&gt; + System.getProperty(&lt;span class="hljs-string"&gt;"java.version"&lt;/span&gt;));&#xD;
        runCase(&lt;span class="hljs-literal"&gt;true&lt;/span&gt;);&#xD;
        runCase(&lt;span class="hljs-literal"&gt;false&lt;/span&gt;);&#xD;
    }&#xD;
}&#xD;
&lt;/code&gt;&lt;/pre&gt;&#xD;
&lt;p&gt;在已经配置好 JDK 的终端运行：&lt;/p&gt;&#xD;
&lt;pre&gt;&lt;code class="lang-bash"&gt;&lt;span class="hljs-selector-tag"&gt;javac&lt;/span&gt; &lt;span class="hljs-selector-tag"&gt;-encoding&lt;/span&gt; &lt;span class="hljs-selector-tag"&gt;UTF-8&lt;/span&gt; &lt;span class="hljs-selector-tag"&gt;FutureCancellationLab&lt;/span&gt;&lt;span class="hljs-selector-class"&gt;.java&lt;/span&gt;&#xD;
&lt;span class="hljs-selector-tag"&gt;java&lt;/span&gt; &lt;span class="hljs-selector-tag"&gt;FutureCancellationLab&lt;/span&gt;&#xD;
&lt;/code&gt;&lt;/pre&gt;&#xD;
&lt;p&gt;本次实际输出：&lt;/p&gt;&#xD;
&lt;pre&gt;&lt;code class="lang-text"&gt;java.version=1.8.0_504&#xD;
CompletableFuture after get timeout: &lt;span class="hljs-keyword"&gt;done&lt;/span&gt;=&lt;span class="hljs-literal"&gt;false&lt;/span&gt;&#xD;
CompletableFuture after cancel: cancelled=&lt;span class="hljs-literal"&gt;true&lt;/span&gt;, &lt;span class="hljs-keyword"&gt;done&lt;/span&gt;=&lt;span class="hljs-literal"&gt;true&lt;/span&gt;&#xD;
CompletableFuture worker: interrupted=&lt;span class="hljs-literal"&gt;false&lt;/span&gt;, effect=&lt;span class="hljs-literal"&gt;true&lt;/span&gt;&#xD;
Executor.submit after get timeout: &lt;span class="hljs-keyword"&gt;done&lt;/span&gt;=&lt;span class="hljs-literal"&gt;false&lt;/span&gt;&#xD;
Executor.submit after cancel: cancelled=&lt;span class="hljs-literal"&gt;true&lt;/span&gt;, &lt;span class="hljs-keyword"&gt;done&lt;/span&gt;=&lt;span class="hljs-literal"&gt;true&lt;/span&gt;&#xD;
Executor.submit worker: interrupted=&lt;span class="hljs-literal"&gt;true&lt;/span&gt;, effect=&lt;span class="hljs-literal"&gt;false&lt;/span&gt;&#xD;
&lt;/code&gt;&lt;/pre&gt;&#xD;
&lt;p&gt;两种结果都显示 &lt;code&gt;cancelled=true, done=true&lt;/code&gt;，实际工作却走了不同路径。&lt;/p&gt;&#xD;
&lt;p&gt;CompletableFuture 那组没有收到中断。主线程打开 &lt;code&gt;release&lt;/code&gt; 之后，工作继续执行，把 &lt;code&gt;effect&lt;/code&gt; 设成了 &lt;code&gt;true&lt;/code&gt;。&lt;/p&gt;&#xD;
&lt;p&gt;普通执行器那组在等待处收到中断，进入异常分支，因此没有执行后面的标记操作。&lt;/p&gt;&#xD;
&lt;p&gt;这里的 &lt;code&gt;effect&lt;/code&gt; 只是布尔变量，用来观察“取消以后，工作还能不能走到下一步”。实验没有真的导出文件，更没有发起任何外部操作。两组执行器也都在 &lt;code&gt;finally&lt;/code&gt; 中关闭，并检查线程已经退出。&lt;/p&gt;&#xD;
&lt;h2 id="-cancel-true-"&gt;三、为什么同一个 cancel(true)，行为却不同&lt;/h2&gt;&#xD;
&lt;p&gt;CompletableFuture 的 Java 8 文档明确说明：它不像 FutureTask 那样直接控制引起结果完成的计算；取消被处理成一种异常完成。&lt;code&gt;mayInterruptIfRunning&lt;/code&gt; 参数在这个实现中不用于控制执行。&lt;/p&gt;&#xD;
&lt;p&gt;所以，对实验里已经开始执行的 &lt;code&gt;work&lt;/code&gt; 而言，下面这句会改变结果对象的状态，却不会替它中断工作线程：&lt;/p&gt;&#xD;
&lt;pre&gt;&lt;code class="lang-java"&gt;result.cancel(&lt;span class="hljs-literal"&gt;true&lt;/span&gt;);&#xD;
&lt;/code&gt;&lt;/pre&gt;&#xD;
&lt;p&gt;普通执行器这一侧使用了另一条路径。&lt;code&gt;AbstractExecutorService&lt;/code&gt; 的默认 &lt;code&gt;submit&lt;/code&gt; 实现通过 &lt;code&gt;newTaskFor&lt;/code&gt; 包装工作，默认包装类型是 &lt;code&gt;FutureTask&lt;/code&gt;；子类可以覆盖这一行为。&lt;/p&gt;&#xD;
&lt;p&gt;FutureTask 对运行中任务执行 &lt;code&gt;cancel(true)&lt;/code&gt; 时，会尝试通过中断执行线程来停止任务。注意这里仍然是“尝试”，不是把任意一段 Java 代码强行终止。&lt;/p&gt;&#xD;
&lt;p&gt;因此，看到 &lt;code&gt;Future&amp;lt;?&amp;gt;&lt;/code&gt; 这个变量类型还不够，还应查清它由谁创建、取消控制的是哪一个对象、任务现在是否已经开始。也不能把普通线程池这次实验的行为，直接推广到所有第三方 Future 实现。&lt;/p&gt;&#xD;
&lt;h2 id="-ortimeout-"&gt;四、换成 orTimeout，也要继续追问工作由谁停止&lt;/h2&gt;&#xD;
&lt;p&gt;Java 9 开始增加了 &lt;code&gt;orTimeout&lt;/code&gt; 和 &lt;code&gt;completeOnTimeout&lt;/code&gt;。例如，下面的代码需要 Java 9 或以上版本，不能放进前面的 Java 8 实验直接编译：&lt;/p&gt;&#xD;
&lt;pre&gt;&lt;code class="lang-java"&gt;CompletableFuture&amp;lt;Result&amp;gt; result =&#xD;
        CompletableFuture.supplyAsync(&lt;span class="hljs-keyword"&gt;this&lt;/span&gt;::load, executor);&#xD;
&#xD;
result.orTimeout(&lt;span class="hljs-number"&gt;2&lt;/span&gt;, TimeUnit.SECONDS);&#xD;
&lt;/code&gt;&lt;/pre&gt;&#xD;
&lt;p&gt;&lt;code&gt;orTimeout&lt;/code&gt; 在期限到达时，尝试把&lt;strong&gt;当前这个&lt;/strong&gt; CompletableFuture 异常完成；&lt;code&gt;completeOnTimeout&lt;/code&gt; 则尝试用指定值完成它。它们返回的也是当前对象，不是自动创建一个隔离的结果副本。&lt;/p&gt;&#xD;
&lt;p&gt;这会改变依赖这个结果的后续处理，但不能据此认定 &lt;code&gt;load()&lt;/code&gt; 已经退出。&lt;/p&gt;&#xD;
&lt;p&gt;如果同一个 Future 被多个调用方共享，还应确认给其中一次等待设置期限，是否允许改变大家共享的结果状态。只想限制某个调用方等多久，与要让整个共享计算进入超时状态，是两种需求。&lt;/p&gt;&#xD;
&lt;p&gt;这部分依据 API 文档解释，本文实测输出来自上一节的 Java 8 程序，二者的版本范围分别标明。&lt;/p&gt;&#xD;
&lt;h2 id="-"&gt;五、中断是一条需要接收方配合的退出路径&lt;/h2&gt;&#xD;
&lt;p&gt;实验中，普通线程池的工作能够退出，是因为 &lt;code&gt;release.await()&lt;/code&gt; 响应了中断，代码又在异常分支结束了执行。&lt;/p&gt;&#xD;
&lt;p&gt;假如工作吞掉异常后继续处理，取消请求并不能替它补上退出逻辑：&lt;/p&gt;&#xD;
&lt;pre&gt;&lt;code class="lang-java"&gt;&lt;span class="hljs-keyword"&gt;try&lt;/span&gt; {&#xD;
    blockingStep();&#xD;
} &lt;span class="hljs-keyword"&gt;catch&lt;/span&gt; (InterruptedException ex) {&#xD;
    &lt;span class="hljs-comment"&gt;// 只记一条日志，然后继续往下执行&lt;/span&gt;&#xD;
}&#xD;
writeResult();&#xD;
&lt;/code&gt;&lt;/pre&gt;&#xD;
&lt;p&gt;常见的处理方式是向上抛出 &lt;code&gt;InterruptedException&lt;/code&gt;，或者在不能继续抛出时恢复中断标记，并离开当前任务。恢复标记只是保留中断信息，不会自动替代码执行 &lt;code&gt;return&lt;/code&gt;。&lt;/p&gt;&#xD;
&lt;p&gt;对不进行可中断等待的计算循环，可以在合适的边界检查 &lt;code&gt;Thread.currentThread().isInterrupted()&lt;/code&gt;，再走资源清理和退出路径。&lt;code&gt;Thread.interrupted()&lt;/code&gt; 会清除当前线程的中断标记，&lt;code&gt;isInterrupted()&lt;/code&gt; 则不会；不要把两个检查混用。&lt;/p&gt;&#xD;
&lt;p&gt;阻塞 I/O、数据库驱动、HTTP 客户端各有自己的行为和取消方式。不能因为 &lt;code&gt;CountDownLatch.await()&lt;/code&gt; 在实验里退出了，就认为任意网络读取也会按同样方式及时返回。&lt;/p&gt;&#xD;
&lt;p&gt;同样，&lt;code&gt;shutdownNow()&lt;/code&gt; 也只是尽力停止运行中的任务，不保证忽略中断的工作能够结束。应用共享的执行器有自己的生命周期，不能在每个请求超时后把整个线程池关闭。&lt;/p&gt;&#xD;
&lt;h2 id="-"&gt;六、需要取消底层任务，就保留底层任务的控制入口&lt;/h2&gt;&#xD;
&lt;p&gt;如果业务使用普通线程池执行可协作取消的阻塞工作，一种做法是保留 &lt;code&gt;submit()&lt;/code&gt; 返回的 Future，在等待超时或等待线程被中断时，把取消请求传给它。&lt;/p&gt;&#xD;
&lt;p&gt;下面的辅助类演示这层关系：等待预算从提交前开始计算，排队时间也算在里面；等待失败后请求取消，不把取消成功当成任务已经退出。&lt;/p&gt;&#xD;
&lt;p&gt;保存为 &lt;code&gt;WithinBudget.java&lt;/code&gt;：&lt;/p&gt;&#xD;
&lt;pre&gt;&lt;code class="lang-java"&gt;&lt;span class="hljs-keyword"&gt;import&lt;/span&gt; java.util.concurrent.Callable;&#xD;
&lt;span class="hljs-keyword"&gt;import&lt;/span&gt; java.util.concurrent.ExecutionException;&#xD;
&lt;span class="hljs-keyword"&gt;import&lt;/span&gt; java.util.concurrent.ExecutorService;&#xD;
&lt;span class="hljs-keyword"&gt;import&lt;/span&gt; java.util.concurrent.Future;&#xD;
&lt;span class="hljs-keyword"&gt;import&lt;/span&gt; java.util.concurrent.TimeUnit;&#xD;
&lt;span class="hljs-keyword"&gt;import&lt;/span&gt; java.util.concurrent.TimeoutException;&#xD;
&#xD;
&lt;span class="hljs-keyword"&gt;public&lt;/span&gt; &lt;span class="hljs-keyword"&gt;final&lt;/span&gt; &lt;span class="hljs-class"&gt;&lt;span class="hljs-keyword"&gt;class&lt;/span&gt; &lt;span class="hljs-title"&gt;WithinBudget&lt;/span&gt; &lt;/span&gt;{&#xD;
    &lt;span class="hljs-keyword"&gt;public&lt;/span&gt; &lt;span class="hljs-keyword"&gt;static&lt;/span&gt; &amp;lt;T&amp;gt; &lt;span class="hljs-function"&gt;T &lt;span class="hljs-title"&gt;call&lt;/span&gt;&lt;span class="hljs-params"&gt;(ExecutorService executor, Callable&amp;lt;T&amp;gt; action,&#xD;
                             &lt;span class="hljs-keyword"&gt;long&lt;/span&gt; budget, TimeUnit unit)&lt;/span&gt;&#xD;
            &lt;span class="hljs-keyword"&gt;throws&lt;/span&gt; InterruptedException, ExecutionException, TimeoutException &lt;/span&gt;{&#xD;
        &lt;span class="hljs-keyword"&gt;if&lt;/span&gt; (budget &amp;lt;= &lt;span class="hljs-number"&gt;0&lt;/span&gt;) {&#xD;
            &lt;span class="hljs-keyword"&gt;throw&lt;/span&gt; &lt;span class="hljs-keyword"&gt;new&lt;/span&gt; IllegalArgumentException(&lt;span class="hljs-string"&gt;"budget must be positive"&lt;/span&gt;);&#xD;
        }&#xD;
        &lt;span class="hljs-keyword"&gt;long&lt;/span&gt; budgetNanos = unit.toNanos(budget);&#xD;
        &lt;span class="hljs-keyword"&gt;long&lt;/span&gt; started = System.nanoTime();&#xD;
        Future&amp;lt;T&amp;gt; task = executor.submit(action);&#xD;
        &lt;span class="hljs-keyword"&gt;try&lt;/span&gt; {&#xD;
            &lt;span class="hljs-keyword"&gt;long&lt;/span&gt; remaining = budgetNanos - (System.nanoTime() - started);&#xD;
            &lt;span class="hljs-keyword"&gt;if&lt;/span&gt; (remaining &amp;lt;= &lt;span class="hljs-number"&gt;0&lt;/span&gt;) {&#xD;
                &lt;span class="hljs-keyword"&gt;throw&lt;/span&gt; &lt;span class="hljs-keyword"&gt;new&lt;/span&gt; TimeoutException(&lt;span class="hljs-string"&gt;"No waiting budget remains"&lt;/span&gt;);&#xD;
            }&#xD;
            &lt;span class="hljs-keyword"&gt;return&lt;/span&gt; task.get(remaining, TimeUnit.NANOSECONDS);&#xD;
        } &lt;span class="hljs-keyword"&gt;catch&lt;/span&gt; (TimeoutException ex) {&#xD;
            task.cancel(&lt;span class="hljs-keyword"&gt;true&lt;/span&gt;);&#xD;
            &lt;span class="hljs-keyword"&gt;throw&lt;/span&gt; ex;&#xD;
        } &lt;span class="hljs-keyword"&gt;catch&lt;/span&gt; (InterruptedException ex) {&#xD;
            task.cancel(&lt;span class="hljs-keyword"&gt;true&lt;/span&gt;);&#xD;
            Thread.currentThread().interrupt();&#xD;
            &lt;span class="hljs-keyword"&gt;throw&lt;/span&gt; ex;&#xD;
        }&#xD;
    }&#xD;
}&#xD;
&lt;/code&gt;&lt;/pre&gt;&#xD;
&lt;p&gt;例如，已有一个由应用统一管理的执行器时：&lt;/p&gt;&#xD;
&lt;pre&gt;&lt;code class="lang-java"&gt;Result result = WithinBudget.call(&#xD;
        executor, this::&lt;span class="hljs-keyword"&gt;load&lt;/span&gt;, &lt;span class="hljs-number"&gt;2&lt;/span&gt;, TimeUnit.SECONDS);&#xD;
&lt;/code&gt;&lt;/pre&gt;&#xD;
&lt;p&gt;这个辅助类已在本地 Java 8 上验证：正常返回结果、等待超时，以及可中断任务收到取消后退出。它没有覆盖真实 HTTP 客户端或数据库驱动的取消行为。&lt;/p&gt;&#xD;
&lt;p&gt;使用前仍有几个前提：&lt;/p&gt;&#xD;
&lt;ol&gt;&#xD;
&lt;li&gt;&lt;strong&gt;提交策略不会把耗时工作转交给当前请求线程运行。&lt;/strong&gt; 例如，线程池饱和后使用 &lt;code&gt;CallerRunsPolicy&lt;/code&gt;，&lt;code&gt;submit()&lt;/code&gt; 本身就可能同步执行工作，直到它结束才返回；等拿到 Future 再检查预算，已经无法限制这段耗时。这里应使用符合业务要求的有界队列和明确拒绝策略。&lt;/li&gt;&#xD;
&lt;li&gt;&lt;strong&gt;底层调用也有自己的时限和释放方式。&lt;/strong&gt; 连接、读取、总调用时限分别约束什么，要按实际客户端确认；只在调用外面包一层 Future，不能替代这些配置。&lt;/li&gt;&#xD;
&lt;li&gt;&lt;strong&gt;各阶段消耗同一份剩余预算。&lt;/strong&gt; 排队已经花掉的时间不能在调用下游时重新发一份；重试前也要重新计算剩余量。&lt;/li&gt;&#xD;
&lt;/ol&gt;&#xD;
&lt;p&gt;这里用 &lt;code&gt;System.nanoTime()&lt;/code&gt; 的差值测量同一 JVM 内的经过时间。它不表示日期时间，也不能把这个值直接传给另一台机器当作通用时间戳。&lt;/p&gt;&#xD;
&lt;p&gt;如果对外还需要 CompletableFuture 来组合后续处理，可以另外维护结果对象；但任务句柄、结果对象与底层 I/O 的取消关系，仍需要明确设计和验证，不能只返回一个新 CompletableFuture 就认为传播已经建立。&lt;/p&gt;&#xD;
&lt;h2 id="-future-"&gt;七、Future 完成以后，不要过早宣布资源已经归还&lt;/h2&gt;&#xD;
&lt;p&gt;这个区别还会影响并发控制。&lt;/p&gt;&#xD;
&lt;p&gt;假设一个系统规定“同一时刻最多执行十个导出”，在提交时扣减名额，又在 &lt;code&gt;whenComplete&lt;/code&gt; 里归还名额。如果结果对象先超时完成，实际导出还没退出，名额就可能提前回到池子里。新任务再进入，实际运行量便超过了原来的十个。&lt;/p&gt;&#xD;
&lt;p&gt;这不是 CompletableFuture 必然造成的问题，而是把“结果已经结束”当成“执行已经结束”的记账错误。前面的实验已经展示了两件事可以分开发生。&lt;/p&gt;&#xD;
&lt;p&gt;应先明确名额约束的是等待请求数、已提交任务数，还是实际持有资源的工作数。不同名额的归还时机不一样，实际资源的释放应跟随真正持有它的执行路径。&lt;/p&gt;&#xD;
&lt;p&gt;设计时还要覆盖任务被拒绝、尚未执行就取消、执行中退出等分支。不能把所有归还都塞进工作体的 &lt;code&gt;finally&lt;/code&gt;，却忘了某些工作根本不会开始；也不能在结果回调和工作退出时各归还一次。&lt;/p&gt;&#xD;
&lt;p&gt;此外，取消不会倒放已经发生的操作。如果导出文件已经生成，或请求已经被下游接收，本地 Future 进入取消状态，并不会自动删除文件或撤销远端处理。是否继续保存结果、按任务标识查询，或者执行清理，应由具体业务决定。&lt;/p&gt;&#xD;
&lt;h2 id="-"&gt;八、回归时，同时验证结果和工作现场&lt;/h2&gt;&#xD;
&lt;p&gt;只断言接口在两秒内返回 &lt;code&gt;TIMEOUT&lt;/code&gt;，测试还没有覆盖后台任务的生命周期。可以把回归拆成这些观察点：&lt;/p&gt;&#xD;
&lt;table&gt;&#xD;
&lt;thead&gt;&#xD;
&lt;tr&gt;&#xD;
&lt;th&gt;场景&lt;/th&gt;&#xD;
&lt;th&gt;要观察的结果&lt;/th&gt;&#xD;
&lt;/tr&gt;&#xD;
&lt;/thead&gt;&#xD;
&lt;tbody&gt;&#xD;
&lt;tr&gt;&#xD;
&lt;td&gt;仅 &lt;code&gt;get&lt;/code&gt; 等待超时&lt;/td&gt;&#xD;
&lt;td&gt;调用方退出等待，不能据此认定任务退出&lt;/td&gt;&#xD;
&lt;/tr&gt;&#xD;
&lt;tr&gt;&#xD;
&lt;td&gt;已开始的 CompletableFuture 被取消&lt;/td&gt;&#xD;
&lt;td&gt;结果状态变化，另查实际工作是否仍继续&lt;/td&gt;&#xD;
&lt;/tr&gt;&#xD;
&lt;tr&gt;&#xD;
&lt;td&gt;普通 FutureTask 收到取消&lt;/td&gt;&#xD;
&lt;td&gt;核对中断是否送达、任务是否响应、清理是否执行&lt;/td&gt;&#xD;
&lt;/tr&gt;&#xD;
&lt;tr&gt;&#xD;
&lt;td&gt;工作忽略中断&lt;/td&gt;&#xD;
&lt;td&gt;能发现取消后仍未退出，不能报成回收成功&lt;/td&gt;&#xD;
&lt;/tr&gt;&#xD;
&lt;tr&gt;&#xD;
&lt;td&gt;提交前后预算耗尽&lt;/td&gt;&#xD;
&lt;td&gt;不重新获得一份完整等待时间&lt;/td&gt;&#xD;
&lt;/tr&gt;&#xD;
&lt;tr&gt;&#xD;
&lt;td&gt;取消与正常完成竞争&lt;/td&gt;&#xD;
&lt;td&gt;不重复记账，不依赖固定线程调度顺序&lt;/td&gt;&#xD;
&lt;/tr&gt;&#xD;
&lt;tr&gt;&#xD;
&lt;td&gt;底层请求已产生结果&lt;/td&gt;&#xD;
&lt;td&gt;按业务规则处理迟到结果，不把取消当成撤销&lt;/td&gt;&#xD;
&lt;/tr&gt;&#xD;
&lt;/tbody&gt;&#xD;
&lt;/table&gt;&#xD;
&lt;p&gt;这些是回归设计；本文实际运行验证的是完整对照程序和等待预算辅助类，不把表中所有场景都标成已实测。&lt;/p&gt;&#xD;
&lt;p&gt;排查线上问题时，建议给一次工作分配可关联的任务标识，分别记录提交、开始、调用方停止等待、发出取消、实际退出这几个时刻。超时日志与退出日志之间隔了多久，往往比一句 &lt;code&gt;cancelled=true&lt;/code&gt; 更能说明问题。&lt;/p&gt;&#xD;
&lt;p&gt;以前的 &lt;a href="https://www.hqxiaozou.top/post/ZSFA1SIfFh6"&gt;CompletableFuture 设计与实践&lt;/a&gt; 讨论了结果组合。这次对照实验补上另一端：&lt;strong&gt;结果对象已经结束，并不意味着产生结果的工作已经结束。&lt;/strong&gt;&lt;/p&gt;&#xD;
&lt;p&gt;把等待期限、取消传播、资源回收分别做清楚，超时才不只是界面上的两个字。&lt;/p&gt;</description>
      <pubDate>Thu, 15 Oct 2026 01:53:08 GMT</pubDate>
    </item>
    <item>
      <title>日志删了，磁盘空间为什么没回来</title>
      <link>https://www.hqxiaozou.top/post/linux-deleted-log-disk-space</link>
      <description>&lt;p&gt;日志已经删了，目录里也找不到大文件，磁盘告警却一点没降。再执行一次 &lt;code&gt;rm&lt;/code&gt; 没用，&lt;code&gt;du&lt;/code&gt; 算出来的总量也明显小于 &lt;code&gt;df&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;这类现象容易让人怀疑统计工具出了问题。更值得先查的是：文件的名字没了，是否还有进程拿着它？&lt;/p&gt;
&lt;p&gt;本文围绕普通 Linux 文件系统上的“已删除但仍被打开的文件”展开。后面用一个实测过的 8 MiB 小实验复现，不需要把磁盘写满，也不需要停止业务进程。示例中的业务路径、PID 和 FD 都是假设值，执行排查命令时要替换成实际对象。&lt;/p&gt;
&lt;h2&gt;一、先确认 df 和 du 查的是同一块盘&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;df&lt;/code&gt; 报告文件系统的空间状态；&lt;code&gt;du&lt;/code&gt; 遍历指定目录下能访问到的文件，估算它们占用的空间。这两个工具的观察范围不同，不能拿任意两个数字直接相减。(&lt;a href="https://www.gnu.org/software/coreutils/manual/html_node/df-invocation.html"&gt;GNU df&lt;/a&gt;、&lt;a href="https://www.gnu.org/software/coreutils/manual/html_node/du-invocation.html"&gt;GNU du&lt;/a&gt;)&lt;/p&gt;
&lt;p&gt;假设告警涉及 &lt;code&gt;/var/log&lt;/code&gt;，先查它实际所在的挂载点：&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-bash"&gt;df -hT /var/log
findmnt -T /var/log
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;假如结果显示 &lt;code&gt;/var/log&lt;/code&gt; 属于根文件系统，检查整个根文件系统时可以用：&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-bash"&gt;sudo du -x -h --max-depth=1 /
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;-x&lt;/code&gt; 限制在同一个文件系统内遍历。若 &lt;code&gt;/var/log&lt;/code&gt; 本来就是独立挂载点，应从它自己的挂载根目录统计，不能把根分区和日志分区混在一起。&lt;/p&gt;
&lt;p&gt;扫描大目录会产生读取开销，先看一级，再沿着占用大的目录向下查。不要在繁忙机器上反复全盘扫描；也不要直接加 &lt;code&gt;2&amp;gt;/dev/null&lt;/code&gt;，把权限错误一起藏掉之后，再断言“这里没有大文件”。&lt;/p&gt;
&lt;p&gt;另一个容易误判的地方是文件大小口径。&lt;code&gt;ls -l&lt;/code&gt; 的逻辑长度与实际分配块数可能不同，稀疏文件尤其明显；&lt;code&gt;du --apparent-size&lt;/code&gt; 改为统计逻辑大小，也不会因此变成 &lt;code&gt;df&lt;/code&gt;。比较时先统一范围和口径，再调查剩下的差距。&lt;/p&gt;
&lt;h2&gt;二、rm 移除的是名字，打开的文件不会立即失效&lt;/h2&gt;
&lt;p&gt;可以把这段生命周期拆成三件事：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;目录项把文件名关联到 inode。&lt;/li&gt;
&lt;li&gt;进程打开文件后，持有一个可用于读写的文件描述符。&lt;/li&gt;
&lt;li&gt;删除最后一个文件名，不会替进程关闭已经打开的描述符。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Linux 的 &lt;code&gt;unlink()&lt;/code&gt; 文档明确区分了“移除名字”和“释放文件”：最后一个链接移除后，如果仍有进程打开它，文件会继续存在，直到相关的最后一个打开引用关闭。(&lt;a href="https://man7.org/linux/man-pages/man2/unlink.2.html"&gt;unlink(2)&lt;/a&gt;)&lt;/p&gt;
&lt;p&gt;所以，删除一个正在写的日志后，可能出现这样的状态：&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-text"&gt;目录里的 app.log：已经不存在
进程里的 FD 7：仍然指向原来的文件
原文件的磁盘块：仍然被占用
进程继续写日志：原文件可能继续增长
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这时，按目录遍历的 &lt;code&gt;du&lt;/code&gt; 找不到原来的名字，文件系统却仍然把那些块算作已用空间。&lt;/p&gt;
&lt;p&gt;还有一个细节：重新创建一个同名 &lt;code&gt;app.log&lt;/code&gt;，不会把旧 FD 自动接到新文件上。新的目录项和进程手里的旧文件可以同时存在，名字看起来一样，指向的对象却已经不同。&lt;/p&gt;
&lt;h2&gt;三、用 lsof 找持有人，再用 /proc 核对对象&lt;/h2&gt;
&lt;h3&gt;1. 先找链接数为零的打开文件&lt;/h3&gt;
&lt;p&gt;系统安装了 &lt;code&gt;lsof&lt;/code&gt; 时，可以先执行只读查询：&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-bash"&gt;sudo lsof -nP +L1
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;+L1&lt;/code&gt; 选择链接数小于 1 的打开文件，也就是已经解除链接的文件。&lt;code&gt;-nP&lt;/code&gt; 避免地址和端口名称解析。确认目标挂载点后，还可以缩小范围：&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-bash"&gt;# 假设目标文件系统挂载在 /var
sudo lsof -nP +aL1 /var
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;下面是解释字段用的示意，不是某次线上故障的真实日志：&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-text"&gt;COMMAND  PID   USER  FD  TYPE DEVICE SIZE/OFF NLINK NODE   NAME
java     12345 app   7w  REG  8,1    8388608  0     456789 /var/log/app.log (deleted)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;先看 &lt;code&gt;TYPE&lt;/code&gt; 是否为普通文件 &lt;code&gt;REG&lt;/code&gt;、&lt;code&gt;NLINK&lt;/code&gt; 是否为零，再看进程和原路径。&lt;code&gt;FD&lt;/code&gt; 中的 &lt;code&gt;7w&lt;/code&gt; 表示编号 7 的写描述符，核对 &lt;code&gt;/proc&lt;/code&gt; 时只用数字 7。具体列名和显示方式会随平台、版本及选项变化。(&lt;a href="https://github.com/lsof-org/lsof/blob/master/docs/manpage.md"&gt;lsof manual&lt;/a&gt;)&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;不要把 SIZE/OFF 一列直接全部求和。&lt;/strong&gt; 同一个文件可能被多个 FD、多个进程持有，而且逻辑长度也不等于分配空间。评估普通文件时，应按设备号和 inode 识别同一对象，避免重复计算。&lt;/p&gt;
&lt;h3&gt;2. 顺着 FD 看它实际指向什么&lt;/h3&gt;
&lt;p&gt;确认示例 PID 和 FD 后：&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-bash"&gt;PID=12345
FD=7

sudo readlink &amp;quot;/proc/$PID/fd/$FD&amp;quot;
sudo stat -L -c &amp;#39;device=%d inode=%i links=%h size=%s blocks=%b block_unit=%B&amp;#39; &amp;quot;/proc/$PID/fd/$FD&amp;quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Linux 的 &lt;code&gt;/proc/&amp;lt;pid&amp;gt;/fd&lt;/code&gt; 按描述符编号列出符号链接，&lt;code&gt;stat -L&lt;/code&gt; 会跟随链接读取对应文件的状态。(&lt;a href="https://man7.org/linux/man-pages/man5/proc_pid_fd.5.html"&gt;proc_pid_fd(5)&lt;/a&gt;)&lt;/p&gt;
&lt;p&gt;在 GNU &lt;code&gt;stat&lt;/code&gt; 的这组输出里，&lt;code&gt;size&lt;/code&gt; 是逻辑字节数，&lt;code&gt;blocks × block_unit&lt;/code&gt; 是所报告分配块的字节数。这里的 &lt;code&gt;block_unit&lt;/code&gt; 不应误当成文件系统的最佳 I/O 大小。(&lt;a href="https://www.gnu.org/software/coreutils/manual/html_node/stat-invocation.html"&gt;GNU stat&lt;/a&gt;)&lt;/p&gt;
&lt;p&gt;采样过程中，进程可能关闭或重新使用这个 FD。若两次结果不一致，要重新查询；权限不足、进程退出或者 FD 消失，都不能直接解释成“已经没有占用”。&lt;/p&gt;
&lt;p&gt;另外，看到 &lt;code&gt;(deleted)&lt;/code&gt; 并不自动等于异常。程序也可能有意删除临时文件的名字，再通过已有句柄使用它。要判断的是它的大小、持有时间和业务用途。&lt;/p&gt;
&lt;h2&gt;四、用 8 MiB 复现：名字没了，文件还能读&lt;/h2&gt;
&lt;p&gt;下面的实验需要 Linux、Python 3 和 GNU &lt;code&gt;du&lt;/code&gt;。它只创建自己的临时目录和一个 8 MiB 文件，读写完成后自动清理，不操作任何业务文件。&lt;/p&gt;
&lt;p&gt;保存为 &lt;code&gt;deleted_file_lab.py&lt;/code&gt;：&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-python"&gt;import os
import subprocess
import tempfile


def disk_usage(path):
    output = subprocess.check_output([&amp;quot;du&amp;quot;, &amp;quot;-B1&amp;quot;, &amp;quot;-s&amp;quot;, path])
    return int(output.split()[0])


with tempfile.TemporaryDirectory(prefix=&amp;quot;deleted-file-lab-&amp;quot;, dir=&amp;quot;/tmp&amp;quot;) as directory:
    path = os.path.join(directory, &amp;quot;demo.log&amp;quot;)
    with open(path, &amp;quot;w+b&amp;quot;, buffering=0) as stream:
        for _ in range(8):
            stream.write(b&amp;quot;x&amp;quot; * 1024 * 1024)
        os.fsync(stream.fileno())
        print(&amp;quot;du_before_unlink=&amp;quot;, disk_usage(directory))

        fd_path = &amp;quot;/proc/{}/fd/{}&amp;quot;.format(os.getpid(), stream.fileno())
        os.unlink(path)
        info = os.fstat(stream.fileno())
        print(&amp;quot;pathname_exists=&amp;quot;, os.path.exists(path))
        print(&amp;quot;fd_target=&amp;quot;, os.readlink(fd_path))
        print(&amp;quot;link_count=&amp;quot;, info.st_nlink)
        print(&amp;quot;logical_bytes=&amp;quot;, info.st_size)
        print(&amp;quot;allocated_bytes=&amp;quot;, info.st_blocks * 512)
        print(&amp;quot;du_after_unlink=&amp;quot;, disk_usage(directory))
        stream.seek(0)
        print(&amp;quot;read_after_unlink=&amp;quot;, stream.read(1))
        assert info.st_nlink == 0
        assert info.st_size == 8 * 1024 * 1024
        assert info.st_blocks &amp;gt; 0
        assert not os.path.exists(path)

    print(&amp;quot;fd_exists_after_close=&amp;quot;, os.path.lexists(fd_path))
    assert not os.path.lexists(fd_path)

print(&amp;quot;temporary_directory_removed=&amp;quot;, not os.path.exists(directory))
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;运行：&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-bash"&gt;python3 deleted_file_lab.py
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;在本次 Linux 验证中，关键输出如下；临时目录的随机后缀已省略：&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-text"&gt;du_before_unlink= 8392704
pathname_exists= False
fd_target= /tmp/deleted-file-lab-.../demo.log (deleted)
link_count= 0
logical_bytes= 8388608
allocated_bytes= 8388608
du_after_unlink= 4096
read_after_unlink= b&amp;#39;x&amp;#39;
fd_exists_after_close= False
temporary_directory_removed= True
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;8 MiB 等于 8,388,608 字节。删除前，目录统计还包含 4 KiB 的目录自身占用；删除后，&lt;code&gt;du&lt;/code&gt; 只剩这 4 KiB，但 &lt;code&gt;fstat()&lt;/code&gt; 仍能看到文件的 8 MiB 分配空间，读操作也能取得原来的字节。&lt;/p&gt;
&lt;p&gt;离开 &lt;code&gt;with open(...)&lt;/code&gt; 后，程序关闭了最后一个文件描述符，对应 &lt;code&gt;/proc&lt;/code&gt; 条目消失。实验没有读取或写入真实日志，临时目录也已清理。&lt;/p&gt;
&lt;p&gt;这里刻意不拿繁忙服务器两次 &lt;code&gt;df&lt;/code&gt; 的差值当作精确实验结果。其他进程也在分配和释放空间，文件系统的统计更新还可能有延迟；小实验要证明的是生命周期，而不是制造一个全局空间数字恰好相等的巧合。&lt;/p&gt;
&lt;h2&gt;五、找到占用之后，释放路径要由持有人决定&lt;/h2&gt;
&lt;p&gt;对于一个仍在工作的日志写入进程，修复通常应该让它关闭旧文件并打开正确的新文件。进程支持什么操作，要查它自己的文档。&lt;/p&gt;
&lt;p&gt;以 Nginx 为例，官方文档规定 &lt;code&gt;USR1&lt;/code&gt; 用于重新打开日志；轮转时先重命名旧日志，再通知 master，master 和 worker 会重新打开日志并关闭旧文件。(&lt;a href="https://nginx.org/en/docs/control.html"&gt;Nginx 日志轮转&lt;/a&gt;)&lt;/p&gt;
&lt;p&gt;确认使用的 Nginx 实例、配置前缀和 PID 文件后，可按该实例的运维方式执行重新打开日志，例如：&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-bash"&gt;# 操作对象必须是已经确认的 Nginx 实例
sudo nginx -s reopen
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;随后重新查 &lt;code&gt;lsof&lt;/code&gt;、检查新日志是否持续写入，再观察目标文件系统的空间。命令返回成功只是操作结果，旧引用是否消失才是释放证据。&lt;/p&gt;
&lt;p&gt;Java 日志框架是否支持重新打开文件，取决于实际 Appender 和轮转配置；不能把 Nginx 的信号约定直接套给 Java。若只能重启服务，应先安排流量摘除和恢复验证，不要看到某个 PID 就直接结束进程。&lt;/p&gt;
&lt;p&gt;把 &lt;code&gt;/proc/&amp;lt;pid&amp;gt;/fd/&amp;lt;fd&amp;gt;&lt;/code&gt; 截断为零也不是通用修复。截断会丢弃内容，却不会自动重置原描述符的文件偏移；某些后续写入可能重新形成较大的逻辑长度。(&lt;a href="https://man7.org/linux/man-pages/man2/truncate.2.html"&gt;truncate(2)&lt;/a&gt;)&lt;/p&gt;
&lt;p&gt;尤其不要把数据库文件、WAL、binlog 或用途不明的文件当成普通日志清空。空间问题应通过对应组件的保留策略和维护操作处理。&lt;/p&gt;
&lt;h2&gt;六、为什么日志轮转了，还会留下旧文件&lt;/h2&gt;
&lt;p&gt;轮转的关键不只是把文件改名，还包括通知写入方切换。&lt;/p&gt;
&lt;p&gt;一种常见错误是：外部脚本改名或删除旧日志，应用却一直通过原 FD 写入。目录里的新日志很小甚至为空，旧文件则在看不见的位置继续增长。&lt;/p&gt;
&lt;p&gt;&lt;code&gt;logrotate&lt;/code&gt; 的 &lt;code&gt;copytruncate&lt;/code&gt; 是另一种方式：先复制，再在原文件上截断，适用于无法通知程序重新打开日志的情况。但复制与截断之间存在时间窗口，官方手册明确指出可能丢失期间写入的数据。它也会产生复制所需的额外空间。(&lt;a href="https://man7.org/linux/man-pages/man8/logrotate.8.html"&gt;logrotate(8)&lt;/a&gt;)&lt;/p&gt;
&lt;p&gt;因此，配置里有 &lt;code&gt;rotate&lt;/code&gt;、&lt;code&gt;compress&lt;/code&gt;，并不能证明轮转链路完整。上线后至少要实测一次：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;轮转前后，应用写入的是哪个文件对象？&lt;/li&gt;
&lt;li&gt;新日志有没有继续收到内容？&lt;/li&gt;
&lt;li&gt;旧文件是否还被持有，持有多久？&lt;/li&gt;
&lt;li&gt;保留与压缩任务是否真正执行，失败有没有被发现？&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;如果应用自身已经轮转日志，外部脚本又独立移动同一批文件，还应确认两套机制不会争抢同一个文件的生命周期。&lt;/p&gt;
&lt;h2&gt;七、没找到大的 deleted 文件，就换一条调查路径&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;df&lt;/code&gt; 和 &lt;code&gt;du&lt;/code&gt; 的差距不能只用这一种原因解释。前面的查询没有足够证据时，应继续缩小问题：&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;现象&lt;/th&gt;
&lt;th&gt;下一步核对&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;创建文件失败，但容量还有余量&lt;/td&gt;
&lt;td&gt;&lt;code&gt;df -i&lt;/code&gt;，确认是否耗尽 inode&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;某个用户写入失败，其他用户正常&lt;/td&gt;
&lt;td&gt;用户或项目配额、错误码及实际写入身份&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;目录统计遗漏一大块数据&lt;/td&gt;
&lt;td&gt;统计范围、权限错误、挂载覆盖和容器挂载命名空间&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;文件已删除，快照仍保留历史&lt;/td&gt;
&lt;td&gt;对应存储系统的快照与保留策略&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;逻辑大小很大，实际占用很小&lt;/td&gt;
&lt;td&gt;稀疏文件、压缩和共享块等文件系统特性&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;这些是后续调查方向，不是看到一个数字差就能成立的结论。文件系统元数据和保留空间也会影响统计，不能把所有差值都标成泄漏。&lt;/p&gt;
&lt;h2&gt;八、留下能复核的证据，比留下一个清理命令更有用&lt;/h2&gt;
&lt;p&gt;一次完整排查，至少应记录目标文件系统、占用对象的设备号与 inode、持有者 PID 和启动时间、相关 FD，以及采取操作前后的状态。&lt;/p&gt;
&lt;p&gt;其中有三条最值得保留：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;删除路径之前，先确认谁正在使用它。&lt;/strong&gt; 路径消失不代表使用结束。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;恢复之后，核对旧引用是否消失、新日志是否正常。&lt;/strong&gt; 不只看 &lt;code&gt;df&lt;/code&gt; 有没有下降。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;让轮转和写入方协同。&lt;/strong&gt; 反复手工删除，只会让同一个问题换个时间回来。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;以前写过 &lt;a href="https://www.hqxiaozou.top/post/TAiWmUnKMJS"&gt;Too many open files 排障实战&lt;/a&gt;，关注的是描述符数量及释放边界。这篇的入口不同：哪怕进程只多持有一个 FD，它指向的文件足够大，也能占住大量磁盘空间。&lt;/p&gt;
&lt;p&gt;所以，查文件问题时，数量和体积都要看；更要看一个对象从创建、使用到释放的整个过程。&lt;/p&gt;</description>
      <pubDate>Wed, 14 Oct 2026 11:58:25 GMT</pubDate>
    </item>
    <item>
      <title>这个博客三年多来的更新日志</title>
      <link>https://www.hqxiaozou.top/post/iCbjXx1C230</link>
      <description>&lt;blockquote&gt;
&lt;p&gt;本文根据两个仓库的完整 Git 提交记录整理：原始 blog 仓库的 431 条提交，以及 &lt;code&gt;total&lt;/code&gt; 聚合工程中触及 &lt;code&gt;blog&lt;/code&gt; 目录的 378 条提交（含 2026 年 7 月 2 日的迁移提交），共 809 条。
统计截止：2026-10-09（&lt;code&gt;705b9b1&lt;/code&gt;）；统计分支：两个仓库的 &lt;code&gt;master&lt;/code&gt;。两个仓库都没有打过 Git Tag，因此本文按自然年、月份和功能阶段整理。
2026-09-24 更新：补写了 7 月下旬至 9 月的内容；并逐条核对提交日期，把旧版误记到 2025、2026 年的几项改动（七牛封面、Redis 登录封禁、评论先审后显、天气异步缓存等）归回它们实际发生的 2024 年。
2026-10-07 更新：补写 9 月 24 日之后的内容——六套主题、炊想小程序与博客后台、10 月初的打磨和后台重做。当天晚些时候又补充了炊想小程序的后续改动。
2026-10-08 更新：补写 10 月 7 日下午到 8 日的内容——首页“正在做”的项目图标、流萤巡检与天气修复、炊想的订单与反馈回复，统计数字随之更新。
2026-10-13 更新：补充炊想小程序 10 月 10—13 日的登录、邀请与 2.0.19 的界面精修（小程序提交，blog 统计仍截至 &lt;code&gt;705b9b1&lt;/code&gt;）。
2026-10-20 更新：补充炊想至 3.1.0 的功能与界面；本文原有 blog 提交统计范围不变。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2 id="写在前面"&gt;写在前面&lt;/h2&gt;
&lt;p&gt;博客上线得比 Git 历史更早——站内最早的一篇文章发布于 2021 年 9 月——但它的代码直到 2023 年 1 月 10 日才第一次放进 Git。这份更新日志从那一天算起。&lt;/p&gt;
&lt;p&gt;第一条提交 &lt;code&gt;679a795&lt;/code&gt; 放进来的，已经是一个具备文章、分类、标签、评论和后台管理的完整博客。此后三年多，它从一个传统的 Spring Boot 个人博客出发，陆续补上留言互动、邮件通知、天气、访问统计、移动端适配、评论审核和性能优化，又在 2026 年引入了 AI 问答与 AI 评论审核。&lt;/p&gt;
&lt;p&gt;2026 年 7 月 2 日，博客以 &lt;code&gt;2c3c5ad&lt;/code&gt; 并入 &lt;code&gt;total&lt;/code&gt; 多模块工程。仓库换了，历史没有重来：原仓库记录它从 2023 年到 2026 年 6 月的成长，聚合仓库接着记录之后的每一次改动。&lt;/p&gt;
&lt;p&gt;并入之后，节奏明显加快。三个多月里，触及 blog 的提交就有 378 条，接近 2024、2025 两年之和（191 条）的两倍；2026 年 9 月以 164 条成为整个历史上提交最多的月份。关注点也在变：新功能还在加，但更多提交是在把已有的东西做扎实——缓存与加载速度、评论与 AI 审核链路的可靠性、移动端的细节，以及测试。blog 模块并入时一个测试都没有，现在有 149 个测试类，其中 62 个是检查模板、样式和脚本约定的契约测试。&lt;/p&gt;
&lt;h2 id="一、git-历史全景"&gt;一、Git 历史全景&lt;/h2&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th align="left"&gt;项目&lt;/th&gt;
&lt;th align="left"&gt;数据&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td align="left"&gt;首次提交&lt;/td&gt;
&lt;td align="left"&gt;2023-01-10&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td align="left"&gt;首次提交哈希&lt;/td&gt;
&lt;td align="left"&gt;&lt;code&gt;679a795&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td align="left"&gt;原 blog 仓库&lt;/td&gt;
&lt;td align="left"&gt;431 次&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td align="left"&gt;迁移提交&lt;/td&gt;
&lt;td align="left"&gt;1 次&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td align="left"&gt;迁移后提交&lt;/td&gt;
&lt;td align="left"&gt;377 次&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td align="left"&gt;合计&lt;/td&gt;
&lt;td align="left"&gt;809 次&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td align="left"&gt;有提交的开发日&lt;/td&gt;
&lt;td align="left"&gt;234 天&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td align="left"&gt;最多的一个月&lt;/td&gt;
&lt;td align="left"&gt;2026-09，164 次&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td align="left"&gt;最多的一天&lt;/td&gt;
&lt;td align="left"&gt;2026-09-23，24 次&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td align="left"&gt;统计终点&lt;/td&gt;
&lt;td align="left"&gt;&lt;code&gt;705b9b1&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;blockquote&gt;
&lt;p&gt;809 次 = 原仓库 431 次（2023-01-10 至 2026-06-16）+ 迁移提交 1 次（&lt;code&gt;2c3c5ad&lt;/code&gt;，2026-07-02）+ 迁移后触及 blog 目录的 377 次（含 6 次合并提交，统计到 2026-10-09 的 &lt;code&gt;705b9b1&lt;/code&gt;）。迁移提交代表代码库归属的变化，并不是一次新的功能开发。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h3 id="各年度提交量"&gt;各年度提交量&lt;/h3&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th align="center"&gt;年份&lt;/th&gt;
&lt;th align="center"&gt;提交数&lt;/th&gt;
&lt;th align="left"&gt;主要内容&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td align="center"&gt;2023&lt;/td&gt;
&lt;td align="center"&gt;51&lt;/td&gt;
&lt;td align="left"&gt;初始版本、留言互动、邮件、访问统计、纪念日、天气&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td align="center"&gt;2024&lt;/td&gt;
&lt;td align="center"&gt;82&lt;/td&gt;
&lt;td align="left"&gt;七牛封面、Redis 登录封禁、评论先审后显、页面缓存、图片交互&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td align="center"&gt;2025&lt;/td&gt;
&lt;td align="center"&gt;109&lt;/td&gt;
&lt;td align="left"&gt;评论按需加载、后台评论运营、表情体系、心知天气&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td align="center"&gt;2026&lt;/td&gt;
&lt;td align="center"&gt;567&lt;/td&gt;
&lt;td align="left"&gt;AI 问答与审核、界面重做、聚合部署、六套主题、炊想小程序、性能与可靠性治理&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td align="center"&gt;&lt;strong&gt;合计&lt;/strong&gt;&lt;/td&gt;
&lt;td align="center"&gt;&lt;strong&gt;809&lt;/strong&gt;&lt;/td&gt;
&lt;td align="left"&gt;从独立博客到持续迭代的聚合应用&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;2026 年的 567 次提交，由原仓库 1—6 月的 189 次和 &lt;code&gt;total&lt;/code&gt; 仓库 7—10 月触及 blog 目录的 378 次组成。&lt;/p&gt;
&lt;h3 id="一图看全程"&gt;一图看全程&lt;/h3&gt;
&lt;pre&gt;&lt;code class="language-mermaid"&gt;flowchart TB
    subgraph R1[&amp;quot;原 blog 仓库：2023-01 至 2026-06，431 次&amp;quot;]
        direction LR
        Y23[&amp;quot;2023 · 51 次&amp;lt;br/&amp;gt;从零到可用&amp;quot;] --&amp;gt; Y24[&amp;quot;2024 · 82 次&amp;lt;br/&amp;gt;补齐底座&amp;quot;] --&amp;gt; Y25[&amp;quot;2025 · 109 次&amp;lt;br/&amp;gt;评论重构&amp;quot;] --&amp;gt; Y26[&amp;quot;2026 上半年 · 189 次&amp;lt;br/&amp;gt;AI 进入博客&amp;quot;]
    end
    subgraph R2[&amp;quot;total 聚合工程：2026-07 至今，378 次&amp;quot;]
        direction LR
        MV[&amp;quot;7 月 2 日&amp;lt;br/&amp;gt;迁入 total&amp;quot;] --&amp;gt; M7[&amp;quot;7 月 · 84 次&amp;lt;br/&amp;gt;界面重做&amp;quot;] --&amp;gt; M8[&amp;quot;8 月 · 68 次&amp;lt;br/&amp;gt;字体与 AI 容错&amp;quot;] --&amp;gt; M9[&amp;quot;9 月 · 164 次&amp;lt;br/&amp;gt;性能与六套主题&amp;quot;] --&amp;gt; M10[&amp;quot;10 月 · 62 次&amp;lt;br/&amp;gt;炊想与后台&amp;quot;]
    end
    R1 --&amp;gt; R2
&lt;/code&gt;&lt;/pre&gt;
&lt;h2 id="二、2023：从零到可用"&gt;二、2023：从零到可用&lt;/h2&gt;
&lt;p&gt;2023 年共 51 条提交，大部分集中在上半年。&lt;/p&gt;
&lt;h3 id="1-初始版本"&gt;1. 初始版本&lt;/h3&gt;
&lt;p&gt;2023 年 1 月 10 日，&lt;code&gt;679a795&lt;/code&gt; 完成首次提交。项目以 Spring Boot、Thymeleaf 和 MySQL 为技术底座，一开始就具备个人博客最核心的一组能力：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;文章发布与管理；&lt;/li&gt;
&lt;li&gt;分类、标签与归档；&lt;/li&gt;
&lt;li&gt;评论和后台管理；&lt;/li&gt;
&lt;li&gt;基础主题与前台页面。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;同一天又连续调整了数据库脚本和页面跳转方式。此后几年里，SQL 备份文件随提交持续更新，为数据库结构和内容留下了一份可追溯的版本记录。&lt;/p&gt;
&lt;h3 id="2-留言、邮件与内容互动"&gt;2. 留言、邮件与内容互动&lt;/h3&gt;
&lt;p&gt;2 月到 4 月，博客从“能展示内容”走向“能和访客交流”：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;4997789&lt;/code&gt; 更新留言板；&lt;/li&gt;
&lt;li&gt;&lt;code&gt;58ad919&lt;/code&gt; 修复 QQ 邮箱配置；&lt;/li&gt;
&lt;li&gt;&lt;code&gt;7d76b89&lt;/code&gt; 增加微信公众号引流；&lt;/li&gt;
&lt;li&gt;&lt;code&gt;f14811d&lt;/code&gt; 修复管理员头像不显示；&lt;/li&gt;
&lt;li&gt;&lt;code&gt;88daf33&lt;/code&gt; 修复搜索无结果；&lt;/li&gt;
&lt;li&gt;&lt;code&gt;bc045ef&lt;/code&gt; 增加支付宝收款码；&lt;/li&gt;
&lt;li&gt;&lt;code&gt;16513e3&lt;/code&gt; 优化异常页面样式，&lt;code&gt;966336e&lt;/code&gt; 补上 README。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;功能还比较零散，但方向已经清楚：博客不仅要能写文章，也要能承载人与人之间的交流。&lt;/p&gt;
&lt;h3 id="3-访问统计、纪念日与天气"&gt;3. 访问统计、纪念日与天气&lt;/h3&gt;
&lt;p&gt;4 月起，博客开始有了自己的性格：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;59f9a0b&lt;/code&gt; 把网站访问量持久化到数据库；&lt;/li&gt;
&lt;li&gt;&lt;code&gt;1a9aa78&lt;/code&gt; 增加 1000 天纪念日；&lt;/li&gt;
&lt;li&gt;&lt;code&gt;8aa0b4c&lt;/code&gt; 修复时区问题；&lt;/li&gt;
&lt;li&gt;&lt;code&gt;f32f81e&lt;/code&gt; 优化烟花页面样式；&lt;/li&gt;
&lt;li&gt;&lt;code&gt;6176943&lt;/code&gt; 接入高德地图，展示实时天气；&lt;/li&gt;
&lt;li&gt;&lt;code&gt;edd3997&lt;/code&gt;、&lt;code&gt;60ad551&lt;/code&gt; 增加明日天气和最低气温预报。&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id="4-写着the-last-version的一次提交"&gt;4. 写着“the last version”的一次提交&lt;/h3&gt;
&lt;p&gt;下半年的提交明显变少：7 月取消了公众号引流（&lt;code&gt;a1e0672&lt;/code&gt;），8 月修复新建文章时资源加载缓慢（&lt;code&gt;7b1648b&lt;/code&gt;）。8 月 14 日的一条提交，说明写的是“the last version: update sql file”（&lt;code&gt;bff611d&lt;/code&gt;），之后项目停了整整三个月。&lt;/p&gt;
&lt;p&gt;它并不是最后一版。11 月，项目又更新了头像、视频和邮件设置，修复了天气接口异常（&lt;code&gt;8e75520&lt;/code&gt;、&lt;code&gt;c7ccf6d&lt;/code&gt;）和图片失效的问题。到年底，博客已经不再是一套通用模板，而是有了自己的内容、互动和视觉风格。&lt;/p&gt;
&lt;h2 id="三、2024：恢复迭代，把底座补齐"&gt;三、2024：恢复迭代，把底座补齐&lt;/h2&gt;
&lt;p&gt;2024 年共 82 条提交，全部集中在 6 月至 11 月，其中 7 月单月 50 条。许多后来一直沿用的基础设施，都是这一年定下来的。&lt;/p&gt;
&lt;h3 id="1-封面、文章链接与音乐"&gt;1. 封面、文章链接与音乐&lt;/h3&gt;
&lt;p&gt;停更半年多之后，项目在 6 月恢复迭代：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;675beef&lt;/code&gt; 文章缩略图改为七牛云；&lt;/li&gt;
&lt;li&gt;&lt;code&gt;111175c&lt;/code&gt; 文章链接改为随机 11 位字符（含大写字母），也就是现在文章地址 &lt;code&gt;/post/&lt;/code&gt; 后面的那一串；&lt;/li&gt;
&lt;li&gt;&lt;code&gt;e0c888a&lt;/code&gt; 接入网易云热门歌单；&lt;/li&gt;
&lt;li&gt;&lt;code&gt;36f39c8&lt;/code&gt; 优化搜索后的页面跳转，&lt;code&gt;a0d665b&lt;/code&gt; 修复自动获取 QQ 信息失效；&lt;/li&gt;
&lt;li&gt;&lt;code&gt;6542dff&lt;/code&gt; 暂时屏蔽留言板视频模块。&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id="2-登录、审核与缓存"&gt;2. 登录、审核与缓存&lt;/h3&gt;
&lt;p&gt;7 月初的几条提交，奠定了之后的安全和性能基础：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;05a8e3a&lt;/code&gt; 后台登录失败后用 Redis 封禁；&lt;/li&gt;
&lt;li&gt;&lt;code&gt;986f6f2&lt;/code&gt; 评论改为审核后可见；&lt;/li&gt;
&lt;li&gt;&lt;code&gt;e25dc89&lt;/code&gt; 页面加入缓存；&lt;/li&gt;
&lt;li&gt;&lt;code&gt;fee7b57&lt;/code&gt; IP 属地显示具体到市；&lt;/li&gt;
&lt;li&gt;&lt;code&gt;9560930&lt;/code&gt; 删除评论增加二次确认，&lt;code&gt;46713e3&lt;/code&gt; 修复删除后页面不刷新。&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id="3-天气、图片与评论表单"&gt;3. 天气、图片与评论表单&lt;/h3&gt;
&lt;p&gt;7 月里，交互体验被集中打磨了一轮：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;d1ffb14&lt;/code&gt; 天气改为异步加载并缓存，&lt;code&gt;aaad629&lt;/code&gt; 首页增加加载样式；&lt;/li&gt;
&lt;li&gt;&lt;code&gt;947434e&lt;/code&gt;、&lt;code&gt;c49341c&lt;/code&gt; 查看图片支持放大、缩小和左右拖动；&lt;/li&gt;
&lt;li&gt;&lt;code&gt;9420c2b&lt;/code&gt;、&lt;code&gt;43b5ec2&lt;/code&gt; 评论时根据 QQ 号自动填充邮箱和头像，&lt;code&gt;cf12c00&lt;/code&gt; 获取 QQ 昵称失败时改用备用接口；&lt;/li&gt;
&lt;li&gt;&lt;code&gt;ee65427&lt;/code&gt; 输入框增加清除按钮，&lt;code&gt;575d296&lt;/code&gt; 不填 QQ 时显示默认头像；&lt;/li&gt;
&lt;li&gt;&lt;code&gt;54bd32d&lt;/code&gt; 升级 ip2region 让定位更准确，并让评论状态的修改实时刷新；&lt;/li&gt;
&lt;li&gt;&lt;code&gt;689f6e3&lt;/code&gt; 评论支持 emoji，&lt;code&gt;2f30a37&lt;/code&gt; 文章页增加返回顶部按钮。&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id="4-下半年：箴言、视频与个人页面"&gt;4. 下半年：箴言、视频与个人页面&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;12b386c&lt;/code&gt; 新增“站长箴言”页面；&lt;/li&gt;
&lt;li&gt;&lt;code&gt;9d63238&lt;/code&gt; 恢复留言板视频，11 月的 &lt;code&gt;700e52b&lt;/code&gt; 又换了一批视频；&lt;/li&gt;
&lt;li&gt;&lt;code&gt;12c19f0&lt;/code&gt;、&lt;code&gt;7ac247d&lt;/code&gt; 更新个人简历和“关于”页面；&lt;/li&gt;
&lt;li&gt;&lt;code&gt;b175d97&lt;/code&gt; 留言板图片支持点击查看；&lt;/li&gt;
&lt;li&gt;&lt;code&gt;f3b39fc&lt;/code&gt; 留言板增加直播入口。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;这一年没有更换技术底座，却把七牛封面、随机文章链接、Redis 登录封禁、评论先审后显、天气缓存这些“地基”一次补齐了。&lt;/p&gt;
&lt;h2 id="四、2025：围绕评论系统深度重构"&gt;四、2025：围绕评论系统深度重构&lt;/h2&gt;
&lt;p&gt;2025 年共 109 条提交，高峰在 6 月至 8 月。评论、留言板、图片查看、表情和后台管理是主要的改造对象。&lt;/p&gt;
&lt;h3 id="1-评论从全量递归到按需加载"&gt;1. 评论从全量递归到按需加载&lt;/h3&gt;
&lt;p&gt;早期的留言板第一次打开就会递归查询全部回复，评论一多，首次访问要 15 秒以上。&lt;code&gt;e790903&lt;/code&gt; 改为首屏只查一级评论，点击“展开回复”时再加载子回复，首次访问降到 1 秒以内；&lt;code&gt;9be29b4&lt;/code&gt; 把同样的按钮补到了文章页。&lt;/p&gt;
&lt;p&gt;7 月，&lt;code&gt;71a89c0&lt;/code&gt; 把留言板改为分页查询，之后又支持输入页码跳转（&lt;code&gt;5e4a736&lt;/code&gt;），并调整为每页 10 条（&lt;code&gt;16328b4&lt;/code&gt;）。这是 2025 年最重要的性能更新：它改的不是一个按钮，而是评论树的加载方式。&lt;/p&gt;
&lt;h3 id="2-后台评论走向可运营"&gt;2. 后台评论走向可运营&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;7148def&lt;/code&gt; 重构后台评论列表页，&lt;code&gt;449e764&lt;/code&gt; 加入分页、状态筛选和关键词搜索。随后陆续补齐：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;修复评论数统计错误（&lt;code&gt;55d1d6e&lt;/code&gt;）和子级评论 IP 属地为空（&lt;code&gt;8e20a5f&lt;/code&gt;）；&lt;/li&gt;
&lt;li&gt;后台可以点击查看评论里的大图（&lt;code&gt;42207e8&lt;/code&gt;）；&lt;/li&gt;
&lt;li&gt;优化置顶标识和“展开回复”按钮的样式；&lt;/li&gt;
&lt;li&gt;12 月支持修改评论的排序值，并按排序值升序或降序显示（&lt;code&gt;24f4c8e&lt;/code&gt;、&lt;code&gt;61f45a8&lt;/code&gt;）。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;评论系统从简单的数据展示，变成了可审核、可检索、可排序的后台工作流。&lt;/p&gt;
&lt;h3 id="3-图片与表情"&gt;3. 图片与表情&lt;/h3&gt;
&lt;p&gt;这一年持续处理真实使用中遇到的边界问题：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;a21de04&lt;/code&gt; 修复表情只能插入到文末，&lt;code&gt;b11edca&lt;/code&gt; 修复表情包超出手机屏幕；&lt;/li&gt;
&lt;li&gt;&lt;code&gt;11520b2&lt;/code&gt; 支持把表情包 URL 一键生成图片，可预览、可删除；&lt;/li&gt;
&lt;li&gt;&lt;code&gt;0facbc6&lt;/code&gt; 修复子评论图片不能点击，&lt;code&gt;928661e&lt;/code&gt; 修复手机端看图时窗口抖动；&lt;/li&gt;
&lt;li&gt;&lt;code&gt;c139a74&lt;/code&gt;、&lt;code&gt;30e2b4e&lt;/code&gt; 同一条留言、同一篇文章里的多张图片可以左右滑动查看；&lt;/li&gt;
&lt;li&gt;&lt;code&gt;477be52&lt;/code&gt;、&lt;code&gt;685d4c6&lt;/code&gt; 先后重构留言板和文章页的表情模块，8—9 月又多次补充表情。&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id="4-回复、阅读与外部服务"&gt;4. 回复、阅读与外部服务&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;b0091fa&lt;/code&gt; 重构留言板回复功能，&lt;code&gt;75bc2f8&lt;/code&gt; 回复框集成表情；&lt;/li&gt;
&lt;li&gt;&lt;code&gt;4b39c9b&lt;/code&gt; 文章代码块增加“复制”按钮，&lt;code&gt;ad1f17e&lt;/code&gt; 调整文章字体和字号，改善电脑端阅读；&lt;/li&gt;
&lt;li&gt;&lt;code&gt;b43c2cc&lt;/code&gt; 留言超过一定行数自动折叠，可展开查看全文；&lt;/li&gt;
&lt;li&gt;&lt;code&gt;28e7607&lt;/code&gt;、&lt;code&gt;ea8c005&lt;/code&gt; IP 解析改为“有市到市、有省到省、都没有到国家”的逐级降级，&lt;code&gt;5cf9367&lt;/code&gt; 显示城市；&lt;/li&gt;
&lt;li&gt;&lt;code&gt;64ba4f3&lt;/code&gt; 根据 QQ 号获取昵称的接口失效，改为只自动补充头像和邮箱；&lt;/li&gt;
&lt;li&gt;&lt;code&gt;700c2c9&lt;/code&gt; 天气接口改用心知天气；&lt;/li&gt;
&lt;li&gt;&lt;code&gt;2dc7da8&lt;/code&gt; 管理员登录页增加验证码；&lt;/li&gt;
&lt;li&gt;&lt;code&gt;fe10cc8&lt;/code&gt; 美化邮件提醒样式，&lt;code&gt;5041a1d&lt;/code&gt; 页脚加上 Copyright，年底的 &lt;code&gt;b44abc4&lt;/code&gt; 给留言页加上友链。&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id="五、2026-上半年：ai-进入博客"&gt;五、2026 上半年：AI 进入博客&lt;/h2&gt;
&lt;p&gt;原 blog 仓库在 2026 年 1 月至 6 月共有 189 条提交，其中 3 月单月 118 条，是迁入 total 之前最密集的一个月。&lt;/p&gt;
&lt;h3 id="1-年初：归档、留言板与交互修复"&gt;1. 年初：归档、留言板与交互修复&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;a94a01e&lt;/code&gt; 重构归档页面；&lt;/li&gt;
&lt;li&gt;&lt;code&gt;dc53cf2&lt;/code&gt; 修复从任意页面进入文章详情页时脚本不生效，提交说明里特意标了“重大更新”；&lt;/li&gt;
&lt;li&gt;&lt;code&gt;39ad9e7&lt;/code&gt;、&lt;code&gt;2bed668&lt;/code&gt; 修复移动端汉堡菜单无法关闭；&lt;/li&gt;
&lt;li&gt;&lt;code&gt;fd8a8e1&lt;/code&gt; 重构留言板 UI；&lt;/li&gt;
&lt;li&gt;&lt;code&gt;2349071&lt;/code&gt; 邮件提醒里点“查看完整对话”，直接跳到对应楼层；&lt;/li&gt;
&lt;li&gt;&lt;code&gt;1dffeec&lt;/code&gt; 文章代码块支持折叠与展开；&lt;/li&gt;
&lt;li&gt;&lt;code&gt;b95a239&lt;/code&gt; 等提交补充了大量表情。&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id="2-3-月：安全修复与个人化页面"&gt;2. 3 月：安全修复与个人化页面&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;e7f23b5&lt;/code&gt; 集中修复 XSS 和文件上传漏洞，并加固登录与 HTTP 请求处理；&lt;/li&gt;
&lt;li&gt;&lt;code&gt;ea12af6&lt;/code&gt; 新增纪念日页面，&lt;code&gt;b3da6d9&lt;/code&gt; 页脚加入“每日一句”，&lt;code&gt;ab8c2cd&lt;/code&gt; 支持播放每日一句的音频；&lt;/li&gt;
&lt;li&gt;&lt;code&gt;b1ef971&lt;/code&gt;、&lt;code&gt;a5e3acc&lt;/code&gt; 留言板加入弹幕墙；&lt;/li&gt;
&lt;li&gt;&lt;code&gt;ce0e6fd&lt;/code&gt;、&lt;code&gt;61cacb8&lt;/code&gt; 文章可以生成分享卡片；&lt;/li&gt;
&lt;li&gt;&lt;code&gt;a87dfec&lt;/code&gt; 文章页增加“热门文章”和“最新文章”。&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id="3-ai-摘要、ai-问答与-ai-审核"&gt;3. AI 摘要、AI 问答与 AI 审核&lt;/h3&gt;
&lt;p&gt;3 月中下旬，AI 能力正式进入博客的主流程：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;d204c30&lt;/code&gt; 文章页调用 AI 自动生成摘要；&lt;/li&gt;
&lt;li&gt;&lt;code&gt;22ec53c&lt;/code&gt; 文章页加入 AI 对话机器人，随后补上 Markdown 实时渲染、暂停、复制和重新生成；&lt;/li&gt;
&lt;li&gt;&lt;code&gt;a7688cd&lt;/code&gt; 引入 AI 评论审核与自动回复，&lt;code&gt;28f6bcc&lt;/code&gt; 审核被拒时发邮件通知；&lt;/li&gt;
&lt;li&gt;&lt;code&gt;4fdc9f9&lt;/code&gt; 审核和回复支持识别图片表情，&lt;code&gt;a6d897d&lt;/code&gt; 提交评论时实时展示审核状态；&lt;/li&gt;
&lt;li&gt;&lt;code&gt;1f84be1&lt;/code&gt; AI 摘要支持 Markdown，同时给博主、博主夫人的评论加上身份标识。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;AI 不再只是一个独立的聊天入口，而是进入了摘要、问答、评论审核与回复等多个环节。之后的提交反复调整模型、提示词和错误处理，重心从“能调用接口”转向“稳定可用”。&lt;/p&gt;
&lt;h3 id="4-公众号解锁：上线与撤下"&gt;4. 公众号解锁：上线与撤下&lt;/h3&gt;
&lt;p&gt;3 月下旬，博客试行文章阅读限制：&lt;code&gt;02e3781&lt;/code&gt; 为文章增加公众号引流，&lt;code&gt;c10190b&lt;/code&gt; 把“查看全文”按钮接入公众号自动回复的验证码，&lt;code&gt;51b0627&lt;/code&gt; 修复了直接修改浏览器 Local Storage 就能绕过验证的漏洞。这套机制运行了一个半月，5 月 11 日由 &lt;code&gt;5aad567&lt;/code&gt; 移除。&lt;/p&gt;
&lt;h3 id="5-3-月底至-6-月：搜索、加载动画与-ip-定位"&gt;5. 3 月底至 6 月：搜索、加载动画与 IP 定位&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;fbf5c82&lt;/code&gt; 重构搜索框并高亮搜索结果，&lt;code&gt;48d0cca&lt;/code&gt; 重构搜索结果页；&lt;/li&gt;
&lt;li&gt;&lt;code&gt;253a6e6&lt;/code&gt; IP 定位改为调用在线接口，本地 ip2region 退为兜底；&lt;/li&gt;
&lt;li&gt;&lt;code&gt;1c25ba1&lt;/code&gt; 给前台页面统一加上加载动画；&lt;/li&gt;
&lt;li&gt;&lt;code&gt;f054462&lt;/code&gt; 重构箴言页面；&lt;/li&gt;
&lt;li&gt;&lt;code&gt;8e4d433&lt;/code&gt; 页脚加上备案号；&lt;/li&gt;
&lt;li&gt;6 月 16 日，&lt;code&gt;b3a6ded&lt;/code&gt; 重构搜索框和分类页，这是原 blog 仓库的最后一条提交。&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id="六、2026-年-7-月：迁入-total，界面重做"&gt;六、2026 年 7 月：迁入 total，界面重做&lt;/h2&gt;
&lt;p&gt;7 月，&lt;code&gt;total&lt;/code&gt; 仓库中触及 blog 的提交共 84 条（含迁移提交）。&lt;/p&gt;
&lt;h3 id="1-从独立仓库迁入聚合工程"&gt;1. 从独立仓库迁入聚合工程&lt;/h3&gt;
&lt;p&gt;2026 年 7 月 2 日，&lt;code&gt;2c3c5ad&lt;/code&gt; 把博客、AI 对话、打牌记账和运动步数四个项目合并为单 JVM 的多模块工程。博客源码进入 &lt;code&gt;blog&lt;/code&gt; 模块，由统一的 Launcher 打包和启动。因此 &lt;code&gt;2c3c5ad&lt;/code&gt; 是仓库迁移的节点，而不是博客真正的首次提交。&lt;/p&gt;
&lt;h3 id="2-前后台界面重做"&gt;2. 前后台界面重做&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;363daa2&lt;/code&gt; 完全重构博客 UI，&lt;code&gt;dc8684d&lt;/code&gt; 跟进移动端适配；&lt;/li&gt;
&lt;li&gt;之后多条提交完善页面加载遮罩、首页卡片和“关于”页的资源卡片；&lt;/li&gt;
&lt;li&gt;&lt;code&gt;c9b6eaf&lt;/code&gt;、&lt;code&gt;add941e&lt;/code&gt; 重构后台界面和友链管理；&lt;/li&gt;
&lt;li&gt;&lt;code&gt;cda9bf9&lt;/code&gt; 首页加入项目展示，留言板在电脑端加入竖屏视频流，&lt;code&gt;3e5dc16&lt;/code&gt; 加快了视频切换。&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id="3-ai-功能的收与放"&gt;3. AI 功能的收与放&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;7 月 14 日，&lt;code&gt;46a9d0b&lt;/code&gt; 下线了 3 月上线的文章 AI 摘要；&lt;/li&gt;
&lt;li&gt;&lt;code&gt;10d4d2b&lt;/code&gt; 把全部 AI 功能统一到 gpt-oss 模型；&lt;/li&gt;
&lt;li&gt;&lt;code&gt;308c065&lt;/code&gt; 博主自己的评论不再等待 AI 回复；&lt;/li&gt;
&lt;li&gt;&lt;code&gt;eb8736e&lt;/code&gt; 修复 AI 机器人多轮对话不稳定，并补齐首页、归档、文章、搜索、留言、回复弹窗、分页和浮动按钮的暗色样式。&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id="4-评论定位与审核一致性"&gt;4. 评论定位与审核一致性&lt;/h3&gt;
&lt;p&gt;7 月 10 日前后，弹幕墙完成并持续优化。7 月 15 日集中处理通过链接定位到某条评论的体验：定位提示从改变背景色，收敛为一次短暂的脉冲，电脑端和移动端表现一致。&lt;code&gt;be0d3b5&lt;/code&gt; 统一了评论审核逻辑，修复后台已经审核通过、前台却因为状态判断不一致而不显示的问题。&lt;/p&gt;
&lt;h3 id="5-归档页、首页卡片与-mermaid-图表"&gt;5. 归档页、首页卡片与 Mermaid 图表&lt;/h3&gt;
&lt;p&gt;7 月 25 日，&lt;code&gt;287ccd1&lt;/code&gt; 重新设计归档页和首页卡片：归档按年、月组织，年份可以平滑展开和折叠，首页卡片用文章缩略图做虚化背景。旧版更新日志就统计到这一条为止。&lt;/p&gt;
&lt;p&gt;同一天，&lt;code&gt;299cc9c&lt;/code&gt; 让文章支持渲染 Mermaid 图表，并适配明暗两套主题。本文第一节那张“一图看全程”，用的就是它。&lt;/p&gt;
&lt;h3 id="6-月底：留言板、邮件与-ai-回退"&gt;6. 月底：留言板、邮件与 AI 回退&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;72665ae&lt;/code&gt; 移除本地 ip2region 兜底，删掉约 11 MB 的离线 IP 库；&lt;/li&gt;
&lt;li&gt;&lt;code&gt;4e0ea74&lt;/code&gt; 留言板视频改为直接使用视频站的 MP4 直链，不再由服务器下载缓存；&lt;/li&gt;
&lt;li&gt;&lt;code&gt;5151aa4&lt;/code&gt;、&lt;code&gt;6df1a22&lt;/code&gt; 完善评论的邮件通知流程，补足失败重试；&lt;/li&gt;
&lt;li&gt;&lt;code&gt;a26db98&lt;/code&gt;、&lt;code&gt;fe80d48&lt;/code&gt; 重做响应式弹幕墙；&lt;/li&gt;
&lt;li&gt;&lt;code&gt;729e286&lt;/code&gt; 等提交重排移动端首页卡片的信息层级，修复长标题溢出；&lt;/li&gt;
&lt;li&gt;7 月 31 日，评论审核与回复改为按额度自动回退、按内容类型分流，文字和图片分别交给不同的模型（&lt;code&gt;39c7b3c&lt;/code&gt;、&lt;code&gt;ccf9b5f&lt;/code&gt;、&lt;code&gt;5e22159&lt;/code&gt;、&lt;code&gt;504b32e&lt;/code&gt;）。&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id="七、2026-年-8-月：读得舒服，评得可靠"&gt;七、2026 年 8 月：读得舒服，评得可靠&lt;/h2&gt;
&lt;p&gt;8 月共 68 条提交，主要围绕两件事：阅读体验，以及评论区 AI 的可靠性。&lt;/p&gt;
&lt;h3 id="1-字体、目录与图表查看器"&gt;1. 字体、目录与图表查看器&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;8 月 5 日先试了 MiSans，当天就改为分层的自托管字体：中文正文用霞鹜文楷屏幕阅读版，西文用 Inter，代码用 JetBrains Mono，同时优化了文章目录（&lt;code&gt;0e7063b&lt;/code&gt;、&lt;code&gt;b4d35b3&lt;/code&gt;、&lt;code&gt;3f0df8c&lt;/code&gt;）；&lt;/li&gt;
&lt;li&gt;&lt;code&gt;09ed071&lt;/code&gt; 为 Mermaid 图表加上原生 SVG 查看器，可以拖动和缩放，之后又简化了手势、补齐了暗色样式；&lt;/li&gt;
&lt;li&gt;&lt;code&gt;611bcb3&lt;/code&gt; 阅读时长改为按内容类型估算：中文按字、英文按词，代码按行，图片、表格和图表各自计时；&lt;/li&gt;
&lt;li&gt;&lt;code&gt;fa01f15&lt;/code&gt; 电脑端首页摘要显示四行，&lt;code&gt;1a80a6f&lt;/code&gt; 点文章页的评论数可以直接跳到评论区；&lt;/li&gt;
&lt;li&gt;&lt;code&gt;e403555&lt;/code&gt; 打开和关闭弹层前后，页面保持在原来的阅读位置。&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id="2-暗色主题补全"&gt;2. 暗色主题补全&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;1936d9f&lt;/code&gt; 重新调校暗色主题的配色，&lt;code&gt;77532c6&lt;/code&gt; 把暗色覆盖到剩下的页面和组件，两次都用视觉契约测试守住结果；&lt;code&gt;ce3ea87&lt;/code&gt; 简化了暗色下的顶栏搜索框。&lt;/p&gt;
&lt;h3 id="3-评论-ai-的路由与容错"&gt;3. 评论 AI 的路由与容错&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;8 月里评论审核与回复的模型路由调整了好几轮，最终统一到 gpt-5.6-luna（&lt;code&gt;d59f10c&lt;/code&gt;、&lt;code&gt;190aa4f&lt;/code&gt;、&lt;code&gt;1ee1fe0&lt;/code&gt;、&lt;code&gt;0cdc31e&lt;/code&gt;）；&lt;/li&gt;
&lt;li&gt;&lt;code&gt;be6463b&lt;/code&gt; 评论里的图片改为以 data URL 发给模型，&lt;code&gt;32da8fd&lt;/code&gt; 连续回复时保留图片上下文，&lt;code&gt;6214432&lt;/code&gt; 按消息类型构造 AI 回复的对话；&lt;/li&gt;
&lt;li&gt;&lt;code&gt;83c4ef1&lt;/code&gt; 避免 AI 回复照抄读者用过的表情；&lt;/li&gt;
&lt;li&gt;&lt;code&gt;c4bdad7&lt;/code&gt; 前台不再暴露 AI 失败的细节，&lt;code&gt;b0d3d05&lt;/code&gt; 容忍较慢的图片回复；&lt;/li&gt;
&lt;li&gt;&lt;code&gt;828cfb6&lt;/code&gt;、&lt;code&gt;292efc4&lt;/code&gt; 文章里的 AI 助手改为读取整篇文章作为上下文。&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id="4-天气、分享卡片与首页入口"&gt;4. 天气、分享卡片与首页入口&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;0cf1ccc&lt;/code&gt;、&lt;code&gt;30aadb6&lt;/code&gt; 天气图标换成心知天气的官方图标；&lt;/li&gt;
&lt;li&gt;&lt;code&gt;60c1fcb&lt;/code&gt; 天气结果加上缓存，首页返回的数据也瘦了身，随后修复定位编码和坐标解析（&lt;code&gt;88c97a5&lt;/code&gt;、&lt;code&gt;fc8cdad&lt;/code&gt;）；&lt;/li&gt;
&lt;li&gt;&lt;code&gt;bd1b5c9&lt;/code&gt; 重新设计电脑端的分享卡片；&lt;/li&gt;
&lt;li&gt;&lt;code&gt;88d41b1&lt;/code&gt;、&lt;code&gt;4d391ff&lt;/code&gt; 编辑文章时可以直接新建标签，新旧标签混合保存也不会出错；&lt;/li&gt;
&lt;li&gt;&lt;code&gt;d617b1e&lt;/code&gt; 随机封面扩充到 200 张，&lt;code&gt;76bba1f&lt;/code&gt; 友链悬浮时显示完整描述；&lt;/li&gt;
&lt;li&gt;&lt;code&gt;4da08c2&lt;/code&gt; 提交评论后，自动定位到自己的评论和随后的 AI 回复；&lt;/li&gt;
&lt;li&gt;&lt;code&gt;210a5b6&lt;/code&gt;、&lt;code&gt;c174209&lt;/code&gt;、&lt;code&gt;fd3ef66&lt;/code&gt; 首页项目区陆续加入“VX步数助手”“QQ空间时光库”和“抖音续火花”的入口。&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id="八、2026-年-9-月：性能、可靠性与六套主题"&gt;八、2026 年 9 月：性能、可靠性与六套主题&lt;/h2&gt;
&lt;p&gt;9 月共 164 条提交，是整个历史上提交最多的一个月。前三周以体检和打磨为主，最后一周做了六套主题。这个月的提交说明大多附带了实测数据、排查过程和回归测试；很多改动是在做减法：删掉从未用到的脚本、样式和静态文件，把字体裁成子集，把大图换成缩略图。&lt;/p&gt;
&lt;h3 id="1-一次全面体检"&gt;1. 一次全面体检&lt;/h3&gt;
&lt;p&gt;9 月 4 日至 7 日，对博客做了一轮系统检查，挖出几个藏得很深的问题：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;缓存形同虚设&lt;/strong&gt;：每次文章浏览都会调用一个带 &lt;code&gt;@CacheEvict(allEntries = true)&lt;/code&gt; 的方法，等于每看一篇文章就清空整个文章缓存。浏览量改为 SQL 原子自增，顺带修掉了并发浏览时计数互相覆盖的问题（&lt;code&gt;3c127e1&lt;/code&gt;）。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;缓存注解静默失效&lt;/strong&gt;：同一个类内部直接调用带缓存注解的方法会绕过 Spring 代理，有两处缓存从来没生效过（&lt;code&gt;5aaadfa&lt;/code&gt;）。搜索结果和评论分页原本与文章共用一个缓存区，换着关键词或页码刷就能把文章缓存挤空，现在各用各的（&lt;code&gt;0a91ce0&lt;/code&gt;、&lt;code&gt;15e0385&lt;/code&gt;）。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;定时任务从未运行&lt;/strong&gt;：blog 模块缺少 &lt;code&gt;@EnableScheduling&lt;/code&gt;，AI 审核失败或卡在待处理的评论，从来没有被自动重试过。补上之后，又给定时任务配了独立的调度线程，免得占住邮件重试（&lt;code&gt;c308307&lt;/code&gt;）。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;后端小毛病&lt;/strong&gt;：删除文章缺少事务、删标签或分类会留下孤儿关联、公开接口的每页条数没有上限、批量 AI 处理会被线程池拒绝打断等 7 个问题一并修复（&lt;code&gt;8308c07&lt;/code&gt;）。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;前端负担&lt;/strong&gt;：首屏去掉约 250 KB 从未使用过的阻塞脚本（Valine、APlayer、Meting）；模板里的内联样式外置为独立样式表，模板瘦身 652 KB；搜索页不再把整篇正文塞进页面，摘要改由服务端截取；归档页从“每个月查一次库”改为一次取回（&lt;code&gt;0a91ce0&lt;/code&gt;、&lt;code&gt;1718914&lt;/code&gt;、&lt;code&gt;4101224&lt;/code&gt;、&lt;code&gt;f609306&lt;/code&gt;）。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;移动端点击高亮&lt;/strong&gt;：手指点过按钮、卡片后残留的悬停背景，前后三轮、共处理 504 条样式规则才彻底清干净（&lt;code&gt;9788c9d&lt;/code&gt;、&lt;code&gt;defde6d&lt;/code&gt;、&lt;code&gt;d14b682&lt;/code&gt;）。&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id="2-评论身份与-ai-审核链路"&gt;2. 评论身份与 AI 审核链路&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;9 月 5 日的一次合并改了身份判定方式，452 条博主评论全部显示成了访客，“博主夫人”的标签也整段消失。现在改为提交评论时按 QQ 固定头像，身份标签只看头像（&lt;code&gt;9fd4428&lt;/code&gt;、&lt;code&gt;74b1fd1&lt;/code&gt;）；博主自己的留言也不再收到 AI 回复（&lt;code&gt;880b66e&lt;/code&gt;）。&lt;/li&gt;
&lt;li&gt;图床开了防盗链，而服务器下载评论配图时没带 Referer，带图评论的 AI 回复因此一直失败，有两条评论各被重试了 312 次、持续约 6.5 天（&lt;code&gt;03eb1f9&lt;/code&gt;）。现在，重试也无济于事的错误立即停手，其余失败给 24 小时窗口，超出就转人工并发邮件提醒博主（&lt;code&gt;c912eb5&lt;/code&gt;）。&lt;/li&gt;
&lt;li&gt;还查出一个藏了一个多月的问题：请求里图片的 &lt;code&gt;detail&lt;/code&gt; 参数传了上游不认的值，从 8 月 6 日起，所有带图评论的审核都没有成功过（&lt;code&gt;0d88347&lt;/code&gt;）。&lt;/li&gt;
&lt;li&gt;上游模型单次响应要将近一分钟，超时却只设了 30 秒，每次都在对方算完、计费之后才放弃再重试；超时放宽到 90 秒（&lt;code&gt;576db33&lt;/code&gt;）。&lt;/li&gt;
&lt;li&gt;后台删除评论不再留下“孤儿回复”：删除前列出会被连带删除的回复，历史遗留的 29 条也一并清理（&lt;code&gt;c9b9b07&lt;/code&gt;）。&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id="3-跳到评论这件小事"&gt;3. “跳到评论”这件小事&lt;/h3&gt;
&lt;p&gt;点击评论数跳到评论区、从邮件里的链接定位到某一楼、提交后跳到自己的评论——这几条路径原本有三种不同的行为。9 月 9 日到 11 日的 10 次提交，把它们统一成一套平滑定位：先算好最终落点再滚动，等滚动真正停稳后再校正；落点避开吸顶的顶栏，让目标在屏幕里垂直居中；弱网下图片陆续加载时继续跟踪位置，用户一动手就让位（&lt;code&gt;5f921f2&lt;/code&gt;、&lt;code&gt;6c408a9&lt;/code&gt;、&lt;code&gt;ee6a189&lt;/code&gt;、&lt;code&gt;23cef8f&lt;/code&gt;）。&lt;/p&gt;
&lt;p&gt;中间还绕了一段弯路：先是以为全站没有平滑滚动，用脚本接管了锚点跳转；结果窄屏下真正在滚动的是 &lt;code&gt;body&lt;/code&gt; 而不是 &lt;code&gt;html&lt;/code&gt;，脚本去滚一个根本滚不动的容器，反而让点击彻底失效。最后把跳转还给浏览器的原生行为，只在 CSS 里给 &lt;code&gt;body&lt;/code&gt; 也声明平滑滚动（&lt;code&gt;5d75e2a&lt;/code&gt;、&lt;code&gt;d443127&lt;/code&gt;、&lt;code&gt;d128e5a&lt;/code&gt;）。&lt;/p&gt;
&lt;h3 id="4-图片、字体与静态资源瘦身"&gt;4. 图片、字体与静态资源瘦身&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;图片&lt;/strong&gt;：文章和评论里的图床原图，在渲染时自动走七牛压缩。留言板上曾有一张 32.9 MB 的原图，整页 42 MB；9 MB 的手机照片压缩后只有 57 KB。压不动的超大原图会退回原图，不会裂图（&lt;code&gt;1bd2edd&lt;/code&gt;、&lt;code&gt;d0d8ba1&lt;/code&gt;）。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Mermaid 按需加载&lt;/strong&gt;：497 篇文章里只有 33 篇有图表，其余页面不再下载 Mermaid（&lt;code&gt;eedd555&lt;/code&gt;）。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;样式库裁剪&lt;/strong&gt;：Semantic UI 按 6 类真实页面实际命中的规则裁剪，564 KB → 37 KB；Font Awesome 6 原本每页要加载两个版本，现在只留一个（&lt;code&gt;eedd555&lt;/code&gt;）。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;字体子集&lt;/strong&gt;：图标字体只保留用到的图标，252 KB → 约 17 KB（&lt;code&gt;7f559c2&lt;/code&gt;、&lt;code&gt;f41bdaa&lt;/code&gt;）；Inter 与 JetBrains Mono 裁成拉丁子集，539 KB → 119 KB（&lt;code&gt;d338b73&lt;/code&gt;）；霞鹜文楷原本按 Unicode 区段切成 97 个分片，一个页面要下 0.8—1.1 MB，换一页又得下一批，现在统计全站实际用到的 3000 多个汉字，做成一个约 0.85 MB 的单文件，整站复用、缓存一年（&lt;code&gt;37432c0&lt;/code&gt; 至 &lt;code&gt;951fe91&lt;/code&gt;）。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;缓存策略&lt;/strong&gt;：CSS 和 JS 改用内容哈希命名，内容不变就一直命中缓存，可以放心缓存一年（&lt;code&gt;eedd555&lt;/code&gt;、&lt;code&gt;a792970&lt;/code&gt;）。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;这一轮也翻过车：&lt;code&gt;57e10b2&lt;/code&gt; 调整静态资源映射时把路径写错，上线后 54 个静态资源里有 51 个 404，页面直接没了样式。当即回滚，先补上能抓住这类错误的契约测试，再重做了一遍（&lt;code&gt;025f1f8&lt;/code&gt;、&lt;code&gt;95c9c03&lt;/code&gt;）。&lt;/p&gt;
&lt;h3 id="5-音乐播放器、首页与弹幕墙"&gt;5. 音乐播放器、首页与弹幕墙&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;c7ce19e&lt;/code&gt; 新增音乐播放器：悬浮在左下角，播放列表由后台维护，支持顺序、随机和单曲循环，也能用系统媒体面板和耳机切歌。跨页续播做出来之后又主动去掉了（&lt;code&gt;bc34660&lt;/code&gt;）——移动端必然拦截自动播放，续播反而会让播放器在每个页面自己弹开。&lt;/li&gt;
&lt;li&gt;后台可以直接搜索网易云并一键添加（&lt;code&gt;a530b5a&lt;/code&gt;）；添加前会像未登录的访客一样实际探测能否完整播放，只能试听 30 秒的歌不许入库（&lt;code&gt;6cf9be3&lt;/code&gt;）。播放器封面从 7 MB 的原图换成约 20 KB 的缩略图（&lt;code&gt;1a06cb9&lt;/code&gt;）。&lt;/li&gt;
&lt;li&gt;首页在电脑端恢复了顶部的介绍区，移动端整块隐藏，让首屏直接看到文章（&lt;code&gt;f1c4c76&lt;/code&gt;）；深色模式下“阅读最新文章”按钮原本是白底白字，对比度只有 1.05:1，修正后为 14.36:1（&lt;code&gt;bee9652&lt;/code&gt;）。&lt;/li&gt;
&lt;li&gt;弹幕墙去掉 &lt;code&gt;ORDER BY RAND()&lt;/code&gt; 抽样，改为全量下发并缓存，前端洗牌后逐条播完才会重复（&lt;code&gt;53002e0&lt;/code&gt;）。&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id="6-后台：封面、图片与附件都接到七牛"&gt;6. 后台：封面、图片与附件都接到七牛&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;选择文章缩略图时，原来的选择器列出的 247 个本地附件早已全部 404；现在直接列出七牛上的封面目录，按编号排序、懒加载缩略图（&lt;code&gt;c439022&lt;/code&gt;）。&lt;/li&gt;
&lt;li&gt;文章编辑器里粘贴或拖入图片，会自动上传到七牛，正文只保存外链（&lt;code&gt;4ab8404&lt;/code&gt;）。&lt;/li&gt;
&lt;li&gt;附件管理改为直接管理七牛上的图片：显示每张图被哪些文章、评论和模板引用，删除前列出引用位置，并且需要邮箱验证码（&lt;code&gt;57a6a78&lt;/code&gt;）。&lt;/li&gt;
&lt;li&gt;电脑端编辑文章时，右侧实时显示发布后的效果；全部后台页面在 320—1920 px 的七种宽度下逐页修了排版（&lt;code&gt;0d725f7&lt;/code&gt;）。&lt;/li&gt;
&lt;li&gt;清理后台“设置了也不生效”的选项，文章页开始显示原创或转载（&lt;code&gt;9db44f2&lt;/code&gt;、&lt;code&gt;a16c679&lt;/code&gt;）。&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id="7-留言框：照片与表情包面板"&gt;7. 留言框：照片与表情包面板&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;留言可以上传照片（&lt;code&gt;2503614&lt;/code&gt;）：浏览器先把照片压缩成长边 1600 px 的 JPEG，顺带去掉 EXIF 里的拍摄地点；连同表情包一条最多 4 张，与 AI 审核一次能看的数量一致，审核通过后才显示。&lt;/li&gt;
&lt;li&gt;表情包面板重做：电脑端是跟随按钮的浮层，手机端是底部抽屉，主评论框和回复弹窗共用一套逻辑；表情和表情包面板都加上了“最近用过”（&lt;code&gt;76b01ca&lt;/code&gt;、&lt;code&gt;01eae26&lt;/code&gt;）。&lt;/li&gt;
&lt;li&gt;通知邮件里的图片改为内嵌附件，在 QQ 邮箱里也能正常显示（&lt;code&gt;1a987fe&lt;/code&gt;）。&lt;/li&gt;
&lt;li&gt;评论头像改用 120 px 宽的缩略图，原本 65 KB 的头像现在只有 2 KB（&lt;code&gt;7f64391&lt;/code&gt;）。&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id="8-加载、收录与分享"&gt;8. 加载、收录与分享&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;前端第三方库改用放在七牛上的固定版本副本，实测加载时间从 3—4.6 秒降到 0.2—0.5 秒（&lt;code&gt;86fbfb1&lt;/code&gt;）；Mermaid 也改用自己的副本（&lt;code&gt;3b89e82&lt;/code&gt;）。&lt;/li&gt;
&lt;li&gt;34 段内联脚本外置，文章页每次访问少下约 54 KB；不存在的页面正确返回 404，并新增了 &lt;code&gt;robots.txt&lt;/code&gt; 和站点地图（&lt;code&gt;86fbfb1&lt;/code&gt;）。&lt;/li&gt;
&lt;li&gt;赞赏二维码改为点开才加载（&lt;code&gt;a75f5dd&lt;/code&gt;）；恋爱页 3.3 MB 的 GIF 背景换成 343 KB 的循环视频（&lt;code&gt;c27c73c&lt;/code&gt;）；删除 205 个没有被引用的静态文件，共 16.7 MB（&lt;code&gt;84c69e3&lt;/code&gt;）。&lt;/li&gt;
&lt;li&gt;Mermaid 图表和代码块渲染时保持阅读位置，正文不再被整段顶走（&lt;code&gt;d44eaff&lt;/code&gt;）。&lt;/li&gt;
&lt;li&gt;页面统一输出描述、canonical 和分享卡片信息，链接分享出去时能显示标题、摘要和封面；搜索、标签和分类页的封面换成缩略图；留言板视频在访客点开之前只取元数据；手机上放开了双指缩放（&lt;code&gt;5659236&lt;/code&gt;）。&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id="9-最后一周：从换肤到六套主题"&gt;9. 最后一周：从换肤到六套主题&lt;/h3&gt;
&lt;p&gt;9 月 25 日起，博客从“一套样式”变成了“六套主题”。原来的样式保留下来，叫“云笺”；新加的五套页面结构各不相同，读的是同一个数据库、跑的是同一个后端：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;9 月 25 日上线第一套“纸墨”，后台一键切换、前后台同步（&lt;code&gt;dcd6684&lt;/code&gt;）；26 日加了“画报”和“格致”，后台外观四选一（&lt;code&gt;1098a7b&lt;/code&gt;）；28 日加了参考星空首屏做的“星河”（&lt;code&gt;e9c631a&lt;/code&gt;）；30 日加了“流萤”，主题切换移到后台“外观 → 主题”（&lt;code&gt;32d10ea&lt;/code&gt;），现在站点用的就是它。&lt;/li&gt;
&lt;li&gt;新主题上线后连着做了几轮逐屏排查：代码块统一深底浅字（&lt;code&gt;d982238&lt;/code&gt;），评论框图标、大图、恋爱计时、天气占位（&lt;code&gt;95e8194&lt;/code&gt;），留言框的表情、表情包、照片改成贴在输入框底边的工具栏（&lt;code&gt;7540ca9&lt;/code&gt;），四套主题的黑夜模式（&lt;code&gt;14df6a7&lt;/code&gt;）。&lt;/li&gt;
&lt;li&gt;交互也一起改了：明暗切换从点击处圆形扩散、汉堡菜单动画（&lt;code&gt;2d7f6e8&lt;/code&gt;），整张文章卡片可点、页面跳转淡入淡出（&lt;code&gt;403b70c&lt;/code&gt;），点站内链接后顶部立刻出现换页进度条（&lt;code&gt;d61fc7e&lt;/code&gt;），菜单支持键盘操作（&lt;code&gt;c109c06&lt;/code&gt;）。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;同一周还有几件独立的事：评论深链能一次定位到楼中楼的回复（&lt;code&gt;a1c5273&lt;/code&gt;）；AI 回复会分清每张图是谁发的（&lt;code&gt;db994a6&lt;/code&gt;）；页面不再同步等 IP 定位和天气接口（&lt;code&gt;e72b1dc&lt;/code&gt;）；文章支持数学公式，KaTeX 按需加载，中转站的模型换成 &lt;code&gt;gpt-6-luna&lt;/code&gt;（&lt;code&gt;24b15b1&lt;/code&gt;）；9 月 29 日又做了一轮全项目排查（&lt;code&gt;fe6b741&lt;/code&gt;）。&lt;/p&gt;
&lt;h3 id="10-站点运维"&gt;10. 站点运维&lt;/h3&gt;
&lt;p&gt;下面几项主要改的是部署脚本和服务器配置，大多不在 &lt;code&gt;blog&lt;/code&gt; 目录里，没有计入上面的统计，但同样关系到博客能否稳定访问：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;六个应用共用一个 JVM、串行启动，博客从最后一个改为第一个启动，发版或重启时不必再等另外五个（&lt;code&gt;439f171&lt;/code&gt;）；重启期间访问会看到“站点正在重启”的说明页，15 秒后自动刷新（&lt;code&gt;d61604e&lt;/code&gt;）。&lt;/li&gt;
&lt;li&gt;移除未使用的 Selenium 和 FreeMarker，部署包从 150.4 MB 降到 136.6 MB（&lt;code&gt;c21ae8c&lt;/code&gt;）。&lt;/li&gt;
&lt;li&gt;HTTPS 证书改为自动续期，数据库每天自动备份（&lt;code&gt;c9c6f90&lt;/code&gt;）；七牛 CDN 域名的证书也改为自动续期，过期证书每周清理，快到期时发邮件提醒（&lt;code&gt;d4da815&lt;/code&gt;、&lt;code&gt;8c07b0c&lt;/code&gt;、&lt;code&gt;d2a64d4&lt;/code&gt;）。&lt;/li&gt;
&lt;li&gt;各应用改用只能访问自己数据库的独立账号（&lt;code&gt;1501e87&lt;/code&gt;）。&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id="九、2026-年-10-月（19-日）：流萤打磨、炊想小程序与后台重做"&gt;九、2026 年 10 月（1—9 日）：流萤打磨、炊想小程序与后台重做&lt;/h2&gt;
&lt;p&gt;10 月头九天共 62 条提交。&lt;/p&gt;
&lt;h3 id="1-流萤打磨与冷加载减半"&gt;1. 流萤打磨与冷加载减半&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;流萤的版式只做减法、照参考站恢复：两栏、横排文章卡、侧栏只留资料、正在做和标签；手机菜单改成右侧抽屉（&lt;code&gt;1dbe39b&lt;/code&gt;、&lt;code&gt;797aebb&lt;/code&gt;、&lt;code&gt;8dafb9e&lt;/code&gt;）。&lt;/li&gt;
&lt;li&gt;冷加载减半：只有纸墨加载文楷字体，首屏大图预加载，评论照片按屏幕宽度取图（&lt;code&gt;f45ae79&lt;/code&gt;）；留言板冷加载从 7.3 MB 降到 0.9 MB，无障碍检查的问题清零（&lt;code&gt;fa41f55&lt;/code&gt;）。&lt;/li&gt;
&lt;li&gt;36 篇文章里 190 张 CSDN 外链图恢复显示，手机滚到留言板顶部回到 60 帧（&lt;code&gt;b54ed68&lt;/code&gt;）；iPhone 上点抽屉外面关不掉菜单、输入框聚焦时整页放大，也一并修好（&lt;code&gt;1bc614f&lt;/code&gt;）。&lt;/li&gt;
&lt;li&gt;明暗切换恢复“太阳落下、月亮升起”的天空动画，改由合成线程播放，切换时不再卡（&lt;code&gt;506f9a6&lt;/code&gt;）。&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id="2-分享海报与代码块"&gt;2. 分享海报与代码块&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;分享海报和弹窗重做，中文断行、括号与标点悬挂又修了一轮，封面最多等 6 秒（&lt;code&gt;c79a4fa&lt;/code&gt;、&lt;code&gt;71e5380&lt;/code&gt;）。&lt;/li&gt;
&lt;li&gt;代码块里每行多出来的一截底色去掉了，折叠代码块底部的渐隐和“展开全部”条跟着代码块底色走（&lt;code&gt;d626477&lt;/code&gt;、&lt;code&gt;274d137&lt;/code&gt;）。&lt;/li&gt;
&lt;li&gt;正文里裸写的网址遇到全角标点就收住，不再把后面的标点和正文吞进链接（&lt;code&gt;a201b4e&lt;/code&gt;）。&lt;/li&gt;
&lt;li&gt;新主题的代码块其实一直没有高亮：站点的 highlight.js 是 v9，只有 &lt;code&gt;highlightBlock&lt;/code&gt;，而新主题只认 v11 的 &lt;code&gt;highlightElement&lt;/code&gt;，代码颜色全靠发布时存进正文的那一份。补上两种写法都认之后，代码块恢复了语法高亮（&lt;code&gt;dde4cf3&lt;/code&gt;）。&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id="3-炊想小程序"&gt;3. 炊想小程序&lt;/h3&gt;
&lt;p&gt;10 月 5 日，“炊想”小程序的后端上线，博客后台随之多了一个《小程序管理》（&lt;code&gt;d2ab4b2&lt;/code&gt;、&lt;code&gt;519344e&lt;/code&gt;、&lt;code&gt;581cc2a&lt;/code&gt;），首页“正在做”和小程序码也换成了炊想（&lt;code&gt;09e08a1&lt;/code&gt;、&lt;code&gt;d358869&lt;/code&gt;、&lt;code&gt;076a382&lt;/code&gt;）。之后两天，会员卡与次数包、一周菜单、操作日志、用户详情和调用记录陆续接进后台（&lt;code&gt;35c5bcd&lt;/code&gt;、&lt;code&gt;2eebb80&lt;/code&gt;、&lt;code&gt;180bb47&lt;/code&gt;、&lt;code&gt;a289b6a&lt;/code&gt;），10 月 7 日又加了“改一改这道菜”“请客菜单”和“家里的厨具”（&lt;code&gt;cfbe33d&lt;/code&gt;），并做了一轮走查修复（&lt;code&gt;c2d2160&lt;/code&gt;）。当天晚些时候，五十道常见家常菜提前搭好（&lt;code&gt;3464b1b&lt;/code&gt;），全部提示词排查优化了一轮——换一版会参照上一版换做法、成品图按食材外形描述、会员换图可以写不满意的原因（&lt;code&gt;e75c68f&lt;/code&gt;、&lt;code&gt;51866b6&lt;/code&gt;）；会员调价为月卡 ¥18、季卡 ¥45，并给出图功能加了整期上限（&lt;code&gt;a71a1d1&lt;/code&gt;）。这几条是小程序后端的提交，不计入本文的 blog 提交统计。&lt;/p&gt;
&lt;p&gt;10 月 8 日，小程序加了“我的订单”：会员卡和次数包的订单按待支付、已支付、已关闭分组，付款时中途退出的订单保留 2 小时，可以接着付或取消，超时由定时任务先向支付平台查单、确实没付才关成“超时未支付”，关单后才到账的照常发货；意见反馈支持管理员回复，用户在“我的反馈”里能看到处理状态和回复，有新回复时“我的”页亮红点。博客后台的《小程序管理》同步跟上：订单页加了超时未支付、已取消的筛选和关闭时间，已关闭的订单也能查单补发；反馈页每条都能回复、改回复，并显示用户是否已看（&lt;code&gt;c11143e&lt;/code&gt;）。同一天实测搭配、改一改、排菜单和冰箱的完整流程后，请客菜单的道数改为不含主食——主食另配一道，热菜至少占一半（&lt;code&gt;4ac5297&lt;/code&gt;）；菜单接口带上这 24 小时的用量，菜单页直接写着还能排几份、用完几点后再来（&lt;code&gt;3661480&lt;/code&gt;）。其中 &lt;code&gt;c11143e&lt;/code&gt; 改到了博客后台，计入 blog 统计；另外两条是小程序后端的提交。小程序当天连发 2.0.14、2.0.15 两版，订单、反馈回复和这一轮的体验修正都已上线。&lt;/p&gt;
&lt;p&gt;10 月 9 日盘点上线以来的数据：三十来个陌生访客打开过小程序，却没有一个人走到注册那一步——核心功能要先用邮箱注册，在微信里这道门槛几乎劝退了所有人。于是加了&lt;strong&gt;免注册直接试用&lt;/strong&gt;：打开小程序时凭微信身份开一个试用账号，首页直接能搭配，以后绑定邮箱原地转正，记录和次数都保留；同时加了&lt;strong&gt;分享邀请&lt;/strong&gt;（从朋友分享的链接第一次进来，双方各得 1 次）和&lt;strong&gt;转化埋点&lt;/strong&gt;，《小程序管理》概览多了一张「拉新漏斗」，从打开、看菜谱、点搭配到注册，每一步流失多少一眼看清，用户列表里也标出游客和受邀用户（&lt;code&gt;0ff865f&lt;/code&gt;、&lt;code&gt;6ec3cac&lt;/code&gt;，改到了博客后台，计入 blog 统计）。这一版是 2.0.16，已提交审核。随后小程序又做了一轮视觉精修（2.0.17）：全部 emoji 换成同一套线性图标，菜名和标题改用宋体，橙色只留给主按钮，首页第一屏加了一张每天换一道的「今晚吃这个」大图——这些是小程序仓库的提交，不计入本文统计。&lt;/p&gt;
&lt;p&gt;10 月 10 日到 13 日，小程序的登录又改了两轮：先把「微信一键登录」放到最前面，免注册试用随之退场（2.0.17）；13 日索性只留微信一键登录，注册、找回密码、改密码三页删掉，邮箱改成「我的」里可选绑定——填邮箱、收个验证码就绑好；以前用邮箱注册的老用户，验证原来的邮箱就回到自己的账号。分享邀请从双方各得 1 次改为各得 10 次，「我的」里还能看到请来了哪几位朋友（2.0.18）。随后的 2.0.19 逐页检查了图标、箭头和文字的对齐，删掉一批重复的文字提示——按钮状态自己会变的操作不再另弹提示，和标题、对比表重复的说明也拿掉了；点功能时被请去登录的，登录页会说一句「登录后接着刚才的操作」。两版都已上线。这些是小程序前后端的提交，不计入本文的 blog 提交统计。&lt;/p&gt;
&lt;p&gt;10 月中下旬，炊想从「搭一道菜」延伸到完整的一餐：新增家人口味档案、库存和临期提示、做过的菜与反馈、做菜问答、审核后的照片识别、菜单单道换菜、三餐计划、多菜统筹与家庭共享；个人清单可同步到账号，家庭成员能共同采购。失败或异常退出后可接着查询、重试，同菜多种做法收进详情，不再挤满全部菜谱（&lt;code&gt;f51d761&lt;/code&gt;、&lt;code&gt;948c210&lt;/code&gt;、&lt;code&gt;3821f84&lt;/code&gt;）。&lt;/p&gt;
&lt;p&gt;到 3.1.0，登录协议默认未勾选，需主动确认后再微信登录；菜单可以沿用选择，采购项可以合并并核对已有库存，做完再确认实际用量。免费可体验一次多菜统筹，会员额度、容量及家庭共享边界也在页面上讲清；选择面板、日历、提示与底部导航统一为应用内样式（&lt;code&gt;e9e24aa&lt;/code&gt;）。10 月 20 日，「今晚吃这个」改为从完整的有效菜谱池每小时挑选，同名去重，不再只在热门前 12 道里轮换（&lt;code&gt;cb6bb07&lt;/code&gt;）。以上是小程序的最新代码进展，不追加计入前面的 blog 历史提交数量。&lt;/p&gt;
&lt;p&gt;&lt;img src="https://img.zxfhq.cn/blog/chuixiang/20261020/chuixiang-3-1-0-20261020-home.jpg" alt="炊想新版首页与选菜入口"&gt;&lt;/p&gt;
&lt;h3 id="4-评论：外链与-ai-上下文"&gt;4. 评论：外链与 AI 上下文&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;评论里除表情包、留言上传的照片和本站链接外，其他外链一律拒绝；超过 4 张图片也直接拒绝（&lt;code&gt;3e65f84&lt;/code&gt;、&lt;code&gt;885b576&lt;/code&gt;）。&lt;/li&gt;
&lt;li&gt;AI 回复取整个楼层作为上下文，多人、多层的回复链不再断（&lt;code&gt;885b576&lt;/code&gt;）。&lt;/li&gt;
&lt;li&gt;点开大图时才去取原图，页面上仍是缩略图；评论和留言里的图片只在同一楼里左右切换（&lt;code&gt;783a533&lt;/code&gt;）。&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id="5-后台重做"&gt;5. 后台重做&lt;/h3&gt;
&lt;p&gt;10 月 7 日，后台的排版和界面整体重做：浅色侧栏、手机上的底部导航和抽屉、表格在手机上变成卡片列表、弹窗从底部弹出，各主题的后台样式只改颜色和圆角这些令牌（&lt;code&gt;3da4026&lt;/code&gt;）。侧栏按“内容 / 站点 / 小程序 / 系统”重新归类（&lt;code&gt;768d12c&lt;/code&gt;），评论页、标签、分类、设置和提示条这些页面细节也一起改了，顺手修掉了“点删除再取消、再保存会覆盖那一条”的问题（&lt;code&gt;187bad9&lt;/code&gt;）。&lt;/p&gt;
&lt;h3 id="6-10-月-78-日：首页正在做、流萤巡检与天气"&gt;6. 10 月 7—8 日：首页“正在做”、流萤巡检与天气&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;首页小程序码原本放在隐藏的弹窗里，&lt;code&gt;loading=&amp;quot;lazy&amp;quot;&lt;/code&gt; 拦不住，每次进首页都白下一张 90 KB 的图，改成指到入口或打开弹窗时才取；后台文章列表、仪表盘只取摘要字段，RSS 查询去掉 Markdown 和 &lt;code&gt;GROUP BY&lt;/code&gt;，本地同库实测从 60~70 毫秒降到 10 毫秒以内；分享配图在 nginx 加了独立缓存，不再每次回源七牛缩放（&lt;code&gt;0fb4430&lt;/code&gt;）。&lt;/li&gt;
&lt;li&gt;首页“正在做”调整了顺序，个人简历提到第三个（&lt;code&gt;623cd3d&lt;/code&gt;）；除炊想外的 9 个项目也各画了一枚同风格的应用图标，不再是线条字；流萤左栏天气卡的单色图标换成 16 枚彩色天气插画，按天气代码选用（&lt;code&gt;5f7b239&lt;/code&gt;）。&lt;/li&gt;
&lt;li&gt;流萤巡检一轮：手机上往下读时收起音乐和快捷按钮，往上滚再出现；404 页按流萤重做；打赏按钮换成微信、支付宝标志；上一篇 / 下一篇卡片带封面；搜索弹层加了热门标签和最近 5 篇；天气能定位到区县时显示“衡阳·雁峰区”这样的区县名（&lt;code&gt;ef5960f&lt;/code&gt;）。随后修了手机上搜索弹层被长标题撑宽、小屏底边出屏的问题（&lt;code&gt;ce86a5e&lt;/code&gt;）。&lt;/li&gt;
&lt;li&gt;心知天气的 403 不只表示“不覆盖这个地点”，访问频率超限、余额不足也是 403；以前一律按不覆盖缓存 6 小时，一阵集中请求触发限频后，广州、北京、上海等地的访客整段时间看不到天气。现在只有真正不覆盖的才长缓存，其余 403 等 30 秒再试（&lt;code&gt;cf07835&lt;/code&gt;）。&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id="7-10-月-89-日：一次-xss、前端去重与大图查看器"&gt;7. 10 月 8—9 日：一次 XSS、前端去重与大图查看器&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;存储型 XSS&lt;/strong&gt;：文章页评论的表情解析会先把 &lt;code&gt;&amp;amp;lt;&lt;/code&gt; &lt;code&gt;&amp;amp;gt;&lt;/code&gt; 解码再写回页面，服务端存成转义文字的 &lt;code&gt;&amp;lt;img onerror=…&amp;gt;&lt;/code&gt; 就这样变回了真标签，首屏评论、展开的回复、发评论后的刷新三处都会触发。评论照片和表情包在服务端渲染时已经还原成图片，这一步解码是多余的，删掉即可（&lt;code&gt;fdcf974&lt;/code&gt;）。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;前端重复实现合并成一份&lt;/strong&gt;：文章页和留言板的评论脚本原来各有一套拷贝，合并成 &lt;code&gt;comment-core&lt;/code&gt; 等七个按功能拆开的脚本，两页的差别只留在配置里；云笺的旧模板改用共用片段，五套新主题里几乎一样的代码高亮、计时、菜单开合合进 &lt;code&gt;theme-kit.js&lt;/code&gt;；三处一模一样的大图、审核提示样式（75 条规则）合并成一份。合并时顺手修了一串小问题，比如留言板回复弹窗关闭时误解绑页面其他监听、手机上回复弹窗不锁背景滚动（&lt;code&gt;4add936&lt;/code&gt;）。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;脚本按功能命名、删掉从没生效的代码&lt;/strong&gt;：&lt;code&gt;pinghsu-&amp;lt;页面&amp;gt;-&amp;lt;编号&amp;gt;.js&lt;/code&gt; 改名成 &lt;code&gt;article-*&lt;/code&gt;、&lt;code&gt;comment-*&lt;/code&gt;、&lt;code&gt;guestbook-*&lt;/code&gt;；每日一句、「在一起」计时从两份合成一份，首页的每日一句从请求两次变成一次；从没调用过 &lt;code&gt;init&lt;/code&gt; 的 InstantClick、一直是空列表的旧目录等一并删掉；浏览器回归脚本收进 &lt;code&gt;tools/ui-check&lt;/code&gt;，改前改后逐元素比样式（&lt;code&gt;f449363&lt;/code&gt;）。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;AI 评论服务拆分&lt;/strong&gt;：&lt;code&gt;AiCommentServiceImpl&lt;/code&gt; 从 2893 行拆到 1080 行，提示词、上下文、图片挑选、回复校验、失败归类、邮件通知各自成类；拆分前后两份实现跑了 34 个场景，比对上游请求体、返回值、邮件和状态变更，全部一致（&lt;code&gt;a4a9187&lt;/code&gt;）。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;流萤的大图查看器和流程图全屏&lt;/strong&gt;换了一套外观：深色底带主色光晕，左右切换是毛玻璃方块，多了「2 / 6」计数和一条按进度露出的流萤彩虹；交互不变，其它五套主题不受影响（&lt;code&gt;852b3d9&lt;/code&gt;）。&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id="十、完整提交分布"&gt;十、完整提交分布&lt;/h2&gt;
&lt;p&gt;下表列出原 blog 仓库全部 431 条提交，以及 &lt;code&gt;total&lt;/code&gt; 仓库中 378 条 blog 相关提交（含迁移提交）的月份分布。正文里的阶段划分都以这些完整记录为依据，而不是只挑了少数提交。&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th align="center"&gt;月份&lt;/th&gt;
&lt;th align="center"&gt;提交数&lt;/th&gt;
&lt;th align="center"&gt;月份&lt;/th&gt;
&lt;th align="center"&gt;提交数&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td align="center"&gt;2023-01&lt;/td&gt;
&lt;td align="center"&gt;4&lt;/td&gt;
&lt;td align="center"&gt;2023-02&lt;/td&gt;
&lt;td align="center"&gt;2&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td align="center"&gt;2023-03&lt;/td&gt;
&lt;td align="center"&gt;9&lt;/td&gt;
&lt;td align="center"&gt;2023-04&lt;/td&gt;
&lt;td align="center"&gt;15&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td align="center"&gt;2023-05&lt;/td&gt;
&lt;td align="center"&gt;2&lt;/td&gt;
&lt;td align="center"&gt;2023-06&lt;/td&gt;
&lt;td align="center"&gt;5&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td align="center"&gt;2023-07&lt;/td&gt;
&lt;td align="center"&gt;3&lt;/td&gt;
&lt;td align="center"&gt;2023-08&lt;/td&gt;
&lt;td align="center"&gt;3&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td align="center"&gt;2023-11&lt;/td&gt;
&lt;td align="center"&gt;7&lt;/td&gt;
&lt;td align="center"&gt;2023-12&lt;/td&gt;
&lt;td align="center"&gt;1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td align="center"&gt;2024-06&lt;/td&gt;
&lt;td align="center"&gt;14&lt;/td&gt;
&lt;td align="center"&gt;2024-07&lt;/td&gt;
&lt;td align="center"&gt;50&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td align="center"&gt;2024-08&lt;/td&gt;
&lt;td align="center"&gt;9&lt;/td&gt;
&lt;td align="center"&gt;2024-09&lt;/td&gt;
&lt;td align="center"&gt;6&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td align="center"&gt;2024-10&lt;/td&gt;
&lt;td align="center"&gt;2&lt;/td&gt;
&lt;td align="center"&gt;2024-11&lt;/td&gt;
&lt;td align="center"&gt;1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td align="center"&gt;2025-03&lt;/td&gt;
&lt;td align="center"&gt;2&lt;/td&gt;
&lt;td align="center"&gt;2025-04&lt;/td&gt;
&lt;td align="center"&gt;1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td align="center"&gt;2025-05&lt;/td&gt;
&lt;td align="center"&gt;5&lt;/td&gt;
&lt;td align="center"&gt;2025-06&lt;/td&gt;
&lt;td align="center"&gt;31&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td align="center"&gt;2025-07&lt;/td&gt;
&lt;td align="center"&gt;37&lt;/td&gt;
&lt;td align="center"&gt;2025-08&lt;/td&gt;
&lt;td align="center"&gt;24&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td align="center"&gt;2025-09&lt;/td&gt;
&lt;td align="center"&gt;3&lt;/td&gt;
&lt;td align="center"&gt;2025-11&lt;/td&gt;
&lt;td align="center"&gt;1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td align="center"&gt;2025-12&lt;/td&gt;
&lt;td align="center"&gt;5&lt;/td&gt;
&lt;td align="center"&gt;2026-01&lt;/td&gt;
&lt;td align="center"&gt;13&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td align="center"&gt;2026-02&lt;/td&gt;
&lt;td align="center"&gt;31&lt;/td&gt;
&lt;td align="center"&gt;2026-03&lt;/td&gt;
&lt;td align="center"&gt;118&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td align="center"&gt;2026-04&lt;/td&gt;
&lt;td align="center"&gt;22&lt;/td&gt;
&lt;td align="center"&gt;2026-05&lt;/td&gt;
&lt;td align="center"&gt;2&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td align="center"&gt;2026-06&lt;/td&gt;
&lt;td align="center"&gt;3&lt;/td&gt;
&lt;td align="center"&gt;2026-07（total）&lt;/td&gt;
&lt;td align="center"&gt;84&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td align="center"&gt;2026-08（total）&lt;/td&gt;
&lt;td align="center"&gt;68&lt;/td&gt;
&lt;td align="center"&gt;2026-09（total）&lt;/td&gt;
&lt;td align="center"&gt;164&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td align="center"&gt;2026-10（total）&lt;/td&gt;
&lt;td align="center"&gt;62&lt;/td&gt;
&lt;td align="center"&gt;&lt;/td&gt;
&lt;td align="center"&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;h2 id="十一、回头看"&gt;十一、回头看&lt;/h2&gt;
&lt;p&gt;从 2023 年的第一条提交到 2026 年 10 月，博客的变化可以归纳为五条主线：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;内容能力&lt;/strong&gt;：从文章、分类和标签，发展到搜索、归档、分享卡片、图表渲染和搜索引擎收录。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;互动能力&lt;/strong&gt;：从基础评论，发展到嵌套回复、审核、邮件、表情、弹幕、照片、楼层定位和 AI 回复。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;工程能力&lt;/strong&gt;：从单体独立部署，发展到缓存、安全治理、异常降级、多模块聚合交付，以及自动化的证书、备份和测试。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;性能与体验&lt;/strong&gt;：从“能打开”到“打开得快、读得舒服”——压缩图片、裁剪字体、按需加载、暗色主题、六套可切换的主题，以及移动端的每一处细节。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;个人表达&lt;/strong&gt;：天气、音乐、纪念日、每日一句、友链和这份长期更新的记录，让它始终是一个个人博客，而不是一套通用的后台系统。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;这份历史里有大量看似很小的提交：一个按钮的位置、一处移动端溢出、一次评论定位、一种暗色文字颜色、一个第三方接口的兜底。真正让项目从“能运行”走向“长期可用”的，恰恰是这些持续发生的小修正。&lt;/p&gt;
&lt;p&gt;并入 total 之后的提交还多了一样东西：证据。提交说明开始写清楚为什么改、改完怎么验证——实测的加载时间、对比度和数据库执行计划，以及问题复发时能第一时间报错的契约测试。&lt;/p&gt;
&lt;p&gt;Git 记录下的不只是功能数量，也记录了关注点的变迁：先解决有没有，再解决好不好用；先把页面做出来，再处理性能、安全、移动端和异常路径；接着让 AI 进入内容与互动；然后回过头来，把每一处已有的东西做扎实。&lt;/p&gt;
&lt;p&gt;从 &lt;code&gt;679a795&lt;/code&gt; 到 &lt;code&gt;705b9b1&lt;/code&gt;，这是一次持续三年多、跨越两个仓库的演进，而且还在继续。&lt;/p&gt;</description>
      <pubDate>Thu, 02 Dec 2021 03:16:51 GMT</pubDate>
    </item>
    <item>
      <title>用免注册试用和分享邀请给小程序拆掉注册墙</title>
      <link>https://www.hqxiaozou.top/post/QFWgR7Mko3C</link>
      <description>&lt;blockquote&gt;
&lt;p&gt;当前实现（按 3.1.0 代码核对）：打开小程序可以先看现成菜谱、挑灵感，不会静默建号。需要搭配、收藏或使用厨房时，先阅读并主动勾选协议，再点「微信一键登录」；默认不勾选，也不会自动发起登录。登录后接着刚才的操作。注册、密码登录、找回密码和改密码入口已经下线，邮箱只用于可选绑定和验证找回原账号；找回是切换到原账号，不会自动合并两边的收藏、次数或会员。&lt;/p&gt;
&lt;p&gt;邀请奖励现为双方各 10 次搭配，30 天内有效。邀请人每 24 小时最多获得 5 份奖励，累计 50 份，统计的是奖励份数；新用户必须从分享链接进入并完成首次微信登录。分享里可浏览的那一版菜谱不会因为有其他做法而被替换。&lt;/p&gt;
&lt;p&gt;下文保留 2.0.16 拆注册墙时的设计与代码：静默游客、&lt;code&gt;/auth/guest&lt;/code&gt;、注册转正及每人 1 次等都是当时的实现，不是当前接口或额度。现在免费搭配每 24 小时 3 次；厨房、菜单等分别计次。最新使用说明见&lt;a href="https://www.hqxiaozou.top/post/bhEgnGf1Nq6"&gt;炊想介绍&lt;/a&gt;。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;&lt;a href="https://www.hqxiaozou.top/post/bhEgnGf1Nq6"&gt;《炊想》&lt;/a&gt;是个小程序：说一道菜，它把一桌饭配好。10 月 5 日上线，除了自己和测试号，只有上线当天有 2 个人注册。到 10 月 9 日盘点数据时发现：另外三十来个陌生访客打开过它，&lt;strong&gt;没有一个人用上搭配，10 月 5 日之后也没有一个人再申请注册验证码&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;最可疑的，是核心功能前面横着的那道邮箱注册。10 月 10 日上线的 2.0.16 把这道墙拆了：打开就能用，想留下来再绑定邮箱；分享链接带上邀请码，新朋友进来双方各得 1 次；再补上埋点，以后每一步流失多少一眼看清。&lt;/p&gt;
&lt;p&gt;这篇记录几处设计：没有埋点时怎么从 nginx 日志里看访客、看不到什么，游客账号为什么不另建一张表，绑定邮箱为什么是「原地转正」，原来的防刷计数为什么要跟着改口径，以及邀请奖励和埋点的取舍。&lt;/p&gt;
&lt;h2 id="一、没有埋点，先翻-nginx-日志"&gt;一、没有埋点，先翻 nginx 日志&lt;/h2&gt;
&lt;p&gt;2.0.16 之前小程序没有任何埋点，库里只记得注册、登录、搭配过的人，只看看就走的访客一个都没留下。好在小程序发出的请求都会进 nginx 访问日志，Referer 是固定格式：&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-text"&gt;https://servicewechat.com/{appid}/{version}/page-frame.html
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;按微信的文档，&lt;code&gt;{version}&lt;/code&gt; 为 &lt;code&gt;0&lt;/code&gt; 是开发版、体验版和审核版，为 &lt;code&gt;devtools&lt;/code&gt; 是开发者工具，其余是正式版。把访问日志按这一段分开，就能只看正式版的访问；再把已经注册、用过的老用户的访问剔掉，剩下的就是陌生访客。&lt;/p&gt;
&lt;p&gt;翻出来的结果：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;陌生 IP 三十来个，集中在 10 月 5 日晚上（发朋友圈和博客那一波），之后每天四到六个，10 月 9 日到盘点时（下午）还一个都没有；&lt;/li&gt;
&lt;li&gt;路径几乎一样：首页拉一下推荐菜，最多点开一道菜的做法，离开；&lt;/li&gt;
&lt;li&gt;有 4 个人在搜索框里打过字（输入时会请求联想词，所以日志里看得到），之后就再没有请求；&lt;/li&gt;
&lt;li&gt;验证码表里，10 月 5 日之后一条注册验证码都没有。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;按 IP 算只能看个大概：同一个人换网络会算成两个，共用出口 IP 的几个人又会算成一个。日志还有一个盲区：没登录时点「开始搭配」，只会跳到本地的登录页，不发任何请求，所以看不出有多少人点过、是不是在登录页走的。&lt;/p&gt;
&lt;p&gt;能确定的是：没有一个人走到申请验证码那一步。而想用上核心功能，必须先去邮箱收验证码、再设用户名和密码，在微信里大家习惯的是点一下就能用。这道墙是最可疑的一环，但「首页没讲清楚能干什么」也解释得通。所以这次两件事一起做：先拆墙，再补埋点，下次就能分清人停在了哪一步。&lt;/p&gt;
&lt;h2 id="二、方案：先让人用上，再让人留下"&gt;二、方案：先让人用上，再让人留下&lt;/h2&gt;
&lt;p&gt;当时选择了不采集手机号的方案：用 &lt;code&gt;wx.login&lt;/code&gt; 换取 openid，识别同一个微信，再复用已有的用户模型。2.0.16 做了四件事：&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;改动&lt;/th&gt;
&lt;th&gt;解决什么&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;打开就用 openid 静默开一个游客，首页直接能搭&lt;/td&gt;
&lt;td&gt;门槛：不填邮箱、不设密码&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;游客绑定邮箱时原地转正&lt;/td&gt;
&lt;td&gt;留存：想留下来的再绑定邮箱，次数和记录都保留&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;分享路径带邀请码，新朋友进来双方各得 1 次&lt;/td&gt;
&lt;td&gt;入口：给每个用户一个转发的理由&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;埋点 + 后台「拉新漏斗」&lt;/td&gt;
&lt;td&gt;度量：以后每一步流失多少看得见，不用再猜&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;&lt;img src="https://img.zxfhq.cn/blog/chuixiang/20261020/chuixiang-3-1-0-20261020-login.jpg" alt="当前登录页：主动确认协议后微信登录，或先浏览"&gt;&lt;/p&gt;
&lt;p&gt;上图为新版入口；下面继续记录 2.0.16 当时的游客方案。&lt;/p&gt;
&lt;h2 id="三、游客就是一个没有邮箱的普通用户"&gt;三、游客就是一个没有邮箱的普通用户&lt;/h2&gt;
&lt;p&gt;最省事、也最不容易出错的做法，是&lt;strong&gt;不给游客单独建表&lt;/strong&gt;：游客就是 &lt;code&gt;rk_user&lt;/code&gt; 里的一行，多一个 &lt;code&gt;guest&lt;/code&gt; 标记。&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-sql"&gt;-- 游客没有邮箱：email 允许为空（唯一索引对多个 NULL 不冲突）
ALTER TABLE `rk_user`
    MODIFY COLUMN `email` VARCHAR(128) NULL COMMENT &amp;#39;注册邮箱，统一存小写，唯一；游客为空&amp;#39;,
    ADD COLUMN `guest` TINYINT NOT NULL DEFAULT 0 COMMENT &amp;#39;1 游客（免注册试用，凭 openid 识别），绑定邮箱后转正为 0&amp;#39; AFTER `wx_openid`,
    ADD COLUMN `invited_by` BIGINT NULL COMMENT &amp;#39;通过谁分享的链接进来的 rk_user.id&amp;#39; AFTER `guest`,
    ADD INDEX `idx_rk_user_invited_by` (`invited_by`);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;几个细节：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;邮箱留空，不填占位邮箱。&lt;/strong&gt;MySQL 的唯一索引允许多个 NULL，邮箱唯一约束原样保留，游客之间也不会冲突；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;密码存一串随机 UUID 的 BCrypt 散列。&lt;/strong&gt;&lt;code&gt;password_hash&lt;/code&gt; 的非空约束不用动，游客实际上没法用密码登录，转正时换成用户自己设的；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;用户名是 &lt;code&gt;wx_&lt;/code&gt; 加 10 位随机字符&lt;/strong&gt;，字母表去掉了容易看混的 l、o、0、1，正好符合原来的用户名规则，校验不用为游客放宽。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;这样做的好处在后面：次数、历史、收藏、会员、订单、菜单……所有表都按 &lt;code&gt;user_id&lt;/code&gt; 关联，游客搭配、收藏、买会员，走的全是原来的代码。搭配次数也不区分游客，同样是每 24 小时滚动 1 次。后端对游客只多了一条限制：不能改密码，要先绑定邮箱。&lt;/p&gt;
&lt;h2 id="四、同一个微信，始终是同一个游客"&gt;四、同一个微信，始终是同一个游客&lt;/h2&gt;
&lt;p&gt;开游客的接口是 &lt;code&gt;POST /api/auth/guest&lt;/code&gt;，请求体是 &lt;code&gt;{ code, inviteCode }&lt;/code&gt;。后端拿 &lt;code&gt;wx.login&lt;/code&gt; 的 code 去换 openid（只用 openid，session_key 不落库），然后按这个顺序判断：&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-java"&gt;Optional&amp;lt;UserEntity&amp;gt; existing = userRepository.findFirstByWxOpenidAndGuestOrderByIdAsc(openid, 1);
if (existing.isPresent()) {
    UserEntity user = existing.get();
    // ... 禁用检查、记登录时间
    return new AuthResult(user, tokenService.issue(user.getId()), false, 0);
}
if (userRepository.existsByWxOpenidAndGuest(openid, 0)) {
    throw ApiException.conflict(ErrorCode.ACCOUNT_EXISTS, ErrorCode.MSG_ACCOUNT_EXISTS);
}
// ... 新游客的全站 / 同 IP 24 小时上限
UserEntity user = new UserEntity();
user.setUsername(newGuestUsername());
user.setNickname(&amp;quot;炊想食客&amp;quot;);
user.setEmail(null);
// 随机密码的散列：游客不能用密码登录，转正时换成用户自己设的
user.setPasswordHash(passwordHasher.hash(UUID.randomUUID().toString()));
user.setWxOpenid(openid);
user.setGuest(1);
&lt;/code&gt;&lt;/pre&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;这个微信有游客，就直接登录那个游客。&lt;/strong&gt;删了重装、换了手机，还是同一个游客，次数不会重置；&lt;/li&gt;
&lt;li&gt;**没有游客、但有正式账号，返回 409 &lt;code&gt;ACCOUNT_EXISTS&lt;/code&gt;**，提示「这个微信注册过炊想，登录一下就行」。老用户不会因为 token 过期，被悄悄塞进一个空的新账号，也不会多拿一份免费次数；&lt;/li&gt;
&lt;li&gt;两样都没有，才新建游客。&lt;/li&gt;
&lt;/ol&gt;
&lt;h2 id="五、什么时候自动开游客"&gt;五、什么时候自动开游客&lt;/h2&gt;
&lt;p&gt;前端的规则放在 &lt;code&gt;App.onLaunch&lt;/code&gt; 里：&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-javascript"&gt;var launch = wx.getLaunchOptionsSync() || {};
auth.rememberInvite((launch.query || {}).inv);
// 免注册试用：后台开好，用户点「开始搭配」时多半已经有了；失败的话点按钮时登录页还会再试
if (auth.canAutoGuest()) {
  auth.startGuest().catch(function () {});
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;canAutoGuest()&lt;/code&gt; 只看本地的三件事：没登录、没有主动退出过、本机没记过「这个微信有正式账号」。&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;主动退出过&lt;/strong&gt;：多半是要换账号登录，再自动开游客就把人又塞回去了，所以退出时记一个标记，之后不再自动开（登录页上仍可以手动点「直接试用」）；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;这个微信有正式账号&lt;/strong&gt;：全新设备上前端无从得知，只能先请求一次，收到 &lt;code&gt;ACCOUNT_EXISTS&lt;/code&gt; 再记下来，之后登录页换成「这个微信注册过炊想，用账号登录就行」。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;启动时的请求不等结果、出错也不管。如果用户手快，游客还没开好就点了「开始搭配」，会走原来的登录墙。登录墙这次只改了一处：&lt;code&gt;requireLogin&lt;/code&gt; 默认在登录页地址后面带上 &lt;code&gt;auto=1&lt;/code&gt;。登录页看到 &lt;code&gt;auto=1&lt;/code&gt; 且能试用，就直接开游客、回到原来的页面，首页在 &lt;code&gt;onShow&lt;/code&gt; 里把刚才被打断的搭配接着做完：&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-javascript"&gt;track.event(&amp;#39;login_wall&amp;#39;, { props: options.auto === &amp;#39;1&amp;#39; ? &amp;#39;auto&amp;#39; : &amp;#39;manual&amp;#39; });
if (options.auto === &amp;#39;1&amp;#39; &amp;amp;&amp;amp; auth.canAutoGuest()) {
  this.onTrial();
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;startGuest&lt;/code&gt; 内部用一个模块级的 promise 合并并发调用，登录页拿到的就是启动时还没回来的那一次请求，不会再发第二次；如果等待期间用户自己用账号登录了，游客会话直接丢掉，以账号为准。全项目 37 处 &lt;code&gt;requireLogin&lt;/code&gt; 调用，只有「我的」页的「登录」按钮改成传 &lt;code&gt;{ manual: true }&lt;/code&gt;，进去看到的是登录表单，其余都没动。&lt;/p&gt;
&lt;p&gt;还有一处要补：注册页有一个必须勾选的同意框，免注册试用绕过了注册页。所以登录页的「直接试用」按钮下补了一行「使用即表示同意《用户协议与隐私说明》」，隐私说明同步写上试用账号、使用统计和分享邀请分别记了什么。&lt;/p&gt;
&lt;h2 id="六、绑定邮箱就是原地转正"&gt;六、绑定邮箱就是原地转正&lt;/h2&gt;
&lt;p&gt;游客想换手机也能登录，就去「我的」页绑定邮箱。这里没有新开接口，复用原来的 &lt;code&gt;/api/auth/register&lt;/code&gt;：请求带着有效的游客 token，就在同一行上转正。&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-java"&gt;UserEntity guest = guestUserId == null ? null : userRepository.findById(guestUserId).orElse(null);
if (guest != null &amp;amp;&amp;amp; !guest.isGuest()) guest = null;
// ... 用户名 / 邮箱 / 手机号唯一性检查
if (guest != null) {
    // 游客转正：已经占过一个号，不再数 IP 注册名额；被封 / 注册受限的 IP 照样拦
    requireRegisterAllowed(clientIp);
    emailCodeService.verifyAndConsume(email, EmailCodeEntity.PURPOSE_REGISTER, emailCode);
    return upgradeGuest(guest, username, email, password, nickname, phone, request.getCode(), clientIp, now);
}
enforceIpRegisterLimit(clientIp, now);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;upgradeGuest&lt;/code&gt; 改的是用户名、邮箱、密码、昵称、手机号，把 &lt;code&gt;guest&lt;/code&gt; 置 0（顺带刷新登录时间和 IP）；id、创建时间、邀请关系都不动。因为所有数据都挂在 &lt;code&gt;user_id&lt;/code&gt; 上，&lt;strong&gt;转正不需要迁移任何一张表&lt;/strong&gt;。如果游客和正式账号是两张表、两个 id，这里就得把十来张表的数据挪过去，还要处理挪到一半失败。&lt;/p&gt;
&lt;p&gt;测试里把这几条钉成了契约：转正后还是同一个 id、&lt;code&gt;guest&lt;/code&gt; 为 false；已经用掉的那次免费搭配跟着走，不会再送一份；能用新设的账号密码登录；同一个微信再要游客，返回 &lt;code&gt;ACCOUNT_EXISTS&lt;/code&gt;。前端也只多一个分支：同一张注册表单，当前是游客时标题换成「绑定邮箱」，请求带上游客 token。&lt;/p&gt;
&lt;h2 id="七、新账号类型上线，防刷计数要跟着改口径"&gt;七、新账号类型上线，防刷计数要跟着改口径&lt;/h2&gt;
&lt;p&gt;炊想原来有两道注册防刷：同一 IP 24 小时内注册数到上限就限制这个 IP 注册，全站 24 小时注册数也有总闸。两者原来都是直接数 &lt;code&gt;rk_user&lt;/code&gt; 的行。&lt;/p&gt;
&lt;p&gt;游客一上线，这个口径就不对了：游客是打开时静默建的，而运营商的出口 IP 很多人共用，一个 IP 上来几个新游客，正式注册的名额就被占满，想绑定邮箱的人反而被拦下。&lt;/p&gt;
&lt;p&gt;改法是把两类账号分开计数，两道上限都只数 &lt;code&gt;guest = 0&lt;/code&gt;：&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-java"&gt;// 与 IP 无关的全站总量：换 IP 批量养号时，每账号的免费额度不至于被无限放大
userRepository.countByGuestAndCreatedAtAfter(0, now.minusHours(IP_REGISTER_WINDOW_HOURS));
// 只数正式注册：游客另有自己的上限（运营商出口 IP 很多人共用）
userRepository.countByGuestAndRegisterIpAndCreatedAtAfter(0, clientIp, now.minusHours(IP_REGISTER_WINDOW_HOURS));
&lt;/code&gt;&lt;/pre&gt;
&lt;ul&gt;
&lt;li&gt;新游客另有一套同 IP、全站的 24 小时上限，超了提示「今天新来的朋友太多了，注册一个账号接着用吧」；&lt;/li&gt;
&lt;li&gt;转正这次请求不再数 IP 注册名额（这一行建游客时已经占过一个号），但被封禁、被限制注册的 IP 照样拦，不能借转正绕过去。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;功能本身测得通，这一处却很容易漏：只要多了一种账号，所有「数用户」的地方都得重新过一遍口径。后台概览里「有次数包的人数」也是同样的道理，下一节会说到。&lt;/p&gt;
&lt;h2 id="八、分享邀请：双方各得-1-次"&gt;八、分享邀请：双方各得 1 次&lt;/h2&gt;
&lt;p&gt;邀请码由用户 id 现算出来，做了一层混淆，不建表、不落库，只为不在链接里直接露出自增 id，并不当作凭证用。分享时由 &lt;code&gt;sharePath&lt;/code&gt; 拼到路径上：&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-javascript"&gt;/** 给分享路径带上自己的邀请码：path 可以已有 query */
function sharePath(path) {
  var code = inviteCode();
  var p = String(path || config.HOME_PAGE);
  if (!code) {
    return p;
  }
  return p + (p.indexOf(&amp;#39;?&amp;#39;) &amp;gt;= 0 ? &amp;#39;&amp;amp;&amp;#39; : &amp;#39;?&amp;#39;) + &amp;#39;inv=&amp;#39; + encodeURIComponent(code);
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;朋友点开卡片，冷启动在 &lt;code&gt;onLaunch&lt;/code&gt;、热启动在 &lt;code&gt;onShow&lt;/code&gt; 里读到 &lt;code&gt;inv&lt;/code&gt;，没登录才记下，接着随 &lt;code&gt;/api/auth/guest&lt;/code&gt; 一起交给后端。&lt;/p&gt;
&lt;p&gt;后端的规则：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;只奖励新建的号。&lt;/strong&gt;只有新建游客、或者直接注册新号的那一刻才写 &lt;code&gt;invited_by&lt;/code&gt;、发奖励；老游客再点别人的链接不发，游客转正也不再发第二次；邀请人和新号是同一个微信时不认；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;奖励复用次数包。&lt;/strong&gt;在权益表里给双方各存一份 1 次、30 天有效的次数包，扣次数、到期、后台收回都沿用原来那一套。扣的顺序是免费额度 → 会员 → 次数包（次数包之间先扣快到期的），所以送的次数排在免费额度和会员之后才用；后台统计「有次数包的人数」时把它们排除，免得混进付费数据；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;上限只卡邀请人。&lt;/strong&gt;邀请人每 24 小时最多得 5 次、累计最多 50 次；被邀请的新人不受影响，总能拿到自己那 1 次。&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code class="language-java"&gt;save(invitee.getId(), PRODUCT_INVITEE, &amp;quot;受 #&amp;quot; + inviterId + &amp;quot; 邀请&amp;quot;, now);
long today = entitlementRepository.countByUserIdAndProductIdAndCreatedAtAfter(inviterId, PRODUCT_INVITER, now.minusHours(24));
long total = entitlementRepository.countByUserIdAndProductId(inviterId, PRODUCT_INVITER);
boolean inviterRewarded = today &amp;lt; cfg.getInviteDailyCap() &amp;amp;&amp;amp; total &amp;lt; cfg.getInviteTotalCap();
if (inviterRewarded) {
    save(inviterId, PRODUCT_INVITER, &amp;quot;邀请了 #&amp;quot; + invitee.getId(), now);
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;测试里专门有一条：同一个邀请码连续进来 6 个新游客，6 个新人都拿到 1 次，邀请人只加了 5 次，受邀人数照记 6 个。&lt;/p&gt;
&lt;h2 id="九、埋点和漏斗"&gt;九、埋点和漏斗&lt;/h2&gt;
&lt;p&gt;拆完墙，要能看见效果。埋点有三条原则：&lt;strong&gt;不影响使用、不用 openid、开发调试不污染数据。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;设备号&lt;/strong&gt;是本机随机生成的一串字符，不是 openid，清缓存就换新的。漏斗按它去重，所以没登录的人也能统计到。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;页面浏览自动记。&lt;/strong&gt;&lt;code&gt;app.js&lt;/code&gt; 最前面调用 &lt;code&gt;track.installPageHook()&lt;/code&gt;，把全局 &lt;code&gt;Page&lt;/code&gt; 包一层，每个页面 &lt;code&gt;onShow&lt;/code&gt; 时记一条，十几个页面不用逐个手写：&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-javascript"&gt;var origin = Page;
var wrapped = function (options) {
  options = options || {};
  var onShow = options.onShow;
  options.onShow = function () {
    try {
      event(&amp;#39;page&amp;#39;, { page: this.route });
    } catch (e) {
      /* 埋点异常不影响页面 */
    }
    if (typeof onShow === &amp;#39;function&amp;#39;) {
      return onShow.apply(this, arguments);
    }
  };
  return origin(options);
};
wrapped.__cxTracked = true;
Page = wrapped;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;其余是几个关键动作：打开（带场景值）、点搭配、看到登录页、试用成功或失败、绑定邮箱、出菜、分享、点购买。「点搭配」记在登录墙之前，没登录时多带一个标记，这样被墙拦下的点击也数得到。这正是第一节里日志看不到的那一步。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;攒批上报。&lt;/strong&gt;攒满 10 条或等 3 秒发一次，一次最多 30 条，切后台时立即发；失败就丢，不重试。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;只收正式版。&lt;/strong&gt;开发版、体验版照常上报，带上 &lt;code&gt;env&lt;/code&gt;（取自 &lt;code&gt;envVersion&lt;/code&gt;），由后端丢掉。前端只有一条代码路径，开发时走的就是线上那条。&lt;/p&gt;
&lt;p&gt;后端这边：事件名有白名单，设备号格式不对整批丢弃，同一 IP 每分钟限量，入库时间用服务器时间，每天凌晨定时删掉 120 天前的数据。数据不合格只丢掉、返回保存了几条，不报错。&lt;/p&gt;
&lt;p&gt;漏斗按北京时间自然日出数：打开、看首页、看菜谱、点搭配、看到登录页按设备去重；新账号、仍是游客、受邀、新人出菜直接查用户表和搭配记录。最后一行「合计」是对整段时间重新去重，不是把每天的数加起来，同一台设备连着几天打开只算一次。打开来源按场景值归成搜索、分享卡片、扫码、最近使用等几类。&lt;/p&gt;
&lt;p&gt;博客后台的《小程序管理》概览加了一张「拉新漏斗（近 7 天）」。它是单独取数的，取不到只显示一行提示，概览照常显示，这样博客和小程序后端可以不同时发布。&lt;/p&gt;
&lt;p&gt;上线一周后按这张表看：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;「打开」很少：问题在入口，得去做分享和内容；&lt;/li&gt;
&lt;li&gt;「看首页」多、「点搭配」少：首页没讲清楚能干什么；&lt;/li&gt;
&lt;li&gt;「点搭配」多、「新人出菜」少：试用或出菜环节出了错，去看失败事件带的错误码。&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id="十、上线以后"&gt;十、上线以后&lt;/h2&gt;
&lt;p&gt;2.0.16 在 10 月 10 日中午发布，12:35 收到第一条正式版埋点。&lt;/p&gt;
&lt;p&gt;第一个碰到新逻辑的，是一个早就注册过正式账号的微信：埋点里收到一条 &lt;code&gt;guest_fail&lt;/code&gt;，错误码 &lt;code&gt;ACCOUNT_EXISTS&lt;/code&gt;。按设计，这台手机从此不再自动开游客，登录页换成「用账号登录就行」，不会多出一个空的游客号。&lt;/p&gt;
&lt;p&gt;当天下午，正式版来了第一个游客。对着埋点和搭配记录看：&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th align="center"&gt;游客开好后&lt;/th&gt;
&lt;th align="center"&gt;发生了什么&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td align="center"&gt;第 0 秒&lt;/td&gt;
&lt;td align="center"&gt;从微信下拉的「最近使用」打开，游客开好&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td align="center"&gt;第 14 秒&lt;/td&gt;
&lt;td align="center"&gt;搜了一道菜，站里已有现成做法，不占次数，当场出菜&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td align="center"&gt;第 25 秒&lt;/td&gt;
&lt;td align="center"&gt;又点了一次随机搭配，用掉免费的那一次，53 秒后搭好&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;从游客开好到搜出一桌饭用了 14 秒，没有经过注册页。&lt;/p&gt;
&lt;p&gt;数据还太少，看不出转化，一周后再看漏斗。但至少「想搭一桌饭得先注册」这一关已经没有了。&lt;/p&gt;
&lt;h2 id="十一、几点体会"&gt;十一、几点体会&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;第一版就该带埋点。&lt;/strong&gt;这次只能靠 nginx 日志里 Referer 的版本段倒推访客，按 IP 算，连谁走到过登录页都看不出。「零个人申请验证码」够下决心拆墙，更细的问题只能猜。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;门槛放在价值后面。&lt;/strong&gt;先让人搭出一桌饭，等他想换手机也能登录、想保住记录时，再让他绑定邮箱。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;新身份尽量复用旧模型，代价是重新数一遍人。&lt;/strong&gt;游客是 &lt;code&gt;rk_user&lt;/code&gt; 里多一个标记，转正是改一行；但注册防刷的两道上限、后台「有次数包的人数」都得跟着改口径。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;奖励只给新建的号，上限只卡邀请人&lt;/strong&gt;，并写成测试。规则一多，靠记忆是守不住的。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;隐私说明跟着功能一起改。&lt;/strong&gt;新记了什么、保存多久、用来做什么，上线前写进去。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;相关：&lt;a href="https://www.hqxiaozou.top/post/3h3vJNHQDNs"&gt;小程序分享卡片被分享签名校验降级的排查&lt;/a&gt; 记录的是分享卡片的另一个坑，邀请链接靠的就是这张卡片。&lt;/p&gt;</description>
      <pubDate>Sat, 10 Oct 2026 08:26:42 GMT</pubDate>
    </item>
    <item>
      <title>用 RestTemplate 流式调用文本接口时按有没有输出决定是否重试</title>
      <link>https://www.hqxiaozou.top/post/WNCFKkWKCBt</link>
      <description>&lt;blockquote&gt;
&lt;p&gt;本文保留 2026-10-12 那轮 RestTemplate 重试的排查。当前 3.1.0 已在其外增加可选的优先通道与回退，菜单、食材想菜、做菜问答和照片识别也分别校验结果；文中的 140 秒、30 秒和 95 秒预留是原通道及当时菜谱方法的参数，不应直接套到所有功能。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;&lt;a href="https://www.hqxiaozou.top/post/bhEgnGf1Nq6"&gt;《炊想》&lt;/a&gt;的后端是 Spring Boot 2.7 + JDK 8，核心一步是调一个 OpenAI 兼容的文本接口，让它按提示词写出一整份菜谱 JSON（食材、步骤、搭配、营养，约 1600 个 token）。上游每秒吐十几个 token，一份写完要 60～90 秒。&lt;/p&gt;
&lt;p&gt;10 月 12 日中午，这个上游连续半个多小时很不稳定：有时一开口就回「当前模型访问量较大，请稍后重试」，有时干脆 30 秒一个字都不写。翻日志发现，后端好几次在&lt;strong&gt;明明还有一大半时间&lt;/strong&gt;的情况下就判了失败，用户在小程序里看到「搭配师走神了」。&lt;/p&gt;
&lt;p&gt;这篇记录当天对重试策略的修改：为什么要流式读取、读超时到底在量什么、怎么在 &lt;code&gt;RestTemplate&lt;/code&gt; 里知道「上游到底写没写」、退避怎么和总时限配合，以及这些分支怎么用 &lt;code&gt;MockRestServiceServer&lt;/code&gt; 测出来。最后附带一个同一天修的、看起来很蠢的解析问题。&lt;/p&gt;
&lt;h2 id="一、先说背景：为什么要流式读"&gt;一、先说背景：为什么要流式读&lt;/h2&gt;
&lt;p&gt;最早的版本是普通请求：发出去，等上游把整段 JSON 写完再一次性读回来。这时 &lt;code&gt;SimpleClientHttpRequestFactory&lt;/code&gt; 的 &lt;code&gt;readTimeout&lt;/code&gt; 量的其实是「&lt;strong&gt;整份必须在多少秒内写完&lt;/strong&gt;」——上游在写的过程中不会发任何字节，连接上一直是静默的。&lt;/p&gt;
&lt;p&gt;10 月 5 日就吃过这个亏：读超时设的 75 秒，两次生成都在 75 秒处断开，重试一次又是 75 秒，小程序在 nginx 的 120 秒处拿到 504。&lt;/p&gt;
&lt;p&gt;改成流式（请求体里带 &lt;code&gt;&amp;quot;stream&amp;quot;: true&lt;/code&gt;，上游按 SSE 一段一段回 &lt;code&gt;data: {...}&lt;/code&gt;）以后，读超时的含义变了：&lt;code&gt;HttpURLConnection&lt;/code&gt; 的读超时是&lt;strong&gt;每次 &lt;code&gt;read&lt;/code&gt; 最多阻塞多久&lt;/strong&gt;，只要上游隔一会儿写一段，就不会超时。于是两个时间可以分开管：&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;配置&lt;/th&gt;
&lt;th&gt;值&lt;/th&gt;
&lt;th&gt;管什么&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;&lt;code&gt;readTimeout&lt;/code&gt;（text-idle-timeout）&lt;/td&gt;
&lt;td&gt;30 秒&lt;/td&gt;
&lt;td&gt;两段输出之间最多隔多久&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;总时限（text-timeout）&lt;/td&gt;
&lt;td&gt;140 秒&lt;/td&gt;
&lt;td&gt;整次生成最多多久，比 nginx 和小程序的 150 秒短&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;读超时交给 &lt;code&gt;RestTemplate&lt;/code&gt;，总时限要自己掐：每读一行检查一次有没有过 deadline。&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-java"&gt;static RestTemplate build(int connectTimeoutMs, int readTimeoutMs) {
    SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory();
    factory.setConnectTimeout(Math.max(1000, connectTimeoutMs));
    factory.setReadTimeout(Math.max(1000, readTimeoutMs));   // 文本接口传的是 30 秒的空闲超时
    return new RestTemplate(factory);
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;流式读取用 &lt;code&gt;RestTemplate.execute&lt;/code&gt; 自己写请求体、自己读响应流，不让它把整个响应读成字符串：&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-java"&gt;content = restTemplate.execute(url, HttpMethod.POST, request -&amp;gt; {
    request.getHeaders().putAll(headers);
    try (OutputStream out = request.getBody()) {
        out.write(body);
    }
}, response -&amp;gt; readContent(response, deadline, gotOutput));
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;readContent&lt;/code&gt; 按 &lt;code&gt;Content-Type&lt;/code&gt; 分流：&lt;code&gt;text/event-stream&lt;/code&gt; 就逐行读 &lt;code&gt;data:&lt;/code&gt;，把 &lt;code&gt;choices[0].delta.content&lt;/code&gt; 拼起来，遇到 &lt;code&gt;data: [DONE]&lt;/code&gt; 结束；上游没按流式回（有的兼容接口会忽略 &lt;code&gt;stream&lt;/code&gt;）就整段解析 &lt;code&gt;choices[0].message.content&lt;/code&gt;。&lt;/p&gt;
&lt;h2 id="二、失败有好几种，处理方式不一样"&gt;二、失败有好几种，处理方式不一样&lt;/h2&gt;
&lt;p&gt;接口调用失败统一抛一个 &lt;code&gt;AiCallException&lt;/code&gt;，带 &lt;code&gt;retryable&lt;/code&gt; 和 HTTP 状态码。原来的重试规则只有一条：&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;只在开头 20 秒内就失败的，才重试一次。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;道理是：写到一半甚至写完才失败（比如 JSON 解析不了），已经花掉六七十秒，再来一遍必然超过总时限，重试只是让用户多等。&lt;/p&gt;
&lt;p&gt;10 月 10 日上线后先碰到一种例外：上游在第 22 秒才回「访问量较大」——它根本没开始写，只是排了 22 秒队才拒绝。按老规则过了 20 秒窗口，直接失败。当时加了一条「上游说忙」的分支：只要总时限还剩 95 秒（一份菜谱一般 60～90 秒写完），就隔 2 秒再试，最多 3 次。&lt;/p&gt;
&lt;p&gt;10 月 12 日又碰到两种新情况，把规则彻底理了一遍：&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;情况&lt;/th&gt;
&lt;th&gt;例子&lt;/th&gt;
&lt;th&gt;处理&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;上游明确说忙&lt;/td&gt;
&lt;td&gt;流里回错误片段「访问量较大」「服务暂时不可用」、HTTP 503&lt;/td&gt;
&lt;td&gt;时间够就退避再试&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;读超时，但一个字都没收到&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;上游 30 秒不吭声，第 34 秒空闲超时&lt;/td&gt;
&lt;td&gt;和「说忙」一样：时间够就退避再试&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;写到一半断了&lt;/td&gt;
&lt;td&gt;收到了一部分正文后读超时&lt;/td&gt;
&lt;td&gt;照老规则：开头 20 秒内才重试一次（实际几乎都已过了窗口）&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;写完了但用不了&lt;/td&gt;
&lt;td&gt;JSON 解析失败、只有推理段没有正文&lt;/td&gt;
&lt;td&gt;开头 20 秒内失败才重试一次&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;明确拒绝&lt;/td&gt;
&lt;td&gt;429、其它 4xx&lt;/td&gt;
&lt;td&gt;不重试&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;第二行是这次的重点。日志长这样（时间换成了相对秒数）：&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-text"&gt;第 34 秒  文本上游第 1 次失败（已用 34206 ms，来不及重试）: 文本上游连接/读取超时:
          I/O error on POST request for &amp;quot;.../chat/completions&amp;quot;: Read timed out
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;总时限 140 秒，才用了 34 秒，还剩 106 秒，足够再写一份；但因为过了 20 秒窗口，被当成「写到一半断了」直接判死。问题在于：&lt;strong&gt;光看一个 &lt;code&gt;Read timed out&lt;/code&gt;，分不清是没开始写还是写到一半。&lt;/strong&gt;&lt;/p&gt;
&lt;h2 id="三、怎么知道上游到底写没写"&gt;三、怎么知道上游到底写没写&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;RestTemplate&lt;/code&gt; 把响应读取里抛出的 &lt;code&gt;IOException&lt;/code&gt;（包括 &lt;code&gt;SocketTimeoutException&lt;/code&gt;）包成 &lt;code&gt;ResourceAccessException&lt;/code&gt; 抛出来，到 &lt;code&gt;catch&lt;/code&gt; 的时候，读到了多少已经无从知道。只能在读的过程中自己记下来。&lt;/p&gt;
&lt;p&gt;第一反应是在 client 上加个字段 &lt;code&gt;volatile boolean gotOutput&lt;/code&gt;——不行，这个 client 是单例，好几个生成任务在不同线程里同时调，字段会互相覆盖。进度必须是&lt;strong&gt;每次调用自己的局部状态&lt;/strong&gt;，所以在 &lt;code&gt;callRaw&lt;/code&gt; 里开一个只有一个元素的数组，传进读取方法：&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-java"&gt;final boolean[] gotOutput = {false};
String content;
try {
    content = restTemplate.execute(url, HttpMethod.POST, request -&amp;gt; { /* 写请求体 */ },
            response -&amp;gt; readContent(response, deadline, gotOutput));
} catch (HttpStatusCodeException ex) {
    int status = ex.getRawStatusCode();
    throw new AiCallException(&amp;quot;文本上游返回 HTTP &amp;quot; + status + &amp;quot;: &amp;quot; + Texts.truncate(ex.getResponseBodyAsString(), 200),
            status &amp;gt;= 500, status, ex);
} catch (ResourceAccessException ex) {
    AiCallException failed = new AiCallException(&amp;quot;文本上游连接/读取超时&amp;quot; + (gotOutput[0] ? &amp;quot;&amp;quot; : &amp;quot;（还没开始输出）&amp;quot;)
            + &amp;quot;: &amp;quot; + ex.getMessage(), true, -1, ex);
    throw gotOutput[0] ? failed : failed.markBeforeOutput();
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;lambda 里不能给外层局部变量重新赋值（必须是 effectively final），所以用数组当个可变的盒子；也可以用 &lt;code&gt;AtomicBoolean&lt;/code&gt;，这里只在同一个线程里读写，数组就够了。&lt;/p&gt;
&lt;p&gt;读 SSE 的循环里，收到第一段正文或推理内容时置位：&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-java"&gt;while ((line = reader.readLine()) != null) {
    checkDeadline(deadline);
    if (!line.startsWith(&amp;quot;data:&amp;quot;)) continue;
    String data = line.substring(5).trim();
    if (data.isEmpty()) continue;
    if (&amp;quot;[DONE]&amp;quot;.equals(data)) break;
    JsonNode chunk = objectMapper.readTree(data);
    // ……错误片段直接抛 AiCallException
    JsonNode delta = chunk.path(&amp;quot;choices&amp;quot;).path(0).path(&amp;quot;delta&amp;quot;);
    JsonNode piece = delta.path(&amp;quot;content&amp;quot;);
    if (piece.isTextual()) content.append(piece.asText());
    JsonNode reasoning = delta.path(&amp;quot;reasoning_content&amp;quot;);
    if (reasoning.isTextual() &amp;amp;&amp;amp; !reasoning.asText().isEmpty()) sawReasoning = true;
    if (content.length() &amp;gt; 0 || sawReasoning) gotOutput[0] = true;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;注意置位的条件是「收到了内容」，不是「收到了一行」。有的上游会先回一个只有 &lt;code&gt;role&lt;/code&gt; 的空 delta、或者定时发 &lt;code&gt;: keep-alive&lt;/code&gt; 注释行，这些不算开始写。&lt;/p&gt;
&lt;p&gt;异常类上加一个标记，不改原来的构造器，免得所有调用方都要跟着动：&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-java"&gt;public class AiCallException extends RuntimeException {
    private final boolean retryable;
    private final int statusCode;
    /** 上游一个字还没写就断了（读超时前没收到任何输出）：和「忙」一样，时间够就值得再来一遍。 */
    private boolean beforeOutput;

    public boolean isBeforeOutput() { return beforeOutput; }

    AiCallException markBeforeOutput() {
        this.beforeOutput = true;
        return this;
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;markBeforeOutput&lt;/code&gt; 是包级可见，只有同包的 client 能打这个标记。连接超时、连接被拒也会走到 &lt;code&gt;ResourceAccessException&lt;/code&gt;，这时 &lt;code&gt;gotOutput&lt;/code&gt; 一样是 false，也按「没开始写」处理——确实也是，重试是对的。&lt;/p&gt;
&lt;h2 id="四、退避要和总时限一起算"&gt;四、退避要和总时限一起算&lt;/h2&gt;
&lt;p&gt;当天中午上游连续说忙，老规则「隔 2 秒、最多 3 次」在 7 秒内就把机会用完了，而总时限还剩一百多秒。改成逐次拉长：2、4、8 秒，之后都是 10 秒，最多 5 次。&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-java"&gt;static final int MAX_ATTEMPTS = 5;
static final long RETRY_WINDOW_MS = 20000L;
static final long BUSY_RETRY_MIN_REMAINING_MS = 95000L;
static final long BUSY_BACKOFF_MS = 2000L;
static final long BUSY_BACKOFF_MAX_MS = 10000L;

/** 第 attempt 次说忙后等多久：2、4、8 秒，之后都是 10 秒。 */
static long busyBackoff(int attempt) {
    return Math.min(BUSY_BACKOFF_MAX_MS, BUSY_BACKOFF_MS &amp;lt;&amp;lt; Math.min(10, attempt - 1));
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;Math.min(10, attempt - 1)&lt;/code&gt; 是防御性的：左移位数不封顶的话，次数一多 &lt;code&gt;2000L &amp;lt;&amp;lt; 63&lt;/code&gt; 会溢出成负数，&lt;code&gt;Math.min&lt;/code&gt; 就会选中一个负的等待时间。&lt;/p&gt;
&lt;p&gt;关键是「还剩多少时间才值得再试」要把&lt;strong&gt;这次要等的时间也扣掉&lt;/strong&gt;：等完 10 秒只剩 90 秒，再试大概率写不完，不如现在就告诉用户失败（失败不扣次数，用户可以过一会儿再点）。&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-java"&gt;private &amp;lt;T&amp;gt; T withRetry(String system, String user, long totalTimeoutMs, Function&amp;lt;String, T&amp;gt; parse) {
    long started = now();
    long deadline = started + Math.max(1000, totalTimeoutMs);
    AiCallException last = null;
    for (int attempt = 1; attempt &amp;lt;= MAX_ATTEMPTS; attempt++) {
        try {
            return parse.apply(callRaw(system, user, deadline));
        } catch (AiCallException ex) {
            last = ex;
            long now = now();
            long elapsed = now - started;
            if (!ex.isRetryable() || attempt == MAX_ATTEMPTS) break;
            if (isBusy(ex) || ex.isBeforeOutput()) {
                long backoff = busyBackoff(attempt);
                if (deadline - now - backoff &amp;lt; BUSY_RETRY_MIN_REMAINING_MS) {
                    log.warn(&amp;quot;文本上游第 {} 次没开始写（忙或排队，已用 {} ms，剩下的时间不够再写一份）: {}&amp;quot;, attempt, elapsed, ex.getMessage());
                    break;
                }
                log.warn(&amp;quot;文本上游第 {} 次没开始写（忙或排队），{} ms 后再试: {}&amp;quot;, attempt, backoff, ex.getMessage());
                pause(backoff);
                continue;
            }
            if (attempt &amp;gt;= 2 || elapsed &amp;gt; RETRY_WINDOW_MS) {
                log.warn(&amp;quot;文本上游第 {} 次失败（已用 {} ms，来不及重试）: {}&amp;quot;, attempt, elapsed, ex.getMessage());
                break;
            }
            log.warn(&amp;quot;文本上游第 {} 次失败，重试一次: {}&amp;quot;, attempt, ex.getMessage());
        }
    }
    throw last;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;几个细节：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;所有尝试共用同一个 deadline。&lt;/strong&gt; 重试不是重新计时，&lt;code&gt;callRaw&lt;/code&gt; 里每读一行都对同一个 deadline 检查，总耗时不会超过 140 秒。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;超过总时限抛的异常是 &lt;code&gt;retryable = false&lt;/code&gt;。&lt;/strong&gt; 已经超时了就别再试，第一行 &lt;code&gt;if&lt;/code&gt; 直接跳出。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;「说忙」靠关键字判断。&lt;/strong&gt; 上游在流里回的错误片段只有一句中文提示，没有结构化的错误码，只能匹配「访问量较大」「稍后重试」「暂时不可用」「overloaded」「unavailable」这些词，外加 HTTP 503。这类判断写成包级 &lt;code&gt;static&lt;/code&gt; 方法，方便单测直接调。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;时间和睡眠都走可覆盖的方法。&lt;/strong&gt; &lt;code&gt;now()&lt;/code&gt; 和 &lt;code&gt;pause(ms)&lt;/code&gt; 是包级可见的普通方法，测试里覆盖掉，后面会用到。&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id="五、怎么测这些分支"&gt;五、怎么测这些分支&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;MockRestServiceServer&lt;/code&gt; 可以绑到同一个 &lt;code&gt;RestTemplate&lt;/code&gt; 上，按顺序预设每次请求的响应。难点在于「过了 20 秒」「还剩 95 秒」这类和时间有关的分支，总不能真等。做法是在测试里继承一个匿名子类，覆盖时钟和睡眠：&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-java"&gt;/** 时钟每读一次往前走 stepMs，用来模拟「已经用了很久」。 */
private ZhiChatClientImpl clientWithClock(final long stepMs) {
    return new ZhiChatClientImpl(restTemplate, objectMapper, properties, new RecipeJsonParser(objectMapper)) {
        private long t = 0;
        @Override
        long now() {
            t += stepMs;
            return t;
        }

        @Override
        void pause(long ms) {
        }
    };
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;没开始写就超时。&lt;/strong&gt; &lt;code&gt;ResponseCreator&lt;/code&gt; 是个函数式接口，直接在里面抛 &lt;code&gt;SocketTimeoutException&lt;/code&gt;，&lt;code&gt;RestTemplate&lt;/code&gt; 会把它包成 &lt;code&gt;ResourceAccessException&lt;/code&gt;，和线上一模一样：&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-java"&gt;@Test
void timeoutBeforeAnyOutputIsRetriedLikeBusy() throws Exception {
    // 线上 2026-10-12：上游 30 秒一个字没写，第 34 秒空闲超时；总时限还剩 106 秒，应该再来一遍
    properties.getAi().setTextTimeoutMs(1_000_000);
    ZhiChatClientImpl late = clientWithClock(ZhiChatClientImpl.RETRY_WINDOW_MS + 2_000);
    server.expect(once(), requestTo(URL)).andRespond(request -&amp;gt; {
        throw new SocketTimeoutException(&amp;quot;Read timed out&amp;quot;);
    });
    server.expect(once(), requestTo(URL)).andRespond(withSuccess(stream(FULL), MediaType.TEXT_EVENT_STREAM));
    assertNotNull(late.generateRecipe(prompt));
    server.verify();
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;时钟每次前进 22 秒，保证「已用时间超过 20 秒窗口」——在老规则下这个测试会失败，新规则下第二次请求成功。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;写到一半才超时。&lt;/strong&gt; 要让响应先吐出一段正文、再在下一次 &lt;code&gt;read&lt;/code&gt; 时超时。用 &lt;code&gt;SequenceInputStream&lt;/code&gt; 把一段正常的 SSE 和一个「一读就超时」的流接起来，塞进 &lt;code&gt;MockClientHttpResponse&lt;/code&gt;：&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-java"&gt;@Test
void timeoutAfterOutputStartedIsNotRetriedLate() throws Exception {
    properties.getAi().setTextTimeoutMs(1_000_000);
    ZhiChatClientImpl late = clientWithClock(ZhiChatClientImpl.RETRY_WINDOW_MS + 2_000);
    server.expect(once(), requestTo(URL)).andRespond(request -&amp;gt; {
        byte[] head = &amp;quot;data: {\&amp;quot;choices\&amp;quot;:[{\&amp;quot;delta\&amp;quot;:{\&amp;quot;content\&amp;quot;:\&amp;quot;{\\\&amp;quot;title\&amp;quot;}}]}\n\n&amp;quot;.getBytes(StandardCharsets.UTF_8);
        InputStream stalled = new InputStream() {
            @Override
            public int read() throws IOException {
                throw new SocketTimeoutException(&amp;quot;Read timed out&amp;quot;);
            }
        };
        MockClientHttpResponse response = new MockClientHttpResponse(
                new SequenceInputStream(new ByteArrayInputStream(head), stalled), HttpStatus.OK);
        response.getHeaders().setContentType(MediaType.TEXT_EVENT_STREAM);
        return response;
    });
    AiCallException ex = assertThrows(AiCallException.class, () -&amp;gt; late.generateRecipe(prompt));
    assertFalse(ex.isBeforeOutput());
    server.verify();
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这里只预设了一次请求：如果代码误判成「没开始写」去重试，&lt;code&gt;MockRestServiceServer&lt;/code&gt; 会因为收到没预期的第二个请求而报错，所以这个测试同时保证了「不重试」。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;退避时间。&lt;/strong&gt; 纯函数直接断言：&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-java"&gt;assertEquals(2000L, ZhiChatClientImpl.busyBackoff(1));
assertEquals(4000L, ZhiChatClientImpl.busyBackoff(2));
assertEquals(8000L, ZhiChatClientImpl.busyBackoff(3));
assertEquals(10000L, ZhiChatClientImpl.busyBackoff(4));
assertEquals(10000L, ZhiChatClientImpl.busyBackoff(9));
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;还踩了一个小坑：原来有个测试「上游连续说忙直到用完次数」用的是真时钟的 client。次数从 3 改成 5、间隔拉长以后，它会真的 &lt;code&gt;Thread.sleep&lt;/code&gt; 2+4+8+10 = 24 秒，整个测试套件慢了一截。换成 &lt;code&gt;clientWithClock(1)&lt;/code&gt;（时钟几乎不走、&lt;code&gt;pause&lt;/code&gt; 不睡）就好了。凡是会睡的代码，睡眠都该能被替换掉。&lt;/p&gt;
&lt;h2 id="六、上线后的样子"&gt;六、上线后的样子&lt;/h2&gt;
&lt;p&gt;部署后当天下午上游还在抽风，日志里能看到新规则在工作：&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-text"&gt;文本上游第 1 次没开始写（忙或排队），2000 ms 后再试: 文本上游流式返回错误: 当前模型访问量较大，请稍后重试
文本上游第 2 次没开始写（忙或排队），4000 ms 后再试: ……
文本上游第 3 次没开始写（忙或排队），8000 ms 后再试: ……
文本上游第 4 次没开始写（忙或排队，已用 32967 ms，剩下的时间不够再写一份）: 文本上游流式返回错误: 服务暂时不可用，请稍后重试
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;第 4 次之后算上 10 秒等待只剩不到 95 秒，按规则放弃。等上游缓过来，同一时段的冰箱想菜（一次短请求）6 秒就回来了。&lt;/p&gt;
&lt;p&gt;要承认的是：&lt;strong&gt;重试只能扛住短时间的抖动&lt;/strong&gt;。上游连续半小时不可用，怎么退避都没用，这时候该做的是让失败便宜一点——失败不扣用户的次数、错误页给一个「再试一次」，而不是在后端死等到 nginx 超时。&lt;/p&gt;
&lt;h2 id="七、顺手修的一个解析问题：forty-eight-克"&gt;七、顺手修的一个解析问题：forty-eight 克&lt;/h2&gt;
&lt;p&gt;同一天截图时发现，一道香酥鸭的「营养参考」显示成这样：&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-text"&gt;720 千卡    forty-ei…    fifty-tw…    twelve 克
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;翻库里的数据：&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-json"&gt;{&amp;quot;calories&amp;quot;:720,&amp;quot;protein&amp;quot;:&amp;quot;forty-eight 克&amp;quot;,&amp;quot;fat&amp;quot;:&amp;quot;fifty-two 克&amp;quot;,&amp;quot;carbs&amp;quot;:&amp;quot;twelve 克&amp;quot;}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;原因在提示词里，营养字段的格式是这么写的：&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-text"&gt;- nutrition：{&amp;quot;calories&amp;quot;:整数,&amp;quot;protein&amp;quot;:&amp;quot;数字 克&amp;quot;,&amp;quot;fat&amp;quot;:&amp;quot;数字 克&amp;quot;,&amp;quot;carbs&amp;quot;:&amp;quot;数字 克&amp;quot;}，每人份；
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;calories&lt;/code&gt; 写的是「整数」，模型老老实实给了 720；三个营养素写的是「数字 克」，模型把「数字」理解成了「用文字写出来的数」。全库只有两道菜中招，但说明这是会复现的。&lt;/p&gt;
&lt;p&gt;两处一起改。提示词不再描述类型，直接给示例：&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-text"&gt;- nutrition：{&amp;quot;calories&amp;quot;:整数,&amp;quot;protein&amp;quot;:&amp;quot;38克&amp;quot;,&amp;quot;fat&amp;quot;:&amp;quot;20克&amp;quot;,&amp;quot;carbs&amp;quot;:&amp;quot;15克&amp;quot;}（数值仅为格式示例，用阿拉伯数字），每人份；
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;解析层再兜一道：取第一个阿拉伯数字；没有的话试着把英文数词换成数字；都不行就置空（页面上这一项不显示，总比显示一串英文强）。&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-java"&gt;private static final Pattern NUMBER = Pattern.compile(&amp;quot;\\d+(?:\\.\\d+)?&amp;quot;);
private static final List&amp;lt;String&amp;gt; EN_UNITS = Arrays.asList(&amp;quot;zero&amp;quot;, &amp;quot;one&amp;quot;, &amp;quot;two&amp;quot;, &amp;quot;three&amp;quot;, &amp;quot;four&amp;quot;, &amp;quot;five&amp;quot;, &amp;quot;six&amp;quot;,
        &amp;quot;seven&amp;quot;, &amp;quot;eight&amp;quot;, &amp;quot;nine&amp;quot;, &amp;quot;ten&amp;quot;, &amp;quot;eleven&amp;quot;, &amp;quot;twelve&amp;quot;, &amp;quot;thirteen&amp;quot;, &amp;quot;fourteen&amp;quot;, &amp;quot;fifteen&amp;quot;, &amp;quot;sixteen&amp;quot;,
        &amp;quot;seventeen&amp;quot;, &amp;quot;eighteen&amp;quot;, &amp;quot;nineteen&amp;quot;);
private static final List&amp;lt;String&amp;gt; EN_TENS = Arrays.asList(&amp;quot;&amp;quot;, &amp;quot;&amp;quot;, &amp;quot;twenty&amp;quot;, &amp;quot;thirty&amp;quot;, &amp;quot;forty&amp;quot;, &amp;quot;fifty&amp;quot;, &amp;quot;sixty&amp;quot;,
        &amp;quot;seventy&amp;quot;, &amp;quot;eighty&amp;quot;, &amp;quot;ninety&amp;quot;);

static String grams(String value) {
    if (value == null) return null;
    Matcher m = NUMBER.matcher(value);
    if (m.find()) return m.group() + &amp;quot;克&amp;quot;;
    Integer words = englishNumber(value);
    return words == null ? null : words + &amp;quot;克&amp;quot;;
}

/** &amp;quot;forty-eight&amp;quot; / &amp;quot;one hundred twenty&amp;quot; → 数字；含不认识的词返回 null。 */
static Integer englishNumber(String value) {
    String v = value.toLowerCase(Locale.ROOT).replace(&amp;quot;克&amp;quot;, &amp;quot; &amp;quot;).replace(&amp;#39;-&amp;#39;, &amp;#39; &amp;#39;).trim();
    if (v.isEmpty()) return null;
    int total = 0;
    boolean any = false;
    for (String word : v.split(&amp;quot;\\s+&amp;quot;)) {
        if (word.isEmpty() || &amp;quot;and&amp;quot;.equals(word) || &amp;quot;g&amp;quot;.equals(word) || &amp;quot;gram&amp;quot;.equals(word) || &amp;quot;grams&amp;quot;.equals(word)) continue;
        int unit = EN_UNITS.indexOf(word);
        int tens = EN_TENS.indexOf(word);
        if (unit &amp;gt;= 0) total += unit;
        else if (tens &amp;gt;= 2) total += tens * 10;
        else if (&amp;quot;hundred&amp;quot;.equals(word)) total = Math.max(1, total) * 100;
        else return null;
        any = true;
    }
    return any ? total : null;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;写第一版时差点埋个雷：想用一个正则 &lt;code&gt;(grams?|g|克|and)&lt;/code&gt; 把单位先替换掉，结果 &lt;code&gt;g&lt;/code&gt; 会把 &lt;code&gt;eight&lt;/code&gt; 里的字母 g 也换掉，变成 &lt;code&gt;ei ht&lt;/code&gt;。改成先按空白切词、再整词比较。单测覆盖了这几种写法：&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-java"&gt;assertEquals(&amp;quot;48克&amp;quot;, RecipeJsonParser.grams(&amp;quot;forty-eight 克&amp;quot;));
assertEquals(&amp;quot;52克&amp;quot;, RecipeJsonParser.grams(&amp;quot;Fifty-two grams&amp;quot;));
assertEquals(&amp;quot;120克&amp;quot;, RecipeJsonParser.grams(&amp;quot;one hundred and twenty g&amp;quot;));
assertEquals(&amp;quot;38克&amp;quot;, RecipeJsonParser.grams(&amp;quot;38 克&amp;quot;));
assertEquals(&amp;quot;12.5克&amp;quot;, RecipeJsonParser.grams(&amp;quot;约12.5g&amp;quot;));
assertEquals(null, RecipeJsonParser.grams(&amp;quot;适量&amp;quot;));
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;线上已有的两条数据用一条带回滚语句的 DML 修正，执行前先 &lt;code&gt;mysqldump&lt;/code&gt; 备份了这两行，&lt;code&gt;UPDATE&lt;/code&gt; 的 &lt;code&gt;WHERE&lt;/code&gt; 里同时带上了旧值，防止误改到别的记录。&lt;/p&gt;
&lt;h2 id="八、几点体会"&gt;八、几点体会&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;超时要分清在量什么。&lt;/strong&gt; 非流式时读超时是「整份写完的时限」，流式时是「两段之间的空闲时限」，总时限得自己掐，而且要比前面 nginx、客户端的超时都短。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;同一个异常可能代表完全不同的处境。&lt;/strong&gt; &lt;code&gt;Read timed out&lt;/code&gt; 既可能是没开始写，也可能是写到一半；能不能重试取决于后者，而这个信息只在读的过程中才有，得自己记下来。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;进度状态放在调用里，不放在对象上。&lt;/strong&gt; 单例 client 被多个线程同时用，局部的 &lt;code&gt;boolean[]&lt;/code&gt; 比字段安全，也比 &lt;code&gt;ThreadLocal&lt;/code&gt; 简单。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;退避和总时限一起算，等待时间也要扣掉。&lt;/strong&gt; 剩下的时间不够写完一份，就别再试，早点失败、失败不扣次数。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;时间和睡眠做成可覆盖的方法。&lt;/strong&gt; 和时间有关的分支才测得出来，测试也不会真睡。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;给模型的格式说明用示例，不用类型名。&lt;/strong&gt; 「数字 克」这种写法对人是格式，对模型可能是内容；解析层永远留一道兜底。&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id="当前实现补充：重试、修复与回退各管一层"&gt;当前实现补充：重试、修复与回退各管一层&lt;/h2&gt;
&lt;p&gt;同一通道内仍按「有没有有效输出」「是否繁忙」「还剩多少时间」决定重试。只有角色字段或 SSE 心跳不算模型已开始输出；心跳能让连接保持活跃，却不能证明模型已经给出正文，所以还需要总预算和结果校验。&lt;/p&gt;
&lt;p&gt;当前结构校验会检查菜谱和菜单等业务结果。无效 JSON、缺少必要内容或不符合约束时，最多在剩余预算内修复一轮；修复提示会带上失败原因，不是把原请求无限重放。合理的业务拒绝直接呈现给用户，不当作传输故障反复生成。&lt;/p&gt;
&lt;p&gt;优先通道不可用时才交给回退通道，文字与图片各自冷却。带显式总预算的方法会扣掉优先通道已经花掉的时间，再把余额交给回退；普通菜谱调用没有这个显式参数，不能据此宣称它也具备完全相同的合计硬时限。生图另行验证返回字节，照片识别则先等微信审核通过，再识别并让用户确认名称、数量和单位。&lt;/p&gt;
&lt;p&gt;对小程序来说，提交后跟踪的是任务，而不是一条一直挂着的请求。返回页面时查询原进度；生成、菜单和食材想菜失败后提供重试，保留原选择，失败不扣对应次数。&lt;/p&gt;</description>
      <pubDate>Mon, 12 Oct 2026 09:45:35 GMT</pubDate>
    </item>
    <item>
      <title>代码块里多出来的那截底色是怎么来的</title>
      <link>https://www.hqxiaozou.top/post/X6Sdgpa9BY0</link>
      <description>&lt;p&gt;前一篇&lt;a href="https://www.hqxiaozou.top/post/oF84qTFBTPW"&gt;《博客长代码块的折叠与展开》&lt;/a&gt;讲了长代码折叠成 10 行、底部加渐隐和「展开全部」条。最近换到流萤主题后，在黑夜模式下截图一看，代码块里多出了两种颜色：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;每一行有字的地方都垫着一块颜色略不同的底，空白处没有；&lt;/li&gt;
&lt;li&gt;折叠时，底部渐隐的末端和「展开全部」那一条是偏蓝的藏青，代码区却是近乎黑的灰。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;白天也有，只是对比小、不明显。两处是同一个原因：组件底座里写死了藏青色，而主题的代码块底色换了。&lt;/p&gt;
&lt;h2 id="-"&gt;一、每行一块底色：行内元素的背景&lt;/h2&gt;
&lt;p&gt;站点的代码块结构是 &lt;code&gt;.code-wrapper &amp;gt; .code-body &amp;gt; pre &amp;gt; code&lt;/code&gt;。底座样式里有这样一条：&lt;/p&gt;
&lt;pre&gt;&lt;code class="lang-css"&gt;&lt;span class="hljs-selector-class"&gt;.code-body&lt;/span&gt;, &lt;span class="hljs-selector-class"&gt;.code-body&lt;/span&gt; &lt;span class="hljs-selector-tag"&gt;pre&lt;/span&gt;, &lt;span class="hljs-selector-class"&gt;.code-body&lt;/span&gt; &lt;span class="hljs-selector-tag"&gt;pre&lt;/span&gt; &lt;span class="hljs-selector-tag"&gt;code&lt;/span&gt;, &lt;span class="hljs-selector-class"&gt;.hljs&lt;/span&gt; {
    &lt;span class="hljs-attribute"&gt;background&lt;/span&gt;: &lt;span class="hljs-number"&gt;#0f1828&lt;/span&gt; &lt;span class="hljs-meta"&gt;!important&lt;/span&gt;;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;后面另一组规则把 &lt;code&gt;.code-body&lt;/code&gt; 改成了主题变量 &lt;code&gt;var(--code-bg)&lt;/code&gt;、把 &lt;code&gt;pre&lt;/code&gt; 改成透明，唯独漏了 &lt;code&gt;code&lt;/code&gt;。&lt;code&gt;code&lt;/code&gt; 是行内元素，行内元素的背景按「行盒」画：每一行只画到这一行最后一个字符，行首的缩进也算在内。于是代码区的底色是主题的，每行文字下面却垫着一条 #0f1828。&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th style="text-align:center"&gt;主题&lt;/th&gt;
&lt;th style="text-align:center"&gt;代码块底色&lt;/th&gt;
&lt;th style="text-align:center"&gt;写死的底色&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style="text-align:center"&gt;流萤白天&lt;/td&gt;
&lt;td style="text-align:center"&gt;#14161f&lt;/td&gt;
&lt;td style="text-align:center"&gt;#0f1828&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style="text-align:center"&gt;流萤黑夜&lt;/td&gt;
&lt;td style="text-align:center"&gt;#0d0f16&lt;/td&gt;
&lt;td style="text-align:center"&gt;#0f1828&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style="text-align:center"&gt;纸墨黑夜&lt;/td&gt;
&lt;td style="text-align:center"&gt;#141210&lt;/td&gt;
&lt;td style="text-align:center"&gt;#0f1828&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style="text-align:center"&gt;星河、格致、画报&lt;/td&gt;
&lt;td style="text-align:center"&gt;#0f1828&lt;/td&gt;
&lt;td style="text-align:center"&gt;#0f1828&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;星河、格致、画报的代码底色本来就是 #0f1828，所以一直看不出来；纸墨黑夜以前单独补过一条透明规则；流萤是新主题，没人补。&lt;/p&gt;
&lt;p&gt;修法是在共享样式里把 &lt;code&gt;pre&lt;/code&gt; 里的 &lt;code&gt;code&lt;/code&gt; 设成透明。两条都带 &lt;code&gt;!important&lt;/code&gt;、选择器优先级也一样时，后写的赢，所以这条要放在写死的那条之后：&lt;/p&gt;
&lt;pre&gt;&lt;code class="lang-css"&gt;&lt;span class="hljs-selector-class"&gt;.code-body&lt;/span&gt; &lt;span class="hljs-selector-tag"&gt;pre&lt;/span&gt; &lt;span class="hljs-selector-tag"&gt;code&lt;/span&gt;, &lt;span class="hljs-selector-class"&gt;.code-body&lt;/span&gt; &lt;span class="hljs-selector-tag"&gt;pre&lt;/span&gt; &lt;span class="hljs-selector-tag"&gt;code&lt;/span&gt;&lt;span class="hljs-selector-class"&gt;.hljs&lt;/span&gt; {
    &lt;span class="hljs-attribute"&gt;background&lt;/span&gt;: transparent &lt;span class="hljs-meta"&gt;!important&lt;/span&gt;;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;纸墨自己补的那条随之删掉。验证时不只看 &lt;code&gt;code&lt;/code&gt;，而是把 &lt;code&gt;pre&lt;/code&gt; 里每个元素的计算背景都取出来，凡是不透明、又和代码块底色不同的都算失败。六套主题深浅色共 12 种组合，改完都只剩代码块本身的底色。&lt;/p&gt;
&lt;h2 id="-"&gt;二、折叠渐隐和展开条：一个颜色，三种写法&lt;/h2&gt;
&lt;p&gt;折叠后的渐隐是 &lt;code&gt;.code-body::after&lt;/code&gt; 上的一层渐变，从透明过渡到代码底色，盖住第 10 行往下的字；下面接「展开全部」条。查层叠时发现，这两样各有三条规则在抢：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;一条用主题变量，但没加 &lt;code&gt;!important&lt;/code&gt;；&lt;/li&gt;
&lt;li&gt;一条写死 #101828，带 &lt;code&gt;!important&lt;/code&gt;；&lt;/li&gt;
&lt;li&gt;一条写死 #0f1828，也带 &lt;code&gt;!important&lt;/code&gt;，写在更后面。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;生效的是最后这条。它的渐隐是这样写的：&lt;/p&gt;
&lt;pre&gt;&lt;code class="lang-css"&gt;&lt;span class="hljs-selector-tag"&gt;background&lt;/span&gt;: &lt;span class="hljs-selector-tag"&gt;linear-gradient&lt;/span&gt;(180&lt;span class="hljs-selector-tag"&gt;deg&lt;/span&gt;, &lt;span class="hljs-selector-tag"&gt;rgba&lt;/span&gt;(15, 24, 40, 0), &lt;span class="hljs-selector-tag"&gt;rgba&lt;/span&gt;(15, 24, 40, &lt;span class="hljs-selector-class"&gt;.72&lt;/span&gt;) 58%, &lt;span class="hljs-selector-id"&gt;#0f1828&lt;/span&gt; 100%) !&lt;span class="hljs-selector-tag"&gt;important&lt;/span&gt;;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;在 58% 的位置就已经 72% 不透明，比一条直线的渐变更快遮住字。我想只换颜色、保留这条曲线，于是需要「主题色加 72% 不透明度」。主题色是变量，里面是十六进制，拆不出三个通道，&lt;code&gt;rgba(var(--code-bg), .72)&lt;/code&gt; 不成立。能用的是 &lt;code&gt;color-mix()&lt;/code&gt;：&lt;/p&gt;
&lt;pre&gt;&lt;code class="lang-css"&gt;&lt;span class="hljs-selector-class"&gt;.code-wrapper&lt;/span&gt;&lt;span class="hljs-selector-class"&gt;.collapsed&lt;/span&gt; &lt;span class="hljs-selector-class"&gt;.code-body&lt;/span&gt;&lt;span class="hljs-selector-pseudo"&gt;::after&lt;/span&gt; {
    &lt;span class="hljs-attribute"&gt;background&lt;/span&gt;: &lt;span class="hljs-built_in"&gt;linear-gradient&lt;/span&gt;(180deg, transparent, color-mix(in srgb, var(--code-bg) &lt;span class="hljs-number"&gt;72%&lt;/span&gt;, transparent) &lt;span class="hljs-number"&gt;58%&lt;/span&gt;, &lt;span class="hljs-built_in"&gt;var&lt;/span&gt;(--code-bg) &lt;span class="hljs-number"&gt;100%&lt;/span&gt;) &lt;span class="hljs-meta"&gt;!important&lt;/span&gt;;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;和透明色按 72% 混合，结果就是原来的颜色、不透明度 0.72。Chrome 算出来的值是 &lt;code&gt;color(srgb 0.0784 0.0863 0.1216 / 0.72)&lt;/code&gt;，三个通道正是流萤白天的 #14161f。&lt;/p&gt;
&lt;h3 id="-"&gt;兜底不能写在前面&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;color-mix()&lt;/code&gt; 要 Chrome 111、Safari 16.2 以后才支持。常见的兜底写法是旧值写在前、新值写在后，不认新语法的浏览器丢掉后一条，前一条照样生效。这里不行：&lt;/p&gt;
&lt;p&gt;一条声明里只要有 &lt;code&gt;var()&lt;/code&gt;，浏览器在解析时就不检查它的值，一律当作有效收下；等到计算样式、把变量代进去时，才发现 &lt;code&gt;color-mix&lt;/code&gt; 不认识。这时这个属性按「计算时无效」处理，退回初始值（背景透明），不会回头去用前面那条声明。结果是旧浏览器上渐隐整个消失，字被直接截断。&lt;/p&gt;
&lt;p&gt;所以兜底要用 &lt;code&gt;@supports&lt;/code&gt; 单独写，并且放在后面：&lt;/p&gt;
&lt;pre&gt;&lt;code class="lang-css"&gt;@&lt;span class="hljs-keyword"&gt;supports&lt;/span&gt; not (background: color-mix(in srgb, red &lt;span class="hljs-number"&gt;50%&lt;/span&gt;, blue)) {
    &lt;span class="hljs-selector-class"&gt;.code-wrapper&lt;/span&gt;&lt;span class="hljs-selector-class"&gt;.collapsed&lt;/span&gt; &lt;span class="hljs-selector-class"&gt;.code-body&lt;/span&gt;&lt;span class="hljs-selector-pseudo"&gt;::after&lt;/span&gt; {
        &lt;span class="hljs-attribute"&gt;background&lt;/span&gt;: &lt;span class="hljs-built_in"&gt;linear-gradient&lt;/span&gt;(180deg, transparent, var(--code-bg) &lt;span class="hljs-number"&gt;100%&lt;/span&gt;) &lt;span class="hljs-meta"&gt;!important&lt;/span&gt;;
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;旧浏览器上曲线变成直线，但颜色是对的。这条规则在新浏览器里永远不生效，平时测不到。验证办法是在测试里把样式表中的 &lt;code&gt;@supports not&lt;/code&gt; 改成 &lt;code&gt;@supports&lt;/code&gt;，让它在 Chrome 里生效，再量一遍：渐隐末端和代码底色一致，计算值里也没有 0.72，说明兜底规则确实接得上。&lt;/p&gt;
&lt;h3 id="-"&gt;悬停色用一个带默认值的变量&lt;/h3&gt;
&lt;p&gt;展开条直接取 &lt;code&gt;var(--code-bg)&lt;/code&gt;。鼠标悬停时原来是 #121e31，比 #0f1828 亮一点、也更蓝一点。如果用 &lt;code&gt;color-mix&lt;/code&gt; 往代码底色里混一点白，流萤是对了，可星河、格致、画报的悬停色也会跟着变，而这三套本来没问题。最后改成一个带默认值的变量：&lt;/p&gt;
&lt;pre&gt;&lt;code class="lang-css"&gt;background: &lt;span class="hljs-keyword"&gt;var&lt;/span&gt;(--code-expand-hover, &lt;span class="hljs-meta"&gt;#121e31) !important;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;只有流萤给了值（白天 #191b25，黑夜 #13151e，按和代码底色的亮度差与原来那一档相近来挑）；其它主题没定义这个变量，仍然用默认的 #121e31。&lt;/p&gt;
&lt;h2 id="-"&gt;三、用像素证明「别的主题没变」&lt;/h2&gt;
&lt;p&gt;改的是六套主题共用的样式，最怕修好了流萤、碰坏了别人。计算样式只能说明某个属性的值，看不出画出来的结果，所以这次直接比截图的像素。&lt;/p&gt;
&lt;p&gt;Playwright 的截图是 PNG，Node 自带 zlib，自己写一个最小的解码器就够用：读出 IHDR 里的宽高，把所有 IDAT 拼起来解压，再按每行开头的过滤类型（无、Sub、Up、Average、Paeth 五种）还原像素，四十行左右。取色时不取单个点，而是在一整行上每隔 5 像素取一次、取出现次数最多的颜色，避开文字的抗锯齿：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;代码底色：&lt;code&gt;pre&lt;/code&gt; 上内边距里的一行，那里没有字；&lt;/li&gt;
&lt;li&gt;渐隐末端：代码区最底下一行；&lt;/li&gt;
&lt;li&gt;展开条：条的左边三成，字在中间。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;改之前先在线上把 12 种组合各截一张（静止和悬停两种状态）；再把线上带哈希的样式表拦下来，换成本地改过的文件，同样截一遍；然后逐像素比较，任一通道差超过 1 就算不同：&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th style="text-align:center"&gt;截图&lt;/th&gt;
&lt;th style="text-align:center"&gt;不同的像素&lt;/th&gt;
&lt;th style="text-align:center"&gt;所在行&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style="text-align:center"&gt;星河、格致、画报&lt;/td&gt;
&lt;td style="text-align:center"&gt;0&lt;/td&gt;
&lt;td style="text-align:center"&gt;—&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style="text-align:center"&gt;纸墨白天、云笺&lt;/td&gt;
&lt;td style="text-align:center"&gt;0&lt;/td&gt;
&lt;td style="text-align:center"&gt;—&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style="text-align:center"&gt;流萤白天&lt;/td&gt;
&lt;td style="text-align:center"&gt;99936&lt;/td&gt;
&lt;td style="text-align:center"&gt;256～366&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style="text-align:center"&gt;流萤黑夜&lt;/td&gt;
&lt;td style="text-align:center"&gt;104356&lt;/td&gt;
&lt;td style="text-align:center"&gt;252～366&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style="text-align:center"&gt;纸墨黑夜&lt;/td&gt;
&lt;td style="text-align:center"&gt;37607&lt;/td&gt;
&lt;td style="text-align:center"&gt;252～320&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;前两行每套都包括深浅色、静止和悬停，全部一个像素不差。流萤的截图高 368，变化只落在最下面 110 来行：渐隐的 72 像素加上 46 像素高的条。纸墨黑夜的条原来就对，只有渐隐变了，所以只到 320 行。&lt;/p&gt;
&lt;h2 id="-"&gt;四、一个假的「点了没反应」&lt;/h2&gt;
&lt;p&gt;测展开以后的「收起代码」条时，脚本报告：点了「展开全部」，代码块还是折叠的，按钮上的字也没变。不拦截样式表、直接在线上跑，结果一样，看上去像是站点本身的 bug。&lt;/p&gt;
&lt;p&gt;在页面里给 document 挂上捕获阶段的监听，把 pointerdown、mousedown、click 和 class 的变化按时间记下来：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;41 ms：click，目标是条里的 span；&lt;/li&gt;
&lt;li&gt;42 ms：代码块的 class 变成 &lt;code&gt;code-wrapper&lt;/code&gt;，collapsed 已经去掉。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;点击是生效的。问题出在测试脚本的定位器上：&lt;/p&gt;
&lt;pre&gt;&lt;code class="lang-js"&gt;&lt;span class="hljs-keyword"&gt;const&lt;/span&gt; wrap = page.locator(&lt;span class="hljs-string"&gt;'.code-wrapper.collapsed'&lt;/span&gt;).first();
&lt;span class="hljs-keyword"&gt;await&lt;/span&gt; wrap.locator(&lt;span class="hljs-string"&gt;'.code-expand-btn'&lt;/span&gt;).click();
&lt;span class="hljs-comment"&gt;// 之后再用 wrap 读状态&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Playwright 的定位器不是元素引用，每次使用都按选择器重新查一遍。第一块展开后不再带 collapsed，「带 collapsed 的第一块」就成了文章里的第二块，量到的是另一块代码。改成先给要测的那块打一个 &lt;code&gt;data-*&lt;/code&gt; 记号、再按记号定位，展开前后就是同一个元素了。&lt;/p&gt;
&lt;h2 id="-ai-"&gt;五、AI 助手里也有一份&lt;/h2&gt;
&lt;p&gt;回归时顺带看了文章页的 AI 助手。它的回答里如果有长代码，也会被同一个脚本折叠，但用的是另一套颜色（One Dark 的 #282c34）。往消息列表里塞一条结构和真实回答一样的消息，调用站点自己的折叠函数，再量一遍：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;白天：代码区、渐隐、展开条都是 #282c34，一致；&lt;/li&gt;
&lt;li&gt;黑夜：代码区被另一条黑夜规则改成了 #171d26，渐隐和展开条还是 #282c34，底部多出一截亮的。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;问题一样，方向反过来：这次是渐隐比代码区亮。在那条黑夜规则旁边补上渐隐和展开条的颜色（#171d26，悬停 #222936），五套新主题的黑夜都量过，一致。&lt;/p&gt;
&lt;h2 id="-"&gt;小结&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;行内元素的背景一行一画，只盖到有字的地方。&lt;code&gt;pre&lt;/code&gt; 里的 &lt;code&gt;code&lt;/code&gt; 带了底色，看到的就是一条条色带。&lt;/li&gt;
&lt;li&gt;主题色一旦是变量，所有用到它的地方都要跟着变量走：底色、渐隐、按钮条、悬停色。漏掉一处，就会在底色不同的主题里露出来。&lt;/li&gt;
&lt;li&gt;带 &lt;code&gt;var()&lt;/code&gt; 的 &lt;code&gt;color-mix()&lt;/code&gt; 在旧浏览器里不会退回前一条声明，而是整条失效。兜底要写在后面的 &lt;code&gt;@supports not&lt;/code&gt; 里，并且要想办法让它在测试里真正跑一次。&lt;/li&gt;
&lt;li&gt;改共享样式时，拿改前改后的截图逐像素比较，可以直接证明没动到的主题一个像素都没变。&lt;/li&gt;
&lt;li&gt;Playwright 的定位器每次都会重新查。选择器里带着会被操作改掉的状态类名时，先打记号再定位。&lt;/li&gt;
&lt;/ul&gt;</description>
      <pubDate>Mon, 05 Oct 2026 00:20:29 GMT</pubDate>
    </item>
    <item>
      <title>零拷贝与 IO 模型等综合面试问答</title>
      <link>https://www.hqxiaozou.top/post/zAiU9jzgz7e</link>
      <description>&lt;p&gt;你好呀，我是小邹。&lt;/p&gt;
&lt;p&gt;面试现场篇又来了，加油，一起卷。&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;聊聊项目，好的设计，好的代码&lt;/li&gt;
&lt;li&gt;谈谈什么是零拷贝？&lt;/li&gt;
&lt;li&gt;一共有几种 IO 模型？NIO 和多路复用的区别？&lt;/li&gt;
&lt;li&gt;Future 实现阻塞等待获取结果的原理？&lt;/li&gt;
&lt;li&gt;ReentrantLock和 Synchronized 的区别？Synchronized 的原理？&lt;/li&gt;
&lt;li&gt;聊聊AQS？ReentrantLock的实现原理？&lt;/li&gt;
&lt;li&gt;乐观锁和悲观锁， 让你来写你怎么实现？&lt;/li&gt;
&lt;li&gt;Paxos 协议了解？工作流程是怎么样的？&lt;/li&gt;
&lt;li&gt;B+树聊一下？B+树是不是有序？B+树和B-树的主要区别？&lt;/li&gt;
&lt;li&gt;TCP的拥塞机制&lt;/li&gt;
&lt;li&gt;工作中有过JVM实践嘛&lt;/li&gt;
&lt;li&gt;数据库分库分表的缺点是啥？&lt;/li&gt;
&lt;li&gt;分布式事务如何解决？TCC 了解？&lt;/li&gt;
&lt;li&gt;RocketMQ 如何保证消息的准确性和安全性？&lt;/li&gt;
&lt;li&gt;算法题：三个数求和&lt;/li&gt;
&lt;/ol&gt;
&lt;h2 id="1-"&gt;1.聊聊项目，好的设计，好的代码&lt;/h2&gt;
&lt;p&gt;项目的话，你可以聊聊你平时做的项目，尤其有亮点的项目。如果没有什么特别亮点的项目，也可以说说一些好的设计，或者你优化了什么接口，性能提升了多少，优化了什么慢SQL都可以。甚至是一些好的代码写法都可以。&lt;/p&gt;
&lt;h2 id="2-"&gt;2. 谈谈什么是零拷贝？&lt;/h2&gt;
&lt;p&gt;零拷贝是指计算机执行IO操作时，CPU不需要将数据从一个存储区域复制到另一个存储区域，从而可以减少上下文切换以及CPU的拷贝时间。它是一种I/O操作优化技术。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;传统 IO 的执行流程&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;传统的IO流程，包括read和write的过程。&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;read：把数据从磁盘读取到内核缓冲区，再拷贝到用户缓冲区&lt;/li&gt;
&lt;li&gt;write：先把数据写入到socket缓冲区，最后写入网卡设备。&lt;/li&gt;
&lt;/ul&gt;

&lt;ol&gt;
&lt;li&gt;用户应用进程调用read函数，向操作系统发起IO调用，上下文从用户态转为内核态（切换1）&lt;/li&gt;
&lt;li&gt;DMA控制器把数据从磁盘中，读取到内核缓冲区。&lt;/li&gt;
&lt;li&gt;CPU把内核缓冲区数据，拷贝到用户应用缓冲区，上下文从内核态转为用户态（切换2），read函数返回&lt;/li&gt;
&lt;li&gt;用户应用进程通过write函数，发起IO调用，上下文从用户态转为内核态（切换3）&lt;/li&gt;
&lt;li&gt;CPU将用户缓冲区中的数据，拷贝到socket缓冲区&lt;/li&gt;
&lt;li&gt;DMA控制器把数据从socket缓冲区，拷贝到网卡设备，上下文从内核态切换回用户态（切换4），write函数返回&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;传统IO的读写流程，包括了&lt;strong&gt;4次上下文切换&lt;/strong&gt;（4次用户态和内核态的切换），&lt;strong&gt;4次数据拷贝&lt;/strong&gt;（两次CPU拷贝以及两次的DMA拷贝)。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;零拷贝实现方式&lt;/strong&gt;：&lt;/p&gt;
&lt;p&gt;零拷贝并不是没有拷贝数据，而是减少用户态/内核态的切换次数以及CPU拷贝的次数。零拷贝一般有这三种实现方式：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;mmap+write&lt;/li&gt;
&lt;li&gt;sendfile&lt;/li&gt;
&lt;li&gt;带有DMA收集拷贝功能的sendfile&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;mmap+write&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;mmap就是用了虚拟内存这个特点，它将内核中的读缓冲区与用户空间的缓冲区进行映射，以减少数据拷贝次数！&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;用户进程通过mmap方法向操作系统内核发起IO调用，上下文从用户态切换为内核态。&lt;/li&gt;
&lt;li&gt;CPU利用DMA控制器，把数据从硬盘中拷贝到内核缓冲区。&lt;/li&gt;
&lt;li&gt;上下文从内核态切换回用户态，mmap方法返回。&lt;/li&gt;
&lt;li&gt;用户进程通过write方法向操作系统内核发起IO调用，上下文从用户态切换为内核态。&lt;/li&gt;
&lt;li&gt;CPU将内核缓冲区的数据拷贝到的socket缓冲区。&lt;/li&gt;
&lt;li&gt;CPU利用DMA控制器，把数据从socket缓冲区拷贝到网卡，上下文从内核态切换回用户态，write调用返回。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;mmap+write实现的零拷贝，I/O发生了4次用户空间与内核空间的上下文切换，以及3次数据拷贝（包括了2次DMA拷贝和1次CPU拷贝）。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;sendfile&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;sendfile表示在两个文件描述符之间传输数据，它是在操作系统内核中操作的，避免了数据从内核缓冲区和用户缓冲区之间的拷贝操作&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;用户进程发起sendfile系统调用，上下文（切换1）从用户态转向内核态&lt;/li&gt;
&lt;li&gt;DMA控制器，把数据从硬盘中拷贝到内核缓冲区。&lt;/li&gt;
&lt;li&gt;CPU将读缓冲区中数据拷贝到socket缓冲区&lt;/li&gt;
&lt;li&gt;DMA控制器，异步把数据从socket缓冲区拷贝到网卡，&lt;/li&gt;
&lt;li&gt;上下文（切换2）从内核态切换回用户态，sendfile调用返回。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;sendfile实现的零拷贝，I/O发生了2次用户空间与内核空间的上下文切换，以及3次数据拷贝。其中3次数据拷贝中，包括了2次DMA拷贝和1次CPU拷贝。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;带有DMA收集拷贝功能的sendfile&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;linux 2.4版本之后，对sendfile做了优化升级，引入SG-DMA技术，其实就是对DMA拷贝加入了scatter/gather操作，它可以直接从内核空间缓冲区中将数据读取到网卡。使用这个特点搞零拷贝，即还可以多省去一次CPU拷贝。&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;用户进程发起sendfile系统调用，上下文（切换1）从用户态转向内核态&lt;/li&gt;
&lt;li&gt;DMA控制器，把数据从硬盘中拷贝到内核缓冲区。&lt;/li&gt;
&lt;li&gt;CPU把内核缓冲区中的文件描述符信息（包括内核缓冲区的内存地址和偏移量）发送到socket缓冲区&lt;/li&gt;
&lt;li&gt;DMA控制器根据文件描述符信息，直接把数据从内核缓冲区拷贝到网卡&lt;/li&gt;
&lt;li&gt;上下文（切换2）从内核态切换回用户态，sendfile调用返回。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;可以发现，&lt;strong&gt;sendfile+DMA scatter/gather&lt;/strong&gt;实现的零拷贝，I/O发生了2次用户空间与内核空间的上下文切换，以及2次数据拷贝。其中2次数据拷贝都是包DMA拷贝。这就是真正的 零拷贝（Zero-copy) 技术，全程都没有通过CPU来搬运数据，所有的数据都是通过DMA来进行传输的。&lt;/p&gt;
&lt;p&gt;&lt;a href="http://www.hqxiaozou.top/post/CTC3NJU95HN"&gt;一文看懂零拷贝原理&lt;/a&gt;&lt;/p&gt;
&lt;h2 id="3-io-nio-"&gt;3. 一共有几种 IO 模型？NIO 和多路复用的区别？&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;一共有五种IO模型&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;阻塞IO模型&lt;/li&gt;
&lt;li&gt;非阻塞IO模型&lt;/li&gt;
&lt;li&gt;IO多路复用模型&lt;/li&gt;
&lt;li&gt;IO模型之信号驱动模型&lt;/li&gt;
&lt;li&gt;IO 模型之异步IO(AIO)&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;NIO（非阻塞IO模型）&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;NIO，即Non-Blocking IO，是非阻塞IO模型。非阻塞IO的流程如下：&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;应用进程向操作系统内核，发起recvfrom读取数据。&lt;/li&gt;
&lt;li&gt;操作系统内核数据没有准备好，立即返回EWOULDBLOCK错误码。&lt;/li&gt;
&lt;li&gt;应用程序进程轮询调用，继续向操作系统内核发起recvfrom读取数据。&lt;/li&gt;
&lt;li&gt;操作系统内核数据准备好了，从内核缓冲区拷贝到用户空间。&lt;/li&gt;
&lt;li&gt;完成调用，返回成功提示。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;NIO(非阻塞IO模型)存在性能问题，即频繁的轮询，导致频繁的系统调用，同样会消耗大量的CPU资源。可以考虑IO复用模型去解决这个问题。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;IO多路复用模型&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;IO多路复用就是，等到内核数据准备好了，主动通知应用进程再去进行系统调用。&lt;/p&gt;
&lt;p&gt;IO复用模型核心思路：系统给我们提供一类函数（如我们耳濡目染的select、poll、epoll函数），它们可以同时监控多个fd的操作，任何一个返回内核数据就绪，应用进程再发起recvfrom系统调用。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;IO多路复用之select&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;应用进程通过调用select函数，可以同时监控多个fd，在select函数监控的fd中，只要有任何一个数据状态准备就绪了，select函数就会返回可读状态，这时应用进程再发起recvfrom请求去读取数据。&lt;/p&gt;

&lt;p&gt;非阻塞IO模型（NIO）中，需要N（N&amp;gt;=1）次轮询系统调用，然而借助select的IO多路复用模型，只需要发起一次询问就够了,大大优化了性能。&lt;/p&gt;
&lt;p&gt;但是呢，select有几个缺点：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;监听的IO最大连接数有限，在Linux系统上一般为1024。&lt;/li&gt;
&lt;li&gt;select函数返回后，是通过遍历fdset，找到就绪的描述符fd。（仅知道有I/O事件发生，却不知是哪几个流，所以遍历所有流）&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;因为存在连接数限制，所以后来又提出了poll。与select相比，poll解决了连接数限制问题。但是呢，select和poll一样，还是需要通过遍历文件描述符来获取已经就绪的socket。如果同时连接的大量客户端，在一时刻可能只有极少处于就绪状态，伴随着监视的描述符数量的增长，效率也会线性下降。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;IO多路复用之epoll&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;为了解决select/poll存在的问题，多路复用模型epoll诞生，它采用事件驱动来实现，流程图如下：&lt;/p&gt;

&lt;p&gt;epoll先通过epoll_ctl()来注册一个fd（文件描述符），一旦基于某个fd就绪时，内核会采用回调机制，迅速激活这个fd，当进程调用epoll_wait()时便得到通知。这里去掉了遍历文件描述符的坑爹操作，而是采用监听事件回调的机制。这就是epoll的亮点。&lt;/p&gt;
&lt;h2 id="4-future-"&gt;4. Future 实现阻塞等待获取结果的原理？&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;Future.get()&lt;/code&gt;用于异步结果的获取。它是阻塞的，背后原理是什么呢？&lt;/p&gt;
&lt;p&gt;我们可以看下&lt;code&gt;FutureTask&lt;/code&gt;的类结构图：&lt;/p&gt;

&lt;p&gt;FutureTask实现了&lt;code&gt;RunnableFuture&lt;/code&gt;接口，&lt;code&gt;RunnableFuture&lt;/code&gt;继承了&lt;code&gt;Runnable和Future&lt;/code&gt;这两个接口， 对于Runnable，我们太熟悉了， 那么Future呢？&lt;/p&gt;
&lt;p&gt;Future 表示一个任务的生命周期，并提供了相应的方法来判断是否已经完成或取消，以及获取任务的结果和取消任务等。&lt;/p&gt;
&lt;pre&gt;&lt;code class="lang-java"&gt;&lt;span class="hljs-keyword"&gt;public&lt;/span&gt; &lt;span class="hljs-class"&gt;&lt;span class="hljs-keyword"&gt;interface&lt;/span&gt; &lt;span class="hljs-title"&gt;Future&lt;/span&gt;&amp;lt;&lt;span class="hljs-title"&gt;V&lt;/span&gt;&amp;gt; &lt;/span&gt;{

    &lt;span class="hljs-function"&gt;&lt;span class="hljs-keyword"&gt;boolean&lt;/span&gt; &lt;span class="hljs-title"&gt;cancel&lt;/span&gt;&lt;span class="hljs-params"&gt;(&lt;span class="hljs-keyword"&gt;boolean&lt;/span&gt; mayInterruptIfRunning)&lt;/span&gt;&lt;/span&gt;;
    &lt;span class="hljs-comment"&gt;//Future 是否被取消&lt;/span&gt;
    &lt;span class="hljs-function"&gt;&lt;span class="hljs-keyword"&gt;boolean&lt;/span&gt; &lt;span class="hljs-title"&gt;isCancelled&lt;/span&gt;&lt;span class="hljs-params"&gt;()&lt;/span&gt;&lt;/span&gt;;
    &lt;span class="hljs-comment"&gt;//当前 Future 是否已结束&lt;/span&gt;
    &lt;span class="hljs-function"&gt;&lt;span class="hljs-keyword"&gt;boolean&lt;/span&gt; &lt;span class="hljs-title"&gt;isDone&lt;/span&gt;&lt;span class="hljs-params"&gt;()&lt;/span&gt;&lt;/span&gt;;
    &lt;span class="hljs-comment"&gt;//或取Future的结果值。如果当前 Future 还没有结束，当前线程阻塞等待，&lt;/span&gt;
    &lt;span class="hljs-function"&gt;V &lt;span class="hljs-title"&gt;get&lt;/span&gt;&lt;span class="hljs-params"&gt;()&lt;/span&gt; &lt;span class="hljs-keyword"&gt;throws&lt;/span&gt; InterruptedException, ExecutionException&lt;/span&gt;;
    &lt;span class="hljs-comment"&gt;//获取 Future 的结果值。与 get()一样，不过多了超时时间设置&lt;/span&gt;
    &lt;span class="hljs-function"&gt;V &lt;span class="hljs-title"&gt;get&lt;/span&gt;&lt;span class="hljs-params"&gt;(&lt;span class="hljs-keyword"&gt;long&lt;/span&gt; timeout, TimeUnit unit)&lt;/span&gt;
        &lt;span class="hljs-keyword"&gt;throws&lt;/span&gt; InterruptedException, ExecutionException, TimeoutException&lt;/span&gt;;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;FutureTask 就是&lt;code&gt;Runnable和Future&lt;/code&gt;的结合体，我们可以把&lt;code&gt;Runnable&lt;/code&gt;看作生产者， Future &lt;code&gt;看作&lt;/code&gt;消费者。而FutureTask 是被这两者共享的，生产者运行&lt;code&gt;run&lt;/code&gt;方法计算结果，消费者通过&lt;code&gt;get&lt;/code&gt;方法获取结果。&lt;/p&gt;
&lt;p&gt;生产者消费者模式，如果生产者数据还没准备的时候，消费者会被阻塞。当生产者数据准备好了以后会唤醒消费者继续执行。我们来看下FutureTask内部是如何实现的。&lt;/p&gt;
&lt;pre&gt;&lt;code class="lang-java"&gt;FutureTask`内部维护了任务状态`state
&lt;span class="hljs-comment"&gt;//NEW 新建状态,表示FutureTask新建还没开始执行&lt;/span&gt;
&lt;span class="hljs-keyword"&gt;private&lt;/span&gt; &lt;span class="hljs-keyword"&gt;static&lt;/span&gt; &lt;span class="hljs-keyword"&gt;final&lt;/span&gt; &lt;span class="hljs-keyword"&gt;int&lt;/span&gt; NEW          = &lt;span class="hljs-number"&gt;0&lt;/span&gt;;
&lt;span class="hljs-comment"&gt;//完成状态,表示FutureTask&lt;/span&gt;
&lt;span class="hljs-keyword"&gt;private&lt;/span&gt; &lt;span class="hljs-keyword"&gt;static&lt;/span&gt; &lt;span class="hljs-keyword"&gt;final&lt;/span&gt; &lt;span class="hljs-keyword"&gt;int&lt;/span&gt; COMPLETING   = &lt;span class="hljs-number"&gt;1&lt;/span&gt;;
&lt;span class="hljs-comment"&gt;//任务正常完成，没有发生异常&lt;/span&gt;
&lt;span class="hljs-keyword"&gt;private&lt;/span&gt; &lt;span class="hljs-keyword"&gt;static&lt;/span&gt; &lt;span class="hljs-keyword"&gt;final&lt;/span&gt; &lt;span class="hljs-keyword"&gt;int&lt;/span&gt; NORMAL       = &lt;span class="hljs-number"&gt;2&lt;/span&gt;;
&lt;span class="hljs-comment"&gt;//发生异常&lt;/span&gt;
&lt;span class="hljs-keyword"&gt;private&lt;/span&gt; &lt;span class="hljs-keyword"&gt;static&lt;/span&gt; &lt;span class="hljs-keyword"&gt;final&lt;/span&gt; &lt;span class="hljs-keyword"&gt;int&lt;/span&gt; EXCEPTIONAL  = &lt;span class="hljs-number"&gt;3&lt;/span&gt;;
&lt;span class="hljs-comment"&gt;//取消任务&lt;/span&gt;
&lt;span class="hljs-keyword"&gt;private&lt;/span&gt; &lt;span class="hljs-keyword"&gt;static&lt;/span&gt; &lt;span class="hljs-keyword"&gt;final&lt;/span&gt; &lt;span class="hljs-keyword"&gt;int&lt;/span&gt; CANCELLED    = &lt;span class="hljs-number"&gt;4&lt;/span&gt;;
&lt;span class="hljs-comment"&gt;//发起中断请求&lt;/span&gt;
&lt;span class="hljs-keyword"&gt;private&lt;/span&gt; &lt;span class="hljs-keyword"&gt;static&lt;/span&gt; &lt;span class="hljs-keyword"&gt;final&lt;/span&gt; &lt;span class="hljs-keyword"&gt;int&lt;/span&gt; INTERRUPTING = &lt;span class="hljs-number"&gt;5&lt;/span&gt;;
&lt;span class="hljs-comment"&gt;//中断请求完成&lt;/span&gt;
&lt;span class="hljs-keyword"&gt;private&lt;/span&gt; &lt;span class="hljs-keyword"&gt;static&lt;/span&gt; &lt;span class="hljs-keyword"&gt;final&lt;/span&gt; &lt;span class="hljs-keyword"&gt;int&lt;/span&gt; INTERRUPTED  = &lt;span class="hljs-number"&gt;6&lt;/span&gt;;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;生产者run方法：&lt;/p&gt;
&lt;pre&gt;&lt;code class="lang-java"&gt; &lt;span class="hljs-function"&gt;&lt;span class="hljs-keyword"&gt;public&lt;/span&gt; &lt;span class="hljs-keyword"&gt;void&lt;/span&gt; &lt;span class="hljs-title"&gt;run&lt;/span&gt;(&lt;span class="hljs-params"&gt;&lt;/span&gt;) &lt;/span&gt;{
        &lt;span class="hljs-comment"&gt;// 如果状态state不是 NEW，或者设置 runner 值失败,直接返回&lt;/span&gt;
        &lt;span class="hljs-keyword"&gt;if&lt;/span&gt; (state != NEW ||
            !UNSAFE.compareAndSwapObject(&lt;span class="hljs-keyword"&gt;this&lt;/span&gt;, runnerOffset,
                                         &lt;span class="hljs-literal"&gt;null&lt;/span&gt;, Thread.currentThread()))
            &lt;span class="hljs-keyword"&gt;return&lt;/span&gt;;
        &lt;span class="hljs-keyword"&gt;try&lt;/span&gt; {
            Callable&amp;lt;V&amp;gt; c = callable;
            &lt;span class="hljs-keyword"&gt;if&lt;/span&gt; (c != &lt;span class="hljs-literal"&gt;null&lt;/span&gt; &amp;amp;&amp;amp; state == NEW) {
                V result;
                boolean ran;
                &lt;span class="hljs-keyword"&gt;try&lt;/span&gt; {
                    &lt;span class="hljs-comment"&gt;//调用callable的call方法，获取结果&lt;/span&gt;
                    result = c.call();
                    &lt;span class="hljs-comment"&gt;//运行成功&lt;/span&gt;
                    ran = &lt;span class="hljs-literal"&gt;true&lt;/span&gt;;
                } &lt;span class="hljs-keyword"&gt;catch&lt;/span&gt; (Throwable ex) {
                    result = &lt;span class="hljs-literal"&gt;null&lt;/span&gt;;
                    &lt;span class="hljs-comment"&gt;//运行不成功&lt;/span&gt;
                    ran = &lt;span class="hljs-literal"&gt;false&lt;/span&gt;;
                    &lt;span class="hljs-comment"&gt;//设置异常&lt;/span&gt;
                    setException(ex);
                }
                &lt;span class="hljs-comment"&gt;//运行成功设置返回结果&lt;/span&gt;
                &lt;span class="hljs-keyword"&gt;if&lt;/span&gt; (ran)
                    &lt;span class="hljs-keyword"&gt;set&lt;/span&gt;(result);
            }
        } &lt;span class="hljs-keyword"&gt;finally&lt;/span&gt; {
            runner = &lt;span class="hljs-literal"&gt;null&lt;/span&gt;;
            &lt;span class="hljs-keyword"&gt;int&lt;/span&gt; s = state;
            &lt;span class="hljs-keyword"&gt;if&lt;/span&gt; (s &amp;gt;= INTERRUPTING)
                handlePossibleCancellationInterrupt(s);
        }
    }
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;消费者的&lt;code&gt;get&lt;/code&gt;方法&lt;/p&gt;
&lt;pre&gt;&lt;code class="lang-java"&gt; &lt;span class="hljs-function"&gt;&lt;span class="hljs-keyword"&gt;public&lt;/span&gt; V &lt;span class="hljs-title"&gt;get&lt;/span&gt;&lt;span class="hljs-params"&gt;()&lt;/span&gt; &lt;span class="hljs-keyword"&gt;throws&lt;/span&gt; InterruptedException, ExecutionException &lt;/span&gt;{
     &lt;span class="hljs-keyword"&gt;int&lt;/span&gt; s = state;
     &lt;span class="hljs-comment"&gt;//如果状态小于等于 COMPLETING，表示 FutureTask 任务还没有完成， 则调用awaitDone让当前线程等待。&lt;/span&gt;
     &lt;span class="hljs-keyword"&gt;if&lt;/span&gt; (s &amp;lt;= COMPLETING)
         s = awaitDone(&lt;span class="hljs-keyword"&gt;false&lt;/span&gt;, &lt;span class="hljs-number"&gt;0L&lt;/span&gt;);
     &lt;span class="hljs-keyword"&gt;return&lt;/span&gt; report(s);
 }
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;awaitDone做了什么事情呢？&lt;/p&gt;
&lt;pre&gt;&lt;code class="lang-java"&gt; &lt;span class="hljs-function"&gt;&lt;span class="hljs-keyword"&gt;private&lt;/span&gt; &lt;span class="hljs-keyword"&gt;int&lt;/span&gt; &lt;span class="hljs-title"&gt;awaitDone&lt;/span&gt;&lt;span class="hljs-params"&gt;(&lt;span class="hljs-keyword"&gt;boolean&lt;/span&gt; timed, &lt;span class="hljs-keyword"&gt;long&lt;/span&gt; nanos)&lt;/span&gt;
        &lt;span class="hljs-keyword"&gt;throws&lt;/span&gt; InterruptedException &lt;/span&gt;{
        &lt;span class="hljs-keyword"&gt;final&lt;/span&gt; &lt;span class="hljs-keyword"&gt;long&lt;/span&gt; deadline = timed ? System.nanoTime() + nanos : &lt;span class="hljs-number"&gt;0L&lt;/span&gt;;
        WaitNode q = &lt;span class="hljs-keyword"&gt;null&lt;/span&gt;;
        &lt;span class="hljs-keyword"&gt;boolean&lt;/span&gt; queued = &lt;span class="hljs-keyword"&gt;false&lt;/span&gt;;
        &lt;span class="hljs-keyword"&gt;for&lt;/span&gt; (;;) {
            &lt;span class="hljs-comment"&gt;// 如果当前线程是中断标记，则  &lt;/span&gt;
            &lt;span class="hljs-keyword"&gt;if&lt;/span&gt; (Thread.interrupted()) {
                &lt;span class="hljs-comment"&gt;//那么从列表中移除节点 q，并抛出 InterruptedException 异常&lt;/span&gt;
                removeWaiter(q);
                &lt;span class="hljs-keyword"&gt;throw&lt;/span&gt; &lt;span class="hljs-keyword"&gt;new&lt;/span&gt; InterruptedException();
            }

            &lt;span class="hljs-keyword"&gt;int&lt;/span&gt; s = state;
            &lt;span class="hljs-comment"&gt;//如果状态已经完成，表示FutureTask任务已结束&lt;/span&gt;
            &lt;span class="hljs-keyword"&gt;if&lt;/span&gt; (s &amp;gt; COMPLETING) {
                &lt;span class="hljs-keyword"&gt;if&lt;/span&gt; (q != &lt;span class="hljs-keyword"&gt;null&lt;/span&gt;)
                    q.thread = &lt;span class="hljs-keyword"&gt;null&lt;/span&gt;;
                &lt;span class="hljs-comment"&gt;//返回&lt;/span&gt;
                &lt;span class="hljs-keyword"&gt;return&lt;/span&gt; s;
            }
            &lt;span class="hljs-comment"&gt;// 表示还有一些后序操作没有完成，那么当前线程让出执行权&lt;/span&gt;
            &lt;span class="hljs-keyword"&gt;else&lt;/span&gt; &lt;span class="hljs-keyword"&gt;if&lt;/span&gt; (s == COMPLETING) &lt;span class="hljs-comment"&gt;// cannot time out yet&lt;/span&gt;
                Thread.yield();
            &lt;span class="hljs-comment"&gt;//将当前线程阻塞等待&lt;/span&gt;
            &lt;span class="hljs-keyword"&gt;else&lt;/span&gt; &lt;span class="hljs-keyword"&gt;if&lt;/span&gt; (q == &lt;span class="hljs-keyword"&gt;null&lt;/span&gt;)
                q = &lt;span class="hljs-keyword"&gt;new&lt;/span&gt; WaitNode();
            &lt;span class="hljs-keyword"&gt;else&lt;/span&gt; &lt;span class="hljs-keyword"&gt;if&lt;/span&gt; (!queued)
                queued = UNSAFE.compareAndSwapObject(&lt;span class="hljs-keyword"&gt;this&lt;/span&gt;, waitersOffset,
                                                     q.next = waiters, q);
            &lt;span class="hljs-comment"&gt;//timed 为 true 表示需要设置超时                                        &lt;/span&gt;
            &lt;span class="hljs-keyword"&gt;else&lt;/span&gt; &lt;span class="hljs-keyword"&gt;if&lt;/span&gt; (timed) {
                nanos = deadline - System.nanoTime();
                &lt;span class="hljs-keyword"&gt;if&lt;/span&gt; (nanos &amp;lt;= &lt;span class="hljs-number"&gt;0L&lt;/span&gt;) {
                    removeWaiter(q);
                    &lt;span class="hljs-keyword"&gt;return&lt;/span&gt; state;
                }
                &lt;span class="hljs-comment"&gt;//让当前线程等待 nanos 时间&lt;/span&gt;
                LockSupport.parkNanos(&lt;span class="hljs-keyword"&gt;this&lt;/span&gt;, nanos);
            }
            &lt;span class="hljs-keyword"&gt;else&lt;/span&gt;
                LockSupport.park(&lt;span class="hljs-keyword"&gt;this&lt;/span&gt;);
        }
    }
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;当然，面试的时候，不一定要讲到源码这么细，只需要讲个大概思路就好啦。&lt;/p&gt;
&lt;h2 id="5-reentrantlock-synchronized-synchronized-"&gt;5. ReentrantLock和Synchronized的区别？Synchronized 原理？&lt;/h2&gt;
&lt;p&gt;ReentrantLock和 Synchronized 的区别？&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Synchronized是依赖于JVM实现的，而ReenTrantLock是API实现的。&lt;/li&gt;
&lt;li&gt;在Synchronized优化以前，synchronized的性能是比ReenTrantLock差很多的，但是自从Synchronized引入了偏向锁，轻量级锁（自旋锁）后，两者性能就差不多了。&lt;/li&gt;
&lt;li&gt;Synchronized的使用比较方便简洁，它由编译器去保证锁的加锁和释放。而ReenTrantLock需要手工声明来加锁和释放锁，最好在finally中声明释放锁。&lt;/li&gt;
&lt;li&gt;ReentrantLock可以指定是公平锁还是⾮公平锁。⽽synchronized只能是⾮公平锁。&lt;/li&gt;
&lt;li&gt;ReentrantLock可响应中断、可轮回，而Synchronized是不可以响应中断的，&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;至于&lt;strong&gt;Synchronized的原理&lt;/strong&gt;，大家可以看一下这篇文章哈&lt;/p&gt;
&lt;p&gt;&lt;a href="http://www.hqxiaozou.top/post/F58B5lphwoy#synchronized-"&gt;Synchronized实现原理&lt;/a&gt;&lt;/p&gt;
&lt;h2 id="6-aqs-reentrantlock-"&gt;6. 聊聊AQS？ReentrantLock的实现原理？&lt;/h2&gt;
&lt;p&gt;AQS（抽象同步队列）的核心回答要点就是：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;state 状态的维护。&lt;/li&gt;
&lt;li&gt;CLH队列&lt;/li&gt;
&lt;li&gt;ConditionObject通知&lt;/li&gt;
&lt;li&gt;模板方法设计模式&lt;/li&gt;
&lt;li&gt;独占与共享模式。&lt;/li&gt;
&lt;li&gt;自定义同步器。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;大家可以看下我之前这篇文章哈：&lt;a href="http://www.hqxiaozou.top/post/S2ZupgMXIKL"&gt;AQS源码分析&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;大家综合ReentrantLock的功能，比如可重入，公平锁，非公平锁等，与AQS结合一起讲就好啦。&lt;/p&gt;
&lt;h2 id="7-"&gt;7. 乐观锁和悲观锁， 让你来写你怎么实现？&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;悲观锁：&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;悲观锁她专一且缺乏安全感了，她的心只属于当前线程，每时每刻都担心着它心爱的数据可能被别的线程修改。因此一个线程拥有（获得）悲观锁后，其他任何线程都不能对数据进行修改啦，只能等待锁被释放才可以执行。&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;SQL语句&lt;code&gt;select ...for update&lt;/code&gt;就是悲观锁的一种实现&lt;/li&gt;
&lt;li&gt;还有Java的synchronized关键字也是悲观锁的一种体现&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;乐观锁：&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;乐观锁的很乐观，它认为数据的变动不会太频繁,操作时一般都不会产生并发问题。因此，它不会上锁，只是在更新数据时，再去判断其他线程在这之前有没有对数据进行过修改。&lt;strong&gt;实现方式&lt;/strong&gt;：乐观锁一般会使用&lt;strong&gt;版本号机制&lt;/strong&gt;或CAS算法实现。&lt;/p&gt;

&lt;h2 id="8-paxos-"&gt;8. Paxos 协议了解？工作流程是怎么样的？&lt;/h2&gt;
&lt;h3 id="8-1-paxos-"&gt;8.1 为什么需要Paxos算法？&lt;/h3&gt;
&lt;p&gt;当前我们应用都是集群部署的，要求所有机器状态一致。假设当前有两台机器A和B，A要把状态修改为a，B要把状态修改为b，那么应该听谁的呢？这时候可以像2PC一样，引入一个协调者，谁最先到就听谁的。&lt;/p&gt;

&lt;p&gt;这里有个问题，就是协调者是单节点，如果它挂了呢。因为可以引入多个协调者&lt;/p&gt;

&lt;p&gt;但是这么多协调者，应该听谁的呢？&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;引入Paxos算法解决这个问题，Paxos算法是一种基于消息传递的分布式一致性算法。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h3 id="8-2-paxos-"&gt;8.2  Paxos的角色&lt;/h3&gt;
&lt;p&gt;Paxos涉及三种角色，分别是Proposer、Accecptor 、Learners。&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Proposer：它可以提出提案 (Proposal)，提案信息包括&lt;strong&gt;提案编号和提案值&lt;/strong&gt;。&lt;/li&gt;
&lt;li&gt;Acceptor：接受接受（accept）提案。一旦接受提案，提案里面的提案值（可以用V表示）就被选定了。&lt;/li&gt;
&lt;li&gt;Learner: 哪个提案被选定了, Learner就学习这个被选择的提案&lt;/li&gt;
&lt;/ul&gt;
&lt;blockquote&gt;
&lt;p&gt;一个进程可能是Proposer,也可能是Acceptor，也可能是Learner。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h3 id="8-2-paxos-"&gt;8.2 Paxos算法推导过程&lt;/h3&gt;
&lt;p&gt;一致性算法需要&lt;strong&gt;前置条件&lt;/strong&gt;&lt;/p&gt;
&lt;blockquote&gt;
&lt;ul&gt;
&lt;li&gt;在这些被提出的提案中，只有一个会被选定&lt;/li&gt;
&lt;li&gt;如果没有提案被提出，就不应该有被选定的提案 -当提案被选定后，learner可以学习被选中的提案&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;
&lt;p&gt;假设只有一个Acceptor，只要Acceptor接受它收到的第一个提案，就可以保证只有一个value会被选定。但是这个 Acceptor 宕机，会导致整个系统不可用。&lt;/p&gt;

&lt;p&gt;如果是是多个Proposer和多个Acceptor，如何选定一个提案呢？&lt;/p&gt;

&lt;p&gt;我们可以加个约定条件，&lt;strong&gt;假设就叫约束P1&lt;/strong&gt;：&lt;strong&gt;一个 Acceptor 必须接受它收到的第一个提案&lt;/strong&gt;。但是这样还是可能会有问题，如果每个Proposer分别提出不同的value（如下图V1，V2，V3），发给了不同的Acceptor，最后会导致不同的value被选择。&lt;/p&gt;

&lt;p&gt;我们可以给多一个额外的约定&lt;strong&gt;P1a:一个提案被选定，需要被半数以上 Acceptor 接受&lt;/strong&gt;。这跟&lt;strong&gt;P1&lt;/strong&gt;有点矛盾啦，我们可以使用一个全局的编号来标识每一个Acceptor批准的提案，当一个具有某value值的提案被&lt;strong&gt;半数以上的Acceptor&lt;/strong&gt;批准后，我们就认为该value被选定了。即提案P= 提案参数 + 提案值，可以记为【M,V】。&lt;/p&gt;
&lt;p&gt;现在可以允许多个提案被选定，但必须保证所有被选定的提案都具有相同的value值。要不然又会出现不一致啦。因此可以再加个约束P2：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;如果提案 P[M1,V1] 被选定了，那么所有比M1编号更高的被选定提案P，其 &lt;span class="hljs-keyword"&gt;value&lt;/span&gt; 的值也必须是 V1。
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;一个提案要被选定，至少要被一个 Acceptor 接受，因此我们可以把P2约束改成对Acceptor接受的约束P2a：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt; 如果提案 &lt;span class="hljs-selector-tag"&gt;P&lt;/span&gt;&lt;span class="hljs-selector-attr"&gt;[M1,V1]&lt;/span&gt; 被接受了，那么所有比&lt;span class="hljs-selector-tag"&gt;M1&lt;/span&gt;编号更高的，且被&lt;span class="hljs-selector-tag"&gt;Acceptor&lt;/span&gt;接受的&lt;span class="hljs-selector-tag"&gt;P&lt;/span&gt;，其值也是 &lt;span class="hljs-selector-tag"&gt;V1&lt;/span&gt;。
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;多提案被选择的问题解决了，但是如果是网络不稳定或者宕机的原因，还是会有问题。&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;假设有 5 个 Acceptor。Proposer2 提出 [M1,V1]的提案，Acceptor2~5（半数以上）均接受了该提案，于是对于 Acceptor2~5 和 Proposer2 来讲，它们都认为 V1 被选定。Acceptor1 刚刚从 宕机状态 恢复过来（之前 Acceptor1 没有收到过任何提案），此时 Proposer1 向 Acceptor1 发送了 [M2,V2] 的提案 （V2≠V1且M2&amp;gt;M1）。对于 Acceptor1 来讲，这是它收到的 第一个提案。根据 P1（一个 Acceptor 必须接受它收到的 第一个提案），Acceptor1 必须接受该提案。同时 Acceptor1 认为 V2 被选定。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;这就出现了两个问题:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;Acceptor1&lt;/code&gt; 认为&lt;code&gt;V2&lt;/code&gt;被选定，&lt;code&gt;Acceptor2~5&lt;/code&gt;和&lt;code&gt;Proposer2&lt;/code&gt;认为&lt;code&gt;V1&lt;/code&gt;被选定。出现了不一致。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;V1&lt;/code&gt;被选定了，但是编号更高的被&lt;code&gt;Acceptor1&lt;/code&gt;接受的提案&lt;code&gt;[M2,V2]&lt;/code&gt;的 value 为 V2，且 V2≠V1。这就跟 P2a（如果提案 P[M1,V1] 被接受了，那么所有比M1编号更高的，且被Acceptor接受的P，其值也是 V1。）矛盾了。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;我们要对P2a约束强化一下得到约束P2b，&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;如果 &lt;span class="hljs-selector-tag"&gt;P&lt;/span&gt;&lt;span class="hljs-selector-attr"&gt;[M1,V1]&lt;/span&gt; 被选定后，任何&lt;span class="hljs-selector-tag"&gt;Proposer&lt;/span&gt; 产生的 &lt;span class="hljs-selector-tag"&gt;P&lt;/span&gt;，其值也是 &lt;span class="hljs-selector-tag"&gt;V1&lt;/span&gt;。
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;对于 P2b 中的描述，如何保证任何Proposer产生的P，其值也是V1 ？只要满足 P2c 即可：&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;对于任意的M和V,如果提案[M,V]被提出，那么肯定存在一个由半数以上的Acceptor组成的集合S，满足以下两个条件 中的任意一个:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;要么S中每个Acceptor都没有接受过编号小于M的提案。&lt;/li&gt;
&lt;li&gt;要么S中所有Acceptor批准的所有编号小于Mn的提案中，编号最大的那个提案的value值为Vn&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;
&lt;h3 id="8-3-"&gt;8.3 算法流程&lt;/h3&gt;
&lt;h4 id="8-3-1-proposer-"&gt;8.3.1. Proposer生成提案&lt;/h4&gt;
&lt;ul&gt;
&lt;li&gt;Prepare请求&lt;/li&gt;
&lt;li&gt;Accept请求&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;在 &lt;strong&gt;P2c&lt;/strong&gt;约束基础上，如何生成提案呢？&lt;/p&gt;
&lt;p&gt;Proposer选择一个新的提案编号N，向 Acceptor 集合 S（数目在半数以上）发送请求，要求 S 中的每一个 Acceptor 做出如下响应：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;如果 Acceptor 没有接受过提案，则向 Proposer 保证 不再接受编号小于N的提案。&lt;/li&gt;
&lt;li&gt;如果 Acceptor 接受过请求，则向 Proposer 返回 已经接受过的编号小于N的编号最大的提案。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;我们将这个请求称为编号为N的&lt;strong&gt;Prepare请求&lt;/strong&gt;。&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;如果Proposer收到半数以上的Acceptor 响应，则生成编号为&lt;code&gt;N&lt;/code&gt;，value 为 V 的提案 [N,V]，V 为所有响应中编号最大的提案的value。&lt;/li&gt;
&lt;li&gt;如果 Proposer收到的响应中没有提案，那么 value 由 Proposer 自己生成，生成后将此提案发给 S，并期望Acceptor 能接受此提案。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;我们称这个请求为&lt;strong&gt;Accept请求&lt;/strong&gt;&lt;/p&gt;
&lt;h4 id="8-3-2-acceptor-"&gt;8.3.2 Acceptor接受提案&lt;/h4&gt;
&lt;p&gt;一个Acceptor可能会受到来自Proposer的两种请求:Prepare请求和Accept请求。Acceptor 什么时候可以响应一个请求呢，它也有个约束：&lt;strong&gt;P1b&lt;/strong&gt;：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;一个Acceptor只要尚未响应过任何编号大于N的&lt;span class="hljs-keyword"&gt;Prepare&lt;/span&gt;请求，那么他就可以接受这个编号为N的提案。
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Acceptor收到编号为 N的Prepare 请求，如果在此之前它已经响应过编号大于N的Prepare请求。由约束P1b，该Acceptor不会接受这个编号为N的提案。因此，Acceptor会忽略这个请求。&lt;/p&gt;
&lt;p&gt;一个 Acceptor 只需记住两点：已接受的编号最大的提案和已响应的请求的最大编号。&lt;/p&gt;
&lt;h4 id="8-3-3-paxos-"&gt;8.3.3 Paxos算法描述&lt;/h4&gt;
&lt;p&gt;阶段一：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Proposer选择一个提案编号N，然后向半数以上的Acceptor发送编号为N的Prepare请求。&lt;/li&gt;
&lt;li&gt;如果一个Acceptor收到一个编号为N的Prepare请求，且N大于该Acceptor已经响应过的所有Prepare请求的编 号，那么它就会将它已经接受过的编号最大的提案(如果有的话)作为响应反馈给Proposer，同时该Acceptor 承诺不再接受任何编号小于N的提案。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;阶段二：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;如果Proposer收到半数以上Acceptor对其发出的编号为N的Prepare请求的响应，那么它就会发送一个针对 [N,V]提案的Accept请求给半数以上的Acceptor。注意:V就是收到的响应中编号最大的提案的value，如果响应 中不包含任何提案，那么V就由Proposer自己决定。&lt;/li&gt;
&lt;li&gt;如果Acceptor收到一个针对编号为N的提案的Accept请求，只要该Acceptor没有对编号大于N的Prepare请求 做出过响应，它就接受该提案。&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 id="8-3-4-learner-value"&gt;8.3.4 Learner学习被选定的value&lt;/h4&gt;

&lt;h2 id="9-b-b-b-b-"&gt;9. B+树是不是有序？B+树和B-树的主要区别？B+树索引，一次查找过程?&lt;/h2&gt;
&lt;p&gt;B+树是有序的。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;B+树和B-树的主要区别？&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;B-树内部节点是保存数据的;而B+树内部节点是不保存数据的，只作索引作用，它的叶子节点才保存数据。&lt;/li&gt;
&lt;li&gt;B+树相邻的叶子节点之间是通过链表指针连起来的，B-树却不是。&lt;/li&gt;
&lt;li&gt;查找过程中，B-树在找到具体的数值以后就结束，而B+树则需要通过索引找到叶子结点中的数据才结束&lt;/li&gt;
&lt;li&gt;B-树中任何一个关键字出现且只出现在一个结点中，而B+树可以出现多次。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;假设有这么一个SQL：&lt;/p&gt;
&lt;pre&gt;&lt;code class="lang-sql"&gt;&lt;span class="hljs-keyword"&gt;select&lt;/span&gt; * &lt;span class="hljs-keyword"&gt;from&lt;/span&gt; Temployee &lt;span class="hljs-keyword"&gt;where&lt;/span&gt; age=&lt;span class="hljs-number"&gt;32&lt;/span&gt;;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;age加个一个索引，这条SQL是如何在索引上执行的？大家可以举例子画个示意图哈，比如二级索引树，&lt;/p&gt;

&lt;p&gt;再画出id主键索引，我们先画出聚族索引结构图，如下：&lt;/p&gt;

&lt;p&gt;因此，这条 SQL 查询语句执行大概流程就是酱紫：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;搜索idx_age索引树，将磁盘块1加载到内存，由于32&amp;lt;37,搜索左路分支，到磁盘寻址磁盘块2。&lt;/li&gt;
&lt;li&gt;将磁盘块2加载到内存中，在内存继续遍历，找到age=32的记录，取得id = 400.&lt;/li&gt;
&lt;li&gt;拿到id=400后，回到id主键索引树。&lt;/li&gt;
&lt;li&gt;搜索id主键索引树，将磁盘块1加载内存，在内存遍历，找到了400，但是B+树索引非叶子节点是不保存数据的。索引会继续搜索400的右分支，到磁盘寻址磁盘块3.&lt;/li&gt;
&lt;li&gt;将磁盘块3加载内存，在内存遍历，找到id=400的记录，拿到R4这一行的数据，好的，大功告成。&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id="10-tcp-"&gt;10. TCP 怎么实现拥塞控制？&lt;/h2&gt;
&lt;p&gt;拥塞控制是作用于网络的，防止过多的数据包注入到网络中，避免出现网络负载过大的情况。它的目标主要是最大化利用网络上瓶颈链路的带宽。&lt;/p&gt;
&lt;p&gt;实际上，拥塞控制主要有这几种常用算法&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;慢启动&lt;/li&gt;
&lt;li&gt;拥塞避免&lt;/li&gt;
&lt;li&gt;拥塞发生&lt;/li&gt;
&lt;li&gt;快速恢复&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 id="-"&gt;慢启动算法&lt;/h4&gt;
&lt;p&gt;慢启动算法，表面意思就是，别急慢慢来。它表示TCP建立连接完成后，一开始不要发送大量的数据，而是先探测一下网络的拥塞程度。由小到大逐渐增加拥塞窗口的大小，如果没有出现丢包，&lt;strong&gt;每收到一个ACK，就将拥塞窗口cwnd大小就加1（单位是MSS）&lt;/strong&gt;。&lt;strong&gt;每轮次&lt;/strong&gt;发送窗口增加一倍，呈指数增长，如果出现丢包，拥塞窗口就减半，进入拥塞避免阶段。&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;TCP连接完成，初始化cwnd = 1，表明可以传一个MSS单位大小的数据。&lt;/li&gt;
&lt;li&gt;每当收到一个ACK，cwnd就加一;&lt;/li&gt;
&lt;li&gt;每当过了一个RTT，cwnd就增加一倍; 呈指数让升&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;为了防止cwnd增长过大引起网络拥塞，还需设置一个&lt;strong&gt;慢启动阀值ssthresh&lt;/strong&gt;（slow start threshold）状态变量。当&lt;code&gt;cwnd&lt;/code&gt;到达该阀值后，就好像水管被关小了水龙头一样，减少拥塞状态。即当&lt;strong&gt;cwnd &amp;gt;ssthresh&lt;/strong&gt;时，进入了&lt;strong&gt;拥塞避免&lt;/strong&gt;算法。&lt;/p&gt;
&lt;h4 id="-"&gt;拥塞避免算法&lt;/h4&gt;
&lt;p&gt;一般来说，慢启动阀值ssthresh是65535字节，&lt;code&gt;cwnd&lt;/code&gt;到达&lt;strong&gt;慢启动阀值&lt;/strong&gt;后&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;每收到一个ACK时，cwnd = cwnd + 1/cwnd&lt;/li&gt;
&lt;li&gt;当每过一个RTT时，cwnd = cwnd + 1&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;显然这是一个线性上升的算法，避免过快导致网络拥塞问题。&lt;/p&gt;

&lt;h4 id="-"&gt;拥塞发生&lt;/h4&gt;
&lt;p&gt;当网络拥塞发生&lt;strong&gt;丢包&lt;/strong&gt;时，会有两种情况：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;RTO超时重传&lt;/li&gt;
&lt;li&gt;快速重传&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;如果是发生了&lt;strong&gt;RTO超时重传&lt;/strong&gt;，就会使用拥塞发生算法&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;慢启动阀值sshthresh =  cwnd /2&lt;/li&gt;
&lt;li&gt;cwnd 重置为 1&lt;/li&gt;
&lt;li&gt;进入新的慢启动过程&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;这真的是&lt;strong&gt;辛辛苦苦几十年，一朝回到解放前&lt;/strong&gt;。其实还有更好的处理方式，就是&lt;strong&gt;快速重传&lt;/strong&gt;。发送方收到3个连续重复的ACK时，就会快速地重传，不必等待&lt;strong&gt;RTO超时&lt;/strong&gt;再重传。&lt;/p&gt;
&lt;p&gt;image.png&lt;/p&gt;
&lt;p&gt;慢启动阀值ssthresh 和 cwnd 变化如下：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;拥塞窗口大小 cwnd = cwnd/2&lt;/li&gt;
&lt;li&gt;慢启动阀值 ssthresh = cwnd&lt;/li&gt;
&lt;li&gt;进入快速恢复算法&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 id="-"&gt;快速恢复&lt;/h4&gt;
&lt;p&gt;快速重传和快速恢复算法一般同时使用。快速恢复算法认为，还有3个重复ACK收到，说明网络也没那么糟糕，所以没有必要像RTO超时那么强烈。&lt;/p&gt;
&lt;p&gt;正如前面所说，进入快速恢复之前，cwnd 和 sshthresh已被更新：&lt;/p&gt;
&lt;pre&gt;&lt;code class="lang-java"&gt;&lt;span class="hljs-deletion"&gt;- cwnd = cwnd /2&lt;/span&gt;
&lt;span class="hljs-deletion"&gt;- sshthresh = cwnd&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;然后，真正的快速算法如下：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;cwnd = sshthresh  + 3&lt;/li&gt;
&lt;li&gt;重传重复的那几个ACK（即丢失的那几个数据包）&lt;/li&gt;
&lt;li&gt;如果再收到重复的 ACK，那么 cwnd = cwnd +1&lt;/li&gt;
&lt;li&gt;如果收到新数据的 ACK 后, cwnd = sshthresh。因为收到新数据的 ACK，表明恢复过程已经结束，可以再次进入了拥塞避免的算法了。&lt;/li&gt;
&lt;/ul&gt;

&lt;h2 id="11-jvm-"&gt;11. JVM调优&lt;/h2&gt;
&lt;h3 id="11-1-jvm-"&gt;11.1 一般什么时候考虑JVM调优呢？&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;Heap内存（老年代）持续上涨达到设置的最大内存值；&lt;/li&gt;
&lt;li&gt;Full GC 次数频繁；&lt;/li&gt;
&lt;li&gt;GC 停顿时间过长（超过1秒）；&lt;/li&gt;
&lt;li&gt;应用出现OutOfMemory 等内存异常；&lt;/li&gt;
&lt;li&gt;应用中有使用本地缓存且占用大量内存空间；&lt;/li&gt;
&lt;li&gt;系统吞吐量与响应性能不高或下降。&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id="11-2-jvm-"&gt;11.2 JVM调优的目标&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;延迟：GC低停顿和GC低频率；&lt;/li&gt;
&lt;li&gt;低内存占用；&lt;/li&gt;
&lt;li&gt;高吞吐量;&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id="11-3-jvm-"&gt;11.3 JVM调优量化目标&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;Heap 内存使用率 &amp;lt;= 70%;&lt;/li&gt;
&lt;li&gt;Old generation内存使用率&amp;lt;= 70%;&lt;/li&gt;
&lt;li&gt;avgpause &amp;lt;= 1秒;&lt;/li&gt;
&lt;li&gt;Full gc 次数0 或 avg pause interval &amp;gt;= 24小时 ;&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id="11-4-jvm-"&gt;11.4 JVM调优的步骤&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;分析GC日志及dump文件，判断是否需要优化，确定瓶颈问题点；&lt;/li&gt;
&lt;li&gt;确定JVM调优量化目标；&lt;/li&gt;
&lt;li&gt;确定JVM调优参数（根据历史JVM参数来调整）；&lt;/li&gt;
&lt;li&gt;依次调优内存、延迟、吞吐量等指标；&lt;/li&gt;
&lt;li&gt;对比观察调优前后的差异；&lt;/li&gt;
&lt;li&gt;不断的分析和调整，直到找到合适的JVM参数配置；&lt;/li&gt;
&lt;li&gt;找到最合适的参数，将这些参数应用到所有服务器，并进行后续跟踪。&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id="11-5-jvm-"&gt;11.5 常见的JVM参数&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;堆栈配置相关&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class="lang-java"&gt;&lt;span class="hljs-deletion"&gt;-Xmx3550m -Xms3550m -Xmn2g -Xss128k &lt;/span&gt;
&lt;span class="hljs-deletion"&gt;-XX:MaxPermSize=16m -XX:NewRatio=4 -XX:SurvivorRatio=4 -XX:MaxTenuringThreshold=0&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;ul&gt;
&lt;li&gt;-Xmx3550m：最大堆大小为3550m。&lt;/li&gt;
&lt;li&gt;-Xms3550m：设置初始堆大小为3550m。&lt;/li&gt;
&lt;li&gt;-Xmn2g：设置年轻代大小为2g。&lt;/li&gt;
&lt;li&gt;-Xss128k：每个线程的堆栈大小为128k。&lt;/li&gt;
&lt;li&gt;-XX:MaxPermSize： 设置持久代大小为16m&lt;/li&gt;
&lt;li&gt;-XX:NewRatio=4: 设置年轻代（包括Eden和两个Survivor区）与年老代的比值（除去持久代）。&lt;/li&gt;
&lt;li&gt;-XX:SurvivorRatio=4：设置年轻代中Eden区与Survivor区的大小比值。设置为4，则两个Survivor区与一个Eden区的比值为2:4，一个Survivor区占整个年轻代的1/6&lt;/li&gt;
&lt;li&gt;-XX:MaxTenuringThreshold=0：设置垃圾最大年龄。如果设置为0的话，则年轻代对象不经过Survivor区，直接进入年老代。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;垃圾收集器相关&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class="lang-java"&gt;&lt;span class="hljs-deletion"&gt;-XX:+UseParallelGC&lt;/span&gt;
&lt;span class="hljs-deletion"&gt;-XX:ParallelGCThreads=20&lt;/span&gt;
&lt;span class="hljs-deletion"&gt;-XX:+UseConcMarkSweepGC &lt;/span&gt;
&lt;span class="hljs-deletion"&gt;-XX:CMSFullGCsBeforeCompaction=5&lt;/span&gt;
&lt;span class="hljs-deletion"&gt;-XX:+UseCMSCompactAtFullCollection：&lt;/span&gt;
&lt;span class="hljs-deletion"&gt;-XX:+UseConcMarkSweepGC&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;ul&gt;
&lt;li&gt;-XX:+UseParallelGC：选择垃圾收集器为并行收集器。&lt;/li&gt;
&lt;li&gt;-XX:ParallelGCThreads=20：配置并行收集器的线程数&lt;/li&gt;
&lt;li&gt;-XX:+UseConcMarkSweepGC：设置年老代为并发收集。&lt;/li&gt;
&lt;li&gt;-XX:CMSFullGCsBeforeCompaction：由于并发收集器不对内存空间进行压缩、整理，所以运行一段时间以后会产生“碎片”，使得运行效率降低。此值设置运行多少次GC以后对内存空间进行压缩、整理。&lt;/li&gt;
&lt;li&gt;-XX:+UseCMSCompactAtFullCollection：打开对年老代的压缩。可能会影响性能，但是可以消除碎片&lt;/li&gt;
&lt;li&gt;-XX:+UseConcMarkSweepGC 使用CMS垃圾收集器&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;辅助信息&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class="lang-java"&gt;&lt;span class="hljs-selector-tag"&gt;-XX&lt;/span&gt;&lt;span class="hljs-selector-pseudo"&gt;:+PrintGC&lt;/span&gt;
&lt;span class="hljs-selector-tag"&gt;-XX&lt;/span&gt;&lt;span class="hljs-selector-pseudo"&gt;:+PrintGCDetails&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 id="11-6-"&gt;11.6 常用调优策略&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;选择合适的垃圾回收器&lt;/li&gt;
&lt;li&gt;调整内存大小(垃圾收集频率非常频繁,如果是内存太小，可适当调整内存大小)&lt;/li&gt;
&lt;li&gt;调整内存区域大小比率（某一个区域的GC频繁，其他都正常。）&lt;/li&gt;
&lt;li&gt;调整对象升老年代的年龄（老年代频繁GC，每次回收的对象很多。）&lt;/li&gt;
&lt;li&gt;调整大对象的标准(老年代频繁GC，每次回收的对象很多,而且单个对象的体积都比较大。)&lt;/li&gt;
&lt;li&gt;调整GC的触发时机(CMS，G1 经常 Full GC，程序卡顿严重。)&lt;/li&gt;
&lt;li&gt;调整 JVM本地内存大小(GC的次数、时间和回收的对象都正常，堆内存空间充足，但是报OOM)&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id="12-"&gt;12. 数据库分库分表的缺点是啥？&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;事务问题，已经不可以用本地事务了，需要用分布式事务。&lt;/li&gt;
&lt;li&gt;跨节点Join的问题：解决这一问题可以分两次查询实现&lt;/li&gt;
&lt;li&gt;跨节点的count,order by,group by以及聚合函数问题：分别在各个节点上得到结果后在应用程序端进行合并。&lt;/li&gt;
&lt;li&gt;ID问题：数据库被切分后，不能再依赖数据库自身的主键生成机制啦，最简单可以考虑UUID&lt;/li&gt;
&lt;li&gt;跨分片的排序分页问题（后台加大pagesize处理？）&lt;/li&gt;
&lt;/ol&gt;
&lt;h2 id="13-tcc-"&gt;13. 分布式事务如何解决？TCC 了解？&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;分布式事务：&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;就是指事务的参与者、支持事务的服务器、资源服务器以及事务管理器分别位于不同的分布式系统的不同节点之上。简单来说，分布式事务指的就是分布式系统中的事务，它的存在就是为了保证不同数据库节点的数据一致性。&lt;/p&gt;
&lt;p&gt;聊到分布式事务，需要知道这两个基本理论哈。&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;CAP 理论&lt;/li&gt;
&lt;li&gt;BASE 理论&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;CAP 理论&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;一致性(C：Consistency)：一致性是指数据在多个副本之间能否保持一致的特性。例如一个数据在某个分区节点更新之后，在其他分区节点读出来的数据也是更新之后的数据。&lt;/li&gt;
&lt;li&gt;可用性(A：Availability)：可用性是指系统提供的服务必须一直处于可用的状态，对于用户的每一个操作请求总是能够在有限的时间内返回结果。这里的重点是&amp;quot;有限时间内&amp;quot;和&amp;quot;返回结果&amp;quot;。&lt;/li&gt;
&lt;li&gt;分区容错性（P:Partition tolerance）:分布式系统在遇到任何网络分区故障的时候，仍然需要能够保证对外提供满足一致性和可用性的服务。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;BASE 理论&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;它是对CAP中AP的一个扩展，对于我们的业务系统，我们考虑牺牲一致性来换取系统的可用性和分区容错性。BASE是&lt;code&gt;Basically Available，Soft state,和 Eventually consistent&lt;/code&gt;三个短语的缩写。&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Basically Available(基本可用)：通过支持局部故障而不是系统全局故障来实现的。如将用户分区在 5 个数据库服务器上，一个用户数据库的故障只影响这台特定主机那 20% 的用户，其他用户不受影响。&lt;/li&gt;
&lt;li&gt;Soft State(软状态):状态可以有一段时间不同步&lt;/li&gt;
&lt;li&gt;Eventually Consistent(最终一致):最终数据是一致的就可以了，而不是时时保持强一致。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;分布式事务的几种解决方案：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;2PC(二阶段提交)方案、3PC&lt;/li&gt;
&lt;li&gt;TCC（Try、Confirm、Cancel）&lt;/li&gt;
&lt;li&gt;本地消息表&lt;/li&gt;
&lt;li&gt;最大努力通知&lt;/li&gt;
&lt;li&gt;seata事务&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;TCC（补偿机制）&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;TCC 采用了补偿机制，其核心思想是：针对每个操作，都要注册一个与其对应的确认和补偿（撤销）操作。TCC（Try-Confirm-Cancel）包括三段流程：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;try阶段：尝试去执行，完成所有业务的一致性检查，预留必须的业务资源。&lt;/li&gt;
&lt;li&gt;Confirm阶段：该阶段对业务进行确认提交，不做任何检查，因为try阶段已经检查过了，默认Confirm阶段是不会出错的。&lt;/li&gt;
&lt;li&gt;Cancel 阶段：若业务执行失败，则进入该阶段，它会释放try阶段占用的所有业务资源，并回滚Confirm阶段执行的所有操作。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;下面再拿用户下单购买礼物作为例子来模拟TCC实现分布式事务的过程：&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;假设用户A余额为100金币，拥有的礼物为5朵。A花了10个金币，下订单，购买10朵玫瑰。余额、订单、礼物都在不同数据库。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;&lt;strong&gt;TCC的Try阶段：&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;生成一条订单记录，订单状态为待确认。&lt;/li&gt;
&lt;li&gt;将用户A的账户金币中余额更新为90，冻结金币为10（预留业务资源）&lt;/li&gt;
&lt;li&gt;将用户的礼物数量为5，预增加数量为10。&lt;/li&gt;
&lt;li&gt;Try成功之后，便进入Confirm阶段&lt;/li&gt;
&lt;li&gt;Try过程发生任何异常，均进入Cancel阶段&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;TCC的Confirm阶段：&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;订单状态更新为已支付&lt;/li&gt;
&lt;li&gt;更新用户余额为90，可冻结为0&lt;/li&gt;
&lt;li&gt;用户礼物数量更新为15，预增加为0&lt;/li&gt;
&lt;li&gt;Confirm过程发生任何异常，均进入Cancel阶段&lt;/li&gt;
&lt;li&gt;Confirm过程执行成功，则该事务结束&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;TCC的Cancel阶段：&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;修改订单状态为已取消&lt;/li&gt;
&lt;li&gt;更新用户余额回100&lt;/li&gt;
&lt;li&gt;更新用户礼物数量为5&lt;/li&gt;
&lt;/ul&gt;

&lt;ul&gt;
&lt;li&gt;TCC的优点是可以自定义数据库操作的粒度，降低了锁冲突，可以提升性能&lt;/li&gt;
&lt;li&gt;TCC的缺点是应用侵入性强，需要根据网络、系统故障等不同失败原因实现不同的回滚策略，实现难度大，一般借助TCC开源框架，ByteTCC，TCC-transaction，Himly。&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id="14-rocketmq-"&gt;14， RocketMQ 如何保证消息的准确性和安全性？&lt;/h2&gt;
&lt;p&gt;我个人理解的话，这道题换汤不换药，就是为如何保证RocketMQ 不丢消息，保证不重复消费，消息有序性，消息堆积的处理。&lt;/p&gt;
&lt;p&gt;消息不丢失的话，即从生产者、存储端、消费端去考虑&lt;/p&gt;
&lt;p&gt;大家可以看下我之前这篇文章哈：&lt;/p&gt;
&lt;p&gt;[&lt;a href="http://www.hqxiaozou.top/post/UQJbyJDeiBH#3-"&gt;消息队列的十道经典面试题&lt;/a&gt;]&lt;/p&gt;
&lt;h2 id="15-"&gt;15. 三个数求和&lt;/h2&gt;
&lt;p&gt;给你一个包含 n 个整数的数组 nums，判断 nums 中是否存在三个元素 a，b，c ，使得 a + b + c = 0 ？请你找出所有和为 0 且不重复的三元组。&lt;/p&gt;
&lt;p&gt;注意：答案中不可以包含重复的三元组&lt;/p&gt;
&lt;p&gt;实例1：&lt;/p&gt;
&lt;pre&gt;&lt;code class="lang-java"&gt;输入：nums = [-1,0,1,2,-1,-4]
输出：[[-1,-1,2],[-1,0,1]]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;实例2：&lt;/p&gt;
&lt;pre&gt;&lt;code class="lang-java"&gt;输入：nums = [0]
输出：[]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;思路：&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;这道题可以先给数组排序，接着用左右双指针。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;完整代码如下：&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class="lang-java"&gt;&lt;span class="hljs-keyword"&gt;class&lt;/span&gt; &lt;span class="hljs-title"&gt;Solution&lt;/span&gt; {
    &lt;span class="hljs-keyword"&gt;public&lt;/span&gt; List&amp;lt;List&amp;lt;Integer&amp;gt;&amp;gt; threeSum(&lt;span class="hljs-keyword"&gt;int&lt;/span&gt;[] nums) {

        List&amp;lt;List&amp;lt;Integer&amp;gt;&amp;gt; result = &lt;span class="hljs-keyword"&gt;new&lt;/span&gt; LinkedList&amp;lt;&amp;gt;();
        &lt;span class="hljs-keyword"&gt;if&lt;/span&gt;(nums==&lt;span class="hljs-literal"&gt;null&lt;/span&gt;||nums.length&amp;lt;&lt;span class="hljs-number"&gt;3&lt;/span&gt;){ &lt;span class="hljs-comment"&gt;//为空或者元素个数小于3，直接返回&lt;/span&gt;
            &lt;span class="hljs-keyword"&gt;return&lt;/span&gt; result;

        }

        Arrays.sort(nums); &lt;span class="hljs-comment"&gt;//排序&lt;/span&gt;

        &lt;span class="hljs-keyword"&gt;for&lt;/span&gt;(&lt;span class="hljs-keyword"&gt;int&lt;/span&gt; i=&lt;span class="hljs-number"&gt;0&lt;/span&gt;;i&amp;lt;nums.length&lt;span class="hljs-number"&gt;-2&lt;/span&gt;;i++){ &lt;span class="hljs-comment"&gt;//遍历到倒数第三个，因为是三个数总和&lt;/span&gt;
            &lt;span class="hljs-keyword"&gt;if&lt;/span&gt;(nums[i]&amp;gt;&lt;span class="hljs-number"&gt;0&lt;/span&gt;){ &lt;span class="hljs-comment"&gt;//大于0可以直接跳出循环了&lt;/span&gt;
                &lt;span class="hljs-keyword"&gt;break&lt;/span&gt;;
            }

            &lt;span class="hljs-keyword"&gt;if&lt;/span&gt;(i&amp;gt;&lt;span class="hljs-number"&gt;0&lt;/span&gt;&amp;amp;&amp;amp;nums[i]==nums[i&lt;span class="hljs-number"&gt;-1&lt;/span&gt;]){ &lt;span class="hljs-comment"&gt;//过滤重复&lt;/span&gt;
                &lt;span class="hljs-keyword"&gt;continue&lt;/span&gt;;
            }

            &lt;span class="hljs-keyword"&gt;int&lt;/span&gt; left = i+&lt;span class="hljs-number"&gt;1&lt;/span&gt;;  &lt;span class="hljs-comment"&gt;//左指针&lt;/span&gt;
            &lt;span class="hljs-keyword"&gt;int&lt;/span&gt; right = nums.length&lt;span class="hljs-number"&gt;-1&lt;/span&gt;; &lt;span class="hljs-comment"&gt;//右指针&lt;/span&gt;
            &lt;span class="hljs-keyword"&gt;int&lt;/span&gt; target = - nums[i];  &lt;span class="hljs-comment"&gt;//目标总和，是第i个的取反，也就是a+b+c=0,则b+c=-a即可&lt;/span&gt;

            &lt;span class="hljs-keyword"&gt;while&lt;/span&gt;(left&amp;lt;right){
                &lt;span class="hljs-keyword"&gt;if&lt;/span&gt;(nums[left]+ nums[right]==target){ &lt;span class="hljs-comment"&gt;//b+c=-a,满足a+b+c=0&lt;/span&gt;
                   result.&lt;span class="hljs-keyword"&gt;add&lt;/span&gt;(Arrays.asList(nums[i],nums[left],nums[right]));
                   left++;  &lt;span class="hljs-comment"&gt;//左指针右移&lt;/span&gt;
                   right--;  &lt;span class="hljs-comment"&gt;//右指针左移&lt;/span&gt;
                   &lt;span class="hljs-keyword"&gt;while&lt;/span&gt;(left&amp;lt;right&amp;amp;&amp;amp;nums[left]==nums[left&lt;span class="hljs-number"&gt;-1&lt;/span&gt;]) left++; &lt;span class="hljs-comment"&gt;//继续左边过滤重复&lt;/span&gt;
                   &lt;span class="hljs-keyword"&gt;while&lt;/span&gt;(left&amp;lt;right&amp;amp;&amp;amp;nums[right]==nums[right+&lt;span class="hljs-number"&gt;1&lt;/span&gt;]) right--; &lt;span class="hljs-comment"&gt;//继续右边过滤重复&lt;/span&gt;
                }&lt;span class="hljs-function"&gt;&lt;span class="hljs-keyword"&gt;else&lt;/span&gt; &lt;span class="hljs-title"&gt;if&lt;/span&gt;(&lt;span class="hljs-params"&gt;nums[left]+ nums[right]&amp;lt;target&lt;/span&gt;)&lt;/span&gt;{
                   left++; &lt;span class="hljs-comment"&gt;//小于目标值，需要右移，因为排好序是从小到大的&lt;/span&gt;
                }&lt;span class="hljs-keyword"&gt;else&lt;/span&gt;{
                  right--;  
                }

            }
        }
            &lt;span class="hljs-keyword"&gt;return&lt;/span&gt; result;
        }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h2 id="-"&gt;参考与感谢&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Callable/Future 使用及原理分析&lt;/strong&gt;   &lt;a href="https://cloud.tencent.com/developer/article/1692202"&gt;https://cloud.tencent.com/developer/article/1692202&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;【JVM进阶之路】十：JVM调优总结&lt;/strong&gt;   &lt;a href="https://zhuanlan.zhihu.com/p/363961261"&gt;https://zhuanlan.zhihu.com/p/363961261&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;分布式理论(五) - 一致性算法Paxos&lt;/strong&gt;   &lt;a href="https://juejin.cn/post/6844903621499289613#heading-19"&gt;https://juejin.cn/post/6844903621499289613#heading-19&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;</description>
      <pubDate>Tue, 15 Mar 2022 14:35:04 GMT</pubDate>
    </item>
    <item>
      <title>小程序分享卡片被分享签名校验降级的排查</title>
      <link>https://www.hqxiaozou.top/post/3h3vJNHQDNs</link>
      <description>&lt;blockquote&gt;
&lt;p&gt;当前代码：3.1.0 的首页和菜谱详情仍同步返回分享对象，详情使用 500×400 的成品缩略图，图片未就绪时用包内分享图。路径保留具体菜谱版本和邀请码，朋友无需登录即可查看现成做法；首次从分享进入并完成微信登录的新用户，双方各得 10 次搭配。下文是当时分享卡片降级的排查，微信后台开关的历史状态不等同于本次重新验收。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;做「炊想」小程序时遇到一个很迷惑的问题：在菜谱页点「分享」发给朋友，&lt;strong&gt;我自己这边看到的卡片好好的&lt;/strong&gt;，有标题有封面；&lt;strong&gt;对方收到的却只有小程序名，封面是一块灰色占位&lt;/strong&gt;。前后改了两版分享代码都没用，最后发现跟代码无关——是小程序后台开着一个「分享请求签名校验」。&lt;/p&gt;
&lt;p&gt;这篇把现象、走过的弯路、真正的原因和两种解法记下来。&lt;/p&gt;
&lt;h2 id="一、现象：同一张卡片，两边看到的不一样"&gt;一、现象：同一张卡片，两边看到的不一样&lt;/h2&gt;
&lt;p&gt;&lt;img src="https://img.zxfhq.cn/blog/chuixiang/20261020/chuixiang-3-1-0-20261020-recipe.jpg" alt="新版菜谱详情及公开浏览入口，分享对应当前选中的做法"&gt;&lt;/p&gt;
&lt;p&gt;上图是当前版本的页面。原故障中，发出方与接收方卡片的差异如下：&lt;/p&gt;
&lt;p&gt;具体是这样：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;发出方&lt;/strong&gt;：卡片标题是代码里写的「「麻辣香锅」这样搭成一桌饭」，封面是这道菜的成品图，一切正常。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;接收方&lt;/strong&gt;：标题变成了小程序名「炊想」，封面位置是灰底加一个小程序图标，像是图片没加载出来。&lt;/li&gt;
&lt;li&gt;不是偶发：昨天发的、今天发的都这样；换了分享代码、发了新版本以后还是这样。&lt;/li&gt;
&lt;li&gt;点开卡片能正常进入小程序、打开那道菜，只是卡片长得不对。&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id="二、先排除了哪些可能"&gt;二、先排除了哪些可能&lt;/h2&gt;
&lt;p&gt;一开始的判断是「分享信息没传对」或者「图片对方加载不出来」，顺着这个思路查了一圈：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;onShareAppMessage&lt;/code&gt; 没写，或者数据还没回来就分享了？&lt;/strong&gt;
发出方自己的卡片标题、封面都对，说明代码交出去的分享内容没问题。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;封面图地址有问题（https、防盗链、过期）？&lt;/strong&gt;
不带 Referer 直接请求图片地址，返回 200、&lt;code&gt;image/jpeg&lt;/code&gt;、约 50KB，图片是好的。而且接收方&lt;strong&gt;连标题都丢了&lt;/strong&gt;，图片问题解释不了标题。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;分享时先把图下成本地临时文件、再用 &lt;code&gt;promise&lt;/code&gt; 交给微信，对方拿不到？&lt;/strong&gt;
2.0.11 改成同步返回、直接给 https 小图地址，接收方照旧，不是这里。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;某一版改动引入的？&lt;/strong&gt;
改动前发的卡片和改动后发的一样，不是版本问题。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;排除到这里，有两条线索已经很明确了：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;发出方正常、接收方异常&lt;/strong&gt;：说明小程序把正确的数据交给了微信，问题出在微信「转交」给对方的环节。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;标题和封面一起丢&lt;/strong&gt;：对方看到的不是「图片挂了」，而是一张完整的&lt;strong&gt;默认卡片&lt;/strong&gt;——默认标题就是小程序名，默认封面就是那个灰色占位。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;也就是说，这张卡片被平台「降级」了。&lt;/p&gt;
&lt;h2 id="三、真正的原因：分享安全校验"&gt;三、真正的原因：分享安全校验&lt;/h2&gt;
&lt;p&gt;在微信官方文档里找到了&lt;a href="https://developers.weixin.qq.com/miniprogram/dev/framework/share_signature.html"&gt;《小程序分享签名》&lt;/a&gt;，说的正是这件事，要点是：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;小程序后台有个「分享安全校验」选项。&lt;strong&gt;开启后，每次自定义分享都要带上开发者签名&lt;/strong&gt;，平台会校验，&lt;strong&gt;验签失败的分享会被降级&lt;/strong&gt;。&lt;/li&gt;
&lt;li&gt;降级后的样子就是「默认分享标题 + 默认分享图」，和上面接收方看到的一模一样。&lt;/li&gt;
&lt;li&gt;开启后，自定义分享&lt;strong&gt;必须&lt;/strong&gt;指定 &lt;code&gt;imageUrl&lt;/code&gt;，否则直接展示降级卡片。&lt;/li&gt;
&lt;li&gt;签名对 &lt;code&gt;appid&lt;/code&gt;、&lt;code&gt;path&lt;/code&gt;、&lt;code&gt;thumbDataHash&lt;/code&gt;（封面图的 SHA256）、&lt;code&gt;title&lt;/code&gt; 四个字段计算，算法是 RSAwithSHA256（PSS 填充）或 SM2withSM3，私钥由开发者自己保管，公钥或证书要先配置到平台。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;回到小程序后台一看，这个开关确实是开着的：&lt;/p&gt;
&lt;p&gt;位置在 &lt;strong&gt;小程序后台 → 管理 → 开发管理 → 开发设置&lt;/strong&gt; 页面里的「安全管理配置」，提示文案是「已开启对该应用的分享请求签名校验」。我们的代码从来没有带过签名，所以每一次分享都被降级了。至于发出方为什么一直正常，我的理解是：发出方那张卡片是本机直接用分享数据画出来的，验签和降级只发生在下发给对方的环节——这也是这个坑最迷惑人的地方。&lt;/p&gt;
&lt;p&gt;这个开关是什么时候打开的，已经无从考证了；它和开发者公钥、证书这些安全配置在一起，配置别的能力时顺手点开的可能性很大。&lt;/p&gt;
&lt;h2 id="四、两种解法"&gt;四、两种解法&lt;/h2&gt;
&lt;h3 id="解法一：不需要验签，就关掉它（我选的）"&gt;解法一：不需要验签，就关掉它（我选的）&lt;/h3&gt;
&lt;p&gt;炊想的分享内容就是菜名和成品图，没有需要防篡改的敏感参数，所以直接点「关闭」：&lt;/p&gt;
&lt;p&gt;关闭后的页面改为说明「开启后」会进行签名校验。排查自己的小程序时，应核对自己后台的实际开关状态。&lt;/p&gt;
&lt;p&gt;关闭后&lt;strong&gt;不用改代码、不用发版&lt;/strong&gt;，重新分享一次，对方收到的卡片标题和封面就都正常了。线上已经发布的旧版本也一样恢复。&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;邀请规则已更新：当前是新用户完成首次微信登录后，双方各得 10 次搭配，30 天内有效；邀请人每 24 小时最多获 5 份、累计 50 份奖励。奖励由服务端按新账号、微信身份、邀请关系和上限核验，修改邀请码不会凭空增加奖励。前述关闭验签是当时的选择，不代表有业务参数的分享都可以省略完整性校验。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h3 id="解法二：需要验签，就把签名补上"&gt;解法二：需要验签，就把签名补上&lt;/h3&gt;
&lt;p&gt;如果你的分享链接里带了业务参数（邀请码、活动 id 之类），担心被人改了再转发，就应该保留校验、把签名做完整。关键是&lt;strong&gt;私钥只能放在服务端&lt;/strong&gt;，所以流程大致是：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;在小程序后台配置开发者公钥（或证书），私钥放服务器环境变量。&lt;/li&gt;
&lt;li&gt;服务端提供一个签名接口：入参是 &lt;code&gt;path&lt;/code&gt;、&lt;code&gt;title&lt;/code&gt;、&lt;code&gt;imageUrl&lt;/code&gt;；服务端下载封面图算出 &lt;code&gt;thumbDataHash&lt;/code&gt;（小写十六进制的 SHA256），按文档的规则拼签名串、用私钥签名，返回 &lt;code&gt;thumbDataHash&lt;/code&gt; 和 &lt;code&gt;signature&lt;/code&gt;。&lt;/li&gt;
&lt;li&gt;小程序在&lt;strong&gt;分享之前&lt;/strong&gt;就把签名拿好（比如菜谱详情加载完、封面图就绪时请求一次缓存起来）。&lt;code&gt;onShareAppMessage&lt;/code&gt; 最好同步返回；用 &lt;code&gt;promise&lt;/code&gt; 的话微信最多等 3 秒，超时就用默认值，等于又降级了。&lt;/li&gt;
&lt;li&gt;分享对象里多带两个字段：&lt;/li&gt;
&lt;/ol&gt;
&lt;pre&gt;&lt;code class="language-js"&gt;onShareAppMessage() {
  return {
    title: this.share.title,
    path: this.share.path,
    imageUrl: this.share.imageUrl,
    thumbDataHash: this.share.thumbDataHash, // 服务端算好的封面 SHA256
    signature: this.share.signature          // 服务端用私钥签的名
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;签名串的拼法按文档来：四个字段按 key 的字典序排列，value 先 UTF-8 再标准 Base64（不带换行），用 &lt;code&gt;&amp;amp;&lt;/code&gt; 连成 &lt;code&gt;appid=...&amp;amp;path=...&amp;amp;thumbDataHash=...&amp;amp;title=...&lt;/code&gt;，再用 RSA-SHA256 + PSS（salt 长度 32）签名后 Base64。几个容易错的点：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;path&lt;/code&gt; 要和分享对象里的&lt;strong&gt;一字不差&lt;/strong&gt;，包括开头的 &lt;code&gt;/&lt;/code&gt; 和 query 的编码方式。&lt;/li&gt;
&lt;li&gt;签的是 &lt;code&gt;imageUrl&lt;/code&gt; &lt;strong&gt;下载下来的图片字节&lt;/strong&gt;的哈希。图片地址如果带缩放参数（比如七牛的 &lt;code&gt;imageMogr2&lt;/code&gt;），要按带参数后实际返回的内容算。&lt;/li&gt;
&lt;li&gt;用包内图片（&lt;code&gt;/assets/share-cover.jpg&lt;/code&gt;）当封面时，哈希按文档的说法应该就是这个文件本身的 SHA256，可以构建时算好（这一点我没有实测，做之前建议先用一张图验证）。&lt;/li&gt;
&lt;li&gt;PSS 签名每次结果都不一样，这是正常的，只能用公钥验，不能拿两次结果比对。&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id="五、遇到类似现象，怎么快速判断"&gt;五、遇到类似现象，怎么快速判断&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;发出方正常、接收方的标题变成小程序名、封面是灰色占位&lt;/strong&gt;：先别查代码，去后台看「安全管理配置」里的分享签名校验是不是开着。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;发出方自己看到的卡片就已经不对&lt;/strong&gt;：那才是代码问题，查 &lt;code&gt;onShareAppMessage&lt;/code&gt; 的返回值、异步数据、图片地址。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;只有封面不显示、标题是对的&lt;/strong&gt;：多半是图片本身的问题（地址不可访问、格式不是 png/jpg、太大太慢）。&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id="六、顺手修掉的两处"&gt;六、顺手修掉的两处&lt;/h2&gt;
&lt;p&gt;排查过程中改过的分享代码，有两处是真的值得改，最后保留了下来：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;分享图被压扁了&lt;/strong&gt;：封面用的是七牛缩略参数 &lt;code&gt;thumbnail/500x400!&lt;/code&gt;，这里的 &lt;code&gt;!&lt;/code&gt; 表示「忽略原比例、强制缩放到这个尺寸」，1:1 的成品图被硬压成 5:4。改成先按比例铺满、再居中裁切：&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-text"&gt;?imageMogr2/thumbnail/!500x400r/gravity/Center/crop/500x400/format/jpg/quality/80
&lt;/code&gt;&lt;/pre&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;分享时不再临时下载图片&lt;/strong&gt;：之前在分享那一刻先 &lt;code&gt;downloadFile&lt;/code&gt; 成本地临时文件，再用 &lt;code&gt;promise&lt;/code&gt; 交给微信，链路长、还受 3 秒上限约束。现在直接同步返回 500×400、约 50KB 的 https 小图地址，成品图还没出来时用包内的分享图兜底。&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;h2 id="七、几点体会"&gt;七、几点体会&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;先分清「谁看到的」&lt;/strong&gt;。这次如果一开始就注意到「发出方看到的是本地预览、接收方看到的才是平台下发的」，能少走很多弯路：本地正常而对方异常，问题多半在平台环节，而不是代码。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;「标题和图片一起丢」和「只丢图片」是两回事&lt;/strong&gt;。前者是整张卡片被换成了默认值，后者才是资源加载问题。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;平台开关会改变行为，但不会出现在代码里&lt;/strong&gt;。小程序后台的安全类开关（分享验签、API 安全、消息推送加密等）开了以后，最好在项目文档里记一笔：开了什么、为什么开、代码要配合做什么。&lt;/li&gt;
&lt;li&gt;文档里的关键词是「&lt;strong&gt;降级&lt;/strong&gt;」。以后遇到「平台把我的内容换成了默认样式」这类现象，可以直接拿「降级」去搜。&lt;/li&gt;
&lt;/ul&gt;</description>
      <pubDate>Wed, 07 Oct 2026 04:03:55 GMT</pubDate>
    </item>
    <item>
      <title>小程序登录页切换输入框时键盘被收起的排查</title>
      <link>https://www.hqxiaozou.top/post/nAXgI6THHb4</link>
      <description>&lt;blockquote&gt;
&lt;p&gt;当前状态：3.1.0 已没有账号、密码两个输入框，登录改为主动勾选协议后的微信一键登录，邮箱是「我的」里的可选绑定项。本文保留的是旧版密码登录的真机排查记录，下面的密码框补救代码不用于当前登录页。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;&lt;img src="https://img.zxfhq.cn/blog/chuixiang/20261020/chuixiang-3-1-0-20261020-login.jpg" alt="当前登录入口：协议默认不勾选，不再输入账号密码"&gt;&lt;/p&gt;
&lt;p&gt;&lt;a href="https://www.hqxiaozou.top/post/bhEgnGf1Nq6"&gt;《炊想》&lt;/a&gt;旧版登录页只有两个输入框：账号和密码。就这么简单的一个页面，却出现了一个怪现象：&lt;strong&gt;键盘弹着的时候从账号框点到密码框，键盘被强制收起，密码框也没聚焦上，得再点一次才能输入。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;前后改了五个版本才弄清楚。前四次都在「猜」，每次的猜测都说得通，每次都没修好；第五次停下来先拿真机上的事实，一个下午就定位了。这篇把弯路和最后的方法都记下来。&lt;/p&gt;
&lt;h2 id="一、现象"&gt;一、现象&lt;/h2&gt;
&lt;p&gt;最早的反馈是：&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;点击密码框，第一次弹出输入框立马关闭了，需要再点击一次才弹出。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;后来补充得越来越具体：&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;直接点击密码输入框，正常弹出键盘；如果先点击了账号输入框，再点击密码输入框，就会闪一下并关闭键盘。&lt;/p&gt;
&lt;p&gt;只有登录页在键盘未收起时切换输入框会触发强制收起。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;几个关键信息：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;只在真机上出现，微信开发者工具的模拟器没有真的系统键盘，复现不了；&lt;/li&gt;
&lt;li&gt;安卓和苹果都有人遇到，最后用来排查的是一台&lt;strong&gt;小米 14&lt;/strong&gt;（Android 16，微信 8.0.78，基础库 3.17.3）；&lt;/li&gt;
&lt;li&gt;「找回密码」页看起来正常，&lt;strong&gt;登录页&lt;/strong&gt;最明显。&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id="二、四次弯路"&gt;二、四次弯路&lt;/h2&gt;
&lt;p&gt;每一次都有一个合理的怀疑，也都按怀疑改了：&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;版本&lt;/th&gt;
&lt;th&gt;怀疑&lt;/th&gt;
&lt;th&gt;改法&lt;/th&gt;
&lt;th&gt;结果&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;2.0.10&lt;/td&gt;
&lt;td&gt;聚焦 / 失焦回调里立刻 &lt;code&gt;setData&lt;/code&gt; 改高亮，和 &lt;code&gt;focus&lt;/code&gt; 属性抢焦点&lt;/td&gt;
&lt;td&gt;去掉 &lt;code&gt;focus&lt;/code&gt; 绑定，高亮延后 120 毫秒合并成一次刷新，失焦校验延后显示&lt;/td&gt;
&lt;td&gt;没好&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2.0.11&lt;/td&gt;
&lt;td&gt;系统认出「账号 + 密码」登录表单，挂上密码自动填充栏，键盘高度来回跳&lt;/td&gt;
&lt;td&gt;两个框中间塞一个看不见的密码框，让系统认不出来；加 &lt;code&gt;always-embed&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;没好&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2.0.12&lt;/td&gt;
&lt;td&gt;点第二个框的那一下被当成「点页面空白」，先收起了键盘&lt;/td&gt;
&lt;td&gt;重写成输入框组件：&lt;code&gt;hold-keyboard&lt;/code&gt;、聚焦时一次 &lt;code&gt;setData&lt;/code&gt; 都不做、根节点 &lt;code&gt;catchtap&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;没好&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2.0.13&lt;/td&gt;
&lt;td&gt;两个原生输入框同时在页面上，切换时互相影响&lt;/td&gt;
&lt;td&gt;页面上同时只渲染一个原生输入框，其余是普通 view，点哪一行才渲染哪一行；关掉 &lt;code&gt;adjust-position&lt;/code&gt;，键盘遮挡自己处理&lt;/td&gt;
&lt;td&gt;找回密码页正常，登录页还是不行&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;2.0.13 发版前用预览码在真机上试过——但只试了找回密码页。那一页的几个框，要么都是普通键盘，要么都是密码键盘，恰好碰不到问题；发版后用户在登录页一试，照旧。&lt;/p&gt;
&lt;p&gt;回头看，这四次的共同问题是：&lt;strong&gt;手里没有任何来自真机的数据&lt;/strong&gt;，只能根据现象推测，再用「改完看看」去验证。每猜错一次，就要发一版、等用户再测一次。&lt;/p&gt;
&lt;h2 id="三、先拿事实：真机上的事件日志"&gt;三、先拿事实：真机上的事件日志&lt;/h2&gt;
&lt;p&gt;第五次没有再改逻辑，而是先在预览版里加了一份日志：每个输入框的聚焦、失焦、键盘高度变化，以及代码里每一次主动收键盘，都按毫秒记下来。&lt;/p&gt;
&lt;p&gt;有两点要注意：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;记录时不能改界面。&lt;/strong&gt;日志只写进内存，不 &lt;code&gt;setData&lt;/code&gt;——这个问题本身就和「切换瞬间改界面」有关，排查工具不能反过来干扰它；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;只在开发版 / 体验版开启&lt;/strong&gt;，正式版什么都不记。登录页底部加一个「日志」按钮，点开才渲染，可以一键复制发过来。&lt;/li&gt;
&lt;/ol&gt;
&lt;pre&gt;&lt;code class="language-js"&gt;// utils/input-log.js
var ENABLED = config.ENV_VERSION !== &amp;#39;release&amp;#39;;
var lines = [];
var t0 = Date.now();

function log() {
  if (!ENABLED) return;
  var line = (Date.now() - t0) + &amp;#39;ms &amp;#39; + [].slice.call(arguments).join(&amp;#39; &amp;#39;);
  lines.push(line);
  if (lines.length &amp;gt; 80) lines.shift();
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;输入框组件里，聚焦、失焦、键盘高度三个回调只做一件事——记日志：&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-js"&gt;onFocusLog: function (e) {
  inputLog.log(&amp;#39;聚焦&amp;#39;, this.data.field, &amp;#39;键盘&amp;#39;, e.detail.height);
},
onBlurLog: function () {
  inputLog.log(&amp;#39;失焦&amp;#39;, this.data.field);
},
onKeyboardLog: function (e) {
  inputLog.log(&amp;#39;键盘高度&amp;#39;, this.data.field, e.detail.height);
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;为了方便在真机上测，预览版的登录页带上 &lt;code&gt;stay=1&lt;/code&gt; 参数时，即使已经登录也不跳走。&lt;/p&gt;
&lt;h2 id="四、日志说了什么"&gt;四、日志说了什么&lt;/h2&gt;
&lt;p&gt;用户在小米 14 上复现了一次，发回来的日志只有五行：&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-text"&gt;10234ms 聚焦 account 键盘 327
10235ms 键盘高度 account 327
13653ms 失焦 account
13683ms 键盘高度 account 0
13720ms 失焦 password
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这五行推翻了之前的所有猜测：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;密码框从头到尾没有「聚焦」这一条。&lt;/strong&gt;它不是「聚焦了又被收起」，而是根本没拿到键盘；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;键盘先变成 0，密码框才失焦。&lt;/strong&gt;是键盘没了，微信才让它失焦；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;整个过程里，代码一次都没主动收过键盘&lt;/strong&gt;（我们自己的收键盘会记一条「收键盘」）。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;也就是说，收起键盘的不是我们的页面逻辑，而是系统或微信那一层。前四次在页面逻辑上怎么改，都碰不到它。&lt;/p&gt;
&lt;h2 id="五、一张对照测试页"&gt;五、一张对照测试页&lt;/h2&gt;
&lt;p&gt;日志只告诉我们「不是页面逻辑」，还没说清楚是哪一层、什么条件。于是又做了一个只在预览里打开的测试页，把几种写法并排放：每组两个原生 &lt;code&gt;&amp;lt;input&amp;gt;&lt;/code&gt;，先点第 1 个，键盘弹出后直接点第 2 个。&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;组&lt;/th&gt;
&lt;th&gt;写法&lt;/th&gt;
&lt;th&gt;结果&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;默认属性：文本 → 密码&lt;/td&gt;
&lt;td&gt;❌ 收起&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;默认属性：文本 → 文本&lt;/td&gt;
&lt;td&gt;✅ 正常&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;默认属性：密码 → 密码&lt;/td&gt;
&lt;td&gt;✅ 正常&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;只开 &lt;code&gt;hold-keyboard&lt;/code&gt;：文本 → 密码&lt;/td&gt;
&lt;td&gt;❌ 收起&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;只关 &lt;code&gt;adjust-position&lt;/code&gt;：文本 → 密码&lt;/td&gt;
&lt;td&gt;❌ 收起（反过来密码 → 文本正常）&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;td&gt;两个都改（登录页原来的写法）：文本 → 密码&lt;/td&gt;
&lt;td&gt;❌ 收起&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;结论非常清楚：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;只有「文本框键盘弹着时，直接点密码框」会出问题&lt;/strong&gt;；文本 → 文本、密码 → 密码、密码 → 文本都正常；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;键盘收着的时候直接点密码框，也正常&lt;/strong&gt;；&lt;/li&gt;
&lt;li&gt;和 &lt;code&gt;hold-keyboard&lt;/code&gt;、&lt;code&gt;adjust-position&lt;/code&gt; 这些属性&lt;strong&gt;无关&lt;/strong&gt;——第 1 组什么都没加，照样收起。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;这也解释了之前的种种现象：找回密码页测的恰好不是「文本 → 密码」；登录页只有两个框，一切换必然是「文本 → 密码」。&lt;/p&gt;
&lt;p&gt;从现象看，原因在系统换键盘这一步：小米的澎湃 OS 默认给密码框用单独的「安全键盘」，从普通键盘直接切过去时，普通键盘先收起，微信就把这次切换当成了失焦。这一层不在小程序的控制范围里。&lt;/p&gt;
&lt;h2 id="六、能做的：先收起，等系统缓过来，再聚焦"&gt;六、能做的：先收起，等系统缓过来，再聚焦&lt;/h2&gt;
&lt;p&gt;既然「键盘收着时点密码框」是正常的，补救的思路就是把出问题的那一种切换，变成正常的那一种：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;在密码框上盖一层透明的 view，点密码框时先点到它；&lt;/li&gt;
&lt;li&gt;如果此刻是&lt;strong&gt;文本框的键盘弹着&lt;/strong&gt;，先 &lt;code&gt;wx.hideKeyboard()&lt;/code&gt;，等键盘高度变成 0；&lt;/li&gt;
&lt;li&gt;再用 &lt;code&gt;focus&lt;/code&gt; 属性把密码框拉起来；&lt;/li&gt;
&lt;li&gt;其他情况（键盘收着、从另一个密码框过来）直接聚焦。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;键盘状态记在组件模块级的变量里，所有输入框共用，同样不 &lt;code&gt;setData&lt;/code&gt;：&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-js"&gt;var keyboard = { field: &amp;#39;&amp;#39;, password: false, height: 0 };

onCoverTap: function () {
  var self = this;
  if (keyboard.field === self.data.field) return;      // 已经在这个框里输入
  if (keyboard.height &amp;gt; 0 &amp;amp;&amp;amp; keyboard.field &amp;amp;&amp;amp; !keyboard.password) {
    wx.hideKeyboard();                                 // 文本键盘弹着：先收起
    var waited = 0;
    var timer = setInterval(function () {
      waited += 40;
      if (keyboard.height === 0 || waited &amp;gt;= 900) {
        clearInterval(timer);
        setTimeout(self.focusSelf.bind(self), 600);    // 等系统缓过来
      }
    }, 40);
    return;
  }
  self.focusSelf();
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;第一版只等了 60 毫秒，日志马上说明了问题：&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-text"&gt;4428ms 键盘高度 account 0
4499ms 聚焦密码框 password
4580ms 失焦 password
5460ms 聚焦密码框 password     ← 用户第二次点
5978ms 聚焦 password 键盘 327
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;键盘收完 70 毫秒就去聚焦，又被收掉了；用户隔了一秒再点，就正常了。又测了一轮，收完后 360 毫秒聚焦也会失败，900 毫秒以后都正常。于是最终改成：收完等 600 毫秒再聚焦；如果聚焦后很快又失焦（一直没等到聚焦事件），每 300 毫秒自动重试一次，最多 4 次。&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-js"&gt;onBlurLog: function () {
  // 程序聚焦后马上又失焦：系统还没准备好，隔一会儿再拉一次
  if (this._autoFocusAt &amp;amp;&amp;amp; Date.now() - this._autoFocusAt &amp;lt; 700 &amp;amp;&amp;amp; this._retries &amp;lt; 4) {
    this._retries += 1;
    setTimeout(this.focusSelf.bind(this), 300);
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;代价是：从账号框点到密码框，键盘会落下、停一下、再弹出来，前后大约一秒。这不是理想的体验，但不用点第二次了。&lt;/p&gt;
&lt;h2 id="七、三个选项"&gt;七、三个选项&lt;/h2&gt;
&lt;p&gt;彻底避开的办法只有一个：&lt;strong&gt;同一个表单里不发生「文本框 → 密码框」的切换。&lt;/strong&gt;能想到的有三种：&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;方案&lt;/th&gt;
&lt;th&gt;做法&lt;/th&gt;
&lt;th&gt;代价&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;分两步&lt;/td&gt;
&lt;td&gt;第一步填账号，点「下一步」再填密码&lt;/td&gt;
&lt;td&gt;多一步操作；很多 App 的登录就是这样&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;明文输入&lt;/td&gt;
&lt;td&gt;密码框输入时用普通键盘明文显示，离开后显示圆点&lt;/td&gt;
&lt;td&gt;输入法可能联想、纠错、首字母自动大写，把密码改掉导致登录失败；旁人能看到&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;收起再弹出&lt;/td&gt;
&lt;td&gt;就是上一节的补救&lt;/td&gt;
&lt;td&gt;切换时有一下「收起又弹出」&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;当时选的是第三种：不改界面和流程，只在这一种切换上多一个约一秒的过渡。后来产品改成微信一键登录，账号密码表单随之删除，这条切换路径也不再存在；下面的结论仍可供需要密码输入的页面参考。&lt;/p&gt;
&lt;h2 id="八、几点体会"&gt;八、几点体会&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;真机才有的问题，先拿数据，再动代码。&lt;/strong&gt;这次前四个版本加起来花的时间，远多于「加日志 → 看日志 → 对照测试」这一个下午。日志里「密码框没有聚焦这一条」「我们没有收过键盘」两行，就把方向从页面逻辑扭到了系统层。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;排查工具不能干扰被排查的东西。&lt;/strong&gt;日志只写内存、不改界面，点开才渲染；测试页的回调里同样不 &lt;code&gt;setData&lt;/code&gt;。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;对照实验比推理快。&lt;/strong&gt;把「文本 → 密码」「文本 → 文本」「密码 → 密码」、加不加某个属性并排放，一轮就能排除掉一大半怀疑。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;验证要覆盖出问题的那条路径。&lt;/strong&gt;2.0.13 只在找回密码页验证过就发了，而真正出问题的是登录页的「文本 → 密码」。修好的判断标准应该是「原来能复现的步骤，现在复现不了」，而不是「随便点了几下没问题」。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;问清楚触发条件和设备。&lt;/strong&gt;「第一次点密码框会闪退」「先点账号再点密码会收起」「只有登录页、键盘没收起时切换」——描述每具体一点，离原因就近一步；设备型号最后也成了关键线索。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;有些问题只能绕，不能修。&lt;/strong&gt;确认是系统层的行为之后，就别再在页面逻辑上较劲了，把选项和代价摆出来，选一个能接受的。&lt;/li&gt;
&lt;/ul&gt;</description>
      <pubDate>Tue, 06 Oct 2026 09:40:08 GMT</pubDate>
    </item>
    <item>
      <title>用代码拍一支 24 秒的美食短片</title>
      <link>https://www.hqxiaozou.top/post/15r3ccv89CH</link>
      <description>&lt;blockquote&gt;
&lt;p&gt;这支短片记录的是 2026-10-09 那一轮宣传素材，视频、过程镜头和渲染参数保留为制作案例，不代表当前小程序的全部功能。按 3.1.0 最新代码，炊想已加入家人口味、库存、问做法、拍照识食材、菜单换菜与多菜统筹，首页推荐也改为每小时轮换；最新功能与权益见&lt;a href="https://www.hqxiaozou.top/post/bhEgnGf1Nq6"&gt;炊想介绍&lt;/a&gt;。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;&lt;img src="https://img.zxfhq.cn/blog/chuixiang/20261020/chuixiang-3-1-0-20261020-home.jpg" alt="当前炊想首页，旧宣传片之外的最新入口"&gt;&lt;/p&gt;
&lt;p&gt;&lt;a href="https://www.hqxiaozou.top/post/bhEgnGf1Nq6"&gt;《炊想》&lt;/a&gt;要发一条抖音宣传片。手边没有相机，没有剪辑软件，也没有音效库，最后整条片子都是代码写出来的：一个 HTML 页面画画面，无头 Chrome 逐帧截图，numpy 合成声音，ffmpeg 合成 mp4。&lt;/p&gt;
&lt;p&gt;前后做了五版，前四版都不满意。第五版的成片是这样的：&lt;/p&gt;
&lt;p&gt;&lt;video src="https://niu.hqxiaozou.top/img/promo-2026-10-09-promo-720.mp4" poster="https://niu.hqxiaozou.top/img/promo-2026-10-09-poster.jpg" controls playsinline preload="none" style="display:block;width:100%;max-width:360px;margin:0 auto;border-radius:12px;background:#000"&gt;&lt;/video&gt;&lt;/p&gt;
&lt;p&gt;这篇记录这套做法的几个关键环节：怎么让每一帧都可复现，静态图片怎么做出镜头感，渲染为什么慢、慢在哪里，没有素材怎么合成做菜的声音。最后一节是前四版为什么不行：比起技术，这一节的教训更重要。&lt;/p&gt;
&lt;h2 id="一、把视频写成一个-rendert"&gt;一、把视频写成一个 render(t)&lt;/h2&gt;
&lt;p&gt;最直接的想法是写一页 CSS 动画，然后录屏。这条路走不通：录屏是实时的，机器一卡就掉帧；CSS 动画、&lt;code&gt;requestAnimationFrame&lt;/code&gt; 都跟着真实时间走，同一段动画录两遍，每一帧都不一样。&lt;/p&gt;
&lt;p&gt;换个思路：页面里不放任何会自己动的东西，只暴露一个函数 &lt;code&gt;render(t)&lt;/code&gt;。给它一个时间（秒），它把页面上每个元素的位置、透明度、文字都按这个时间算好、写死。页面本身是静止的，时间由外面的脚本一格一格地推：&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-js"&gt;const FPS = 60;
await page.setViewport({ width: 1080, height: 1920, deviceScaleFactor: 1 });
await page.goto(&amp;#39;file://&amp;#39; + path.join(dir, &amp;#39;scene.html&amp;#39;), { waitUntil: &amp;#39;networkidle0&amp;#39; });
const total = Math.round(await page.evaluate(() =&amp;gt; window.DURATION) * FPS);
for (let i = from; i &amp;lt; to; i++) {
    await page.evaluate(t =&amp;gt; window.render(t), i / FPS);
    await page.screenshot({ path: `frames/${String(i).padStart(5, &amp;#39;0&amp;#39;)}.jpg`, type: &amp;#39;jpeg&amp;#39;, quality: 94 });
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;一台机器慢一点，只是渲染得慢，不会掉帧。为了保证「同一个 t 永远画出同一帧」，页面里有三条规矩：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;不用 CSS 动画和过渡&lt;/strong&gt;，也不用 &lt;code&gt;Date.now()&lt;/code&gt;；所有运动都是 t 的函数。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;随机数自己种&lt;/strong&gt;。蒸汽团块的位置、大小用的是固定种子的线性同余：&lt;code&gt;seed = seed * 16807 % 2147483647&lt;/code&gt;，每次打开页面都一样。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;截图前等字体和图片&lt;/strong&gt;。&lt;code&gt;networkidle0&lt;/code&gt; 不保证字体已经可用，所以再等 &lt;code&gt;document.fonts.ready&lt;/code&gt; 和每张图片的 &lt;code&gt;onload&lt;/code&gt;，并用 &lt;code&gt;document.fonts.check(&amp;#39;900 60px &amp;quot;Songti SC&amp;quot;&amp;#39;)&lt;/code&gt; 打一行日志确认。否则头几帧可能是用回退字体画的。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;有了这几条，好处马上就来了：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;可以只渲染一段&lt;/strong&gt;。第 18 秒的标题字号太大、伸出了屏幕，我改了 CSS，只重渲第 1040～1145 帧，再整体重新编码，不用从头来。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;可以并行&lt;/strong&gt;。整片 1440 帧拆成两半，两个进程各跑 720 帧，最后按帧号拼起来。（拼起来是不是逐像素一致，第三节末尾有个意外。）&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;可以先看关键帧&lt;/strong&gt;。正式渲染前传几个时间点，比如 &lt;code&gt;node render.js 3.6,8.55,18.5&lt;/code&gt;，只出这几张图，把排版问题先看掉。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;动画曲线只用几个小函数，所有动效都由它们拼出来：&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-js"&gt;function clamp(x) { return Math.max(0, Math.min(1, x)); }
function prog(t, a, b) { return clamp((t - a) / (b - a)); }          // t 在 [a, b] 里走到了几成
function expo(x) { x = clamp(x); return x === 1 ? 1 : 1 - Math.pow(2, -10 * x); }  // 快出慢收
function sine(x) { x = clamp(x); return -(Math.cos(Math.PI * x) - 1) / 2; }       // 两头缓
function eio(x) { x = clamp(x); return x &amp;lt; .5 ? 4 * x * x * x : 1 - Math.pow(-2 * x + 2, 3) / 2; }  // 三次缓入缓出
// 一个元素在 [a, b] 之间可见：i 秒淡入、o 秒淡出
function win(t, a, b, i, o) {
    if (t &amp;lt; a || t &amp;gt; b) return 0;
    const x = i ? expo((t - a) / i) : 1, y = o ? eio((b - t) / o) : 1;
    return Math.min(x, y);
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;比如字幕的三行依次从遮罩里滑上来：第 k 行的位移是 &lt;code&gt;(1 - expo(prog(t, a + 0.08k, a + 0.5 + 0.08k))) × 110%&lt;/code&gt;，每行比上一行晚 0.08 秒出发。&lt;/p&gt;
&lt;h2 id="二、静态图片怎么有「镜头感」"&gt;二、静态图片怎么有「镜头感」&lt;/h2&gt;
&lt;p&gt;素材是 11 张竖版静态图（下一节会说它们从哪来），要让它们看起来像一组镜头。每个镜头写成一行配置：&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-js"&gt;var SH = [
  { img: &amp;#39;s06_toss&amp;#39;,  a: 0.0, b: 1.0, s0: 1.16, s1: 1.05, y0: -20, y1: 10, in: &amp;#39;cut&amp;#39;, fl: 1 },
  { img: &amp;#39;s01_slice&amp;#39;, a: 3.5, b: 5.0, s0: 1.04, s1: 1.12, x0: 40, x1: -40, in: &amp;#39;whip&amp;#39; },
  { img: &amp;#39;s05_drop&amp;#39;,  a: 8.5, b: 10.0, s0: 1.0, s1: 1.1, in: &amp;#39;flash&amp;#39;, fl: 1 },
  { img: &amp;#39;s09_hero&amp;#39;,  a: 12.0, b: 15.0, s0: 1.0, s1: 1.1, in: &amp;#39;cut&amp;#39;, st: 1 },
  // …
];
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;a&lt;/code&gt;、&lt;code&gt;b&lt;/code&gt; 是起止时间，&lt;code&gt;s0→s1&lt;/code&gt; 是缩放，&lt;code&gt;x&lt;/code&gt;、&lt;code&gt;y&lt;/code&gt;、&lt;code&gt;r&lt;/code&gt; 是平移和旋转，&lt;code&gt;in&lt;/code&gt; 是入场方式，&lt;code&gt;fl&lt;/code&gt; 是火光闪烁强度，&lt;code&gt;st&lt;/code&gt; 是蒸汽强度。&lt;code&gt;render(t)&lt;/code&gt; 找到当前镜头，按 &lt;code&gt;sine(prog(t, a, b))&lt;/code&gt; 插值出变换。在这个基础上加了四种手法：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;切入时轻推&lt;/strong&gt;。硬切的镜头开头 0.35 秒从 1.05 倍缩回 1 倍。切点因此有一下「落地」的感觉，不像幻灯片换页。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;甩镜&lt;/strong&gt;。新镜头用 0.16 秒从右侧 420px 滑进来，上一镜在最后 0.1 秒往左甩出。同时给整个舞台套一个只在横向模糊的 SVG 滤镜：&lt;code&gt;&amp;lt;feGaussianBlur stdDeviation=&amp;quot;60 0&amp;quot;&amp;gt;&lt;/code&gt;，模糊量跟着速度走。CSS 的 &lt;code&gt;filter: blur()&lt;/code&gt; 只能四个方向一起糊，做不出这种运动模糊。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;闪白和火光&lt;/strong&gt;。牛肉下锅那一刻，叠一层暖白色、&lt;code&gt;mix-blend-mode: screen&lt;/code&gt; 的图层，0.25 秒内从 0.75 淡到 0。有火的镜头，亮度按 &lt;code&gt;1 + 0.07 * noise1(t, 23)&lt;/code&gt; 抖动；&lt;code&gt;noise1&lt;/code&gt; 是三个不同频率正弦的叠加，看起来像火苗一跳一跳。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;蒸汽&lt;/strong&gt;。28 个径向渐变的椭圆，按各自的速度往上飘、变大、淡出。整组挂一个 &lt;code&gt;feTurbulence&lt;/code&gt; + &lt;code&gt;feDisplacementMap&lt;/code&gt; + 模糊的滤镜，把圆团扭成一缕一缕的烟。&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id="标签要跟着图走"&gt;标签要跟着图走&lt;/h3&gt;
&lt;p&gt;一桌饭的镜头上有「主菜」「汤品」「配菜」「主食」四个标签，要贴在对应的碗上。可镜头一直在缩放，写死屏幕坐标，标签就会慢慢滑离碗。&lt;/p&gt;
&lt;p&gt;做法是标签坐标写在图上（0～1 的比例），每帧用和镜头一样的变换换算成屏幕坐标：&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-js"&gt;// 图是 1440×2160，中心对齐屏幕中心（540, 960）
var sx = 540 + (g.x - 0.5) * 1440 * T.sc + T.x;
var sy = 960 + (g.y - 0.5) * 2160 * T.sc + T.y;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;图上的比例坐标怎么量？用 sharp 给图叠一张带刻度的网格，打开看一眼，碗在 (0.30, 0.15) 就写 0.30、0.15。&lt;/p&gt;
&lt;h3 id="先放大，别让浏览器放大"&gt;先放大，别让浏览器放大&lt;/h3&gt;
&lt;p&gt;生成的图是 1024×1536。镜头层是 1440×2160，比屏幕大一圈，给平移和推近留余量；再乘上最多 1.16 倍的推近，原图实际要放大 1.4～1.6 倍。直接交给浏览器放大，用什么插值、要不要锐化都由不得自己，推近的镜头会发软。&lt;/p&gt;
&lt;p&gt;所以渲染前先用 sharp 把每张图放大到 1440×2160，用 Lanczos 插值再轻微锐化。浏览器拿到的图和镜头层一样大，渲染时只剩 1.0～1.16 倍的推近：&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-js"&gt;await sharp(src).resize(1440, 2160, { kernel: &amp;#39;lanczos3&amp;#39; })
    .sharpen({ sigma: 0.9, m1: 0.5, m2: 2 })
    .jpeg({ quality: 93, mozjpeg: true }).toFile(dst);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;第四版用的是 1024×1024 的方图，要铺满竖屏得放大到 2 倍左右，预放大也救不回来。素材的分辨率和画幅，最好在源头就选对。&lt;/p&gt;
&lt;h2 id="三、渲染为什么慢，慢在哪里"&gt;三、渲染为什么慢，慢在哪里&lt;/h2&gt;
&lt;p&gt;这台机器是 2013 年的 i7-4770HQ（4 核 8 线程）。24 秒、60fps 一共 1440 帧，两个进程各跑 720 帧，各用了 906 秒和 876 秒，差不多 15 分钟，平均每个进程 1.2 秒一帧。&lt;/p&gt;
&lt;p&gt;先看时间花在哪：&lt;code&gt;render(t)&lt;/code&gt; 本身几毫秒，几乎所有时间都花在 &lt;code&gt;page.screenshot&lt;/code&gt; 上，也就是 Chrome 把这一帧真正画出来、编码成图片的过程。按镜头分别量：&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th align="center"&gt;位置&lt;/th&gt;
&lt;th align="center"&gt;每帧&lt;/th&gt;
&lt;th align="center"&gt;说明&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td align="center"&gt;3.6 秒 切牛肉&lt;/td&gt;
&lt;td align="center"&gt;1156ms&lt;/td&gt;
&lt;td align="center"&gt;没有蒸汽&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td align="center"&gt;5.95 秒 甩镜中&lt;/td&gt;
&lt;td align="center"&gt;1625ms&lt;/td&gt;
&lt;td align="center"&gt;整个舞台套着横向模糊&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td align="center"&gt;12.6 秒 成品&lt;/td&gt;
&lt;td align="center"&gt;1275ms&lt;/td&gt;
&lt;td align="center"&gt;有蒸汽&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td align="center"&gt;22 秒 片尾&lt;/td&gt;
&lt;td align="center"&gt;983ms&lt;/td&gt;
&lt;td align="center"&gt;纯色背景 + 图标&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td align="center"&gt;12.6 秒 改存 PNG&lt;/td&gt;
&lt;td align="center"&gt;3269ms&lt;/td&gt;
&lt;td align="center"&gt;同一帧&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;甩镜最慢，这个合理，整屏模糊本来就贵。PNG 慢了将近三倍，这个也合理，1080×1920 的无损压缩很重，逐帧中间文件用 JPEG 质量 94 足够，最后还要再被 H.264 压一遍。&lt;/p&gt;
&lt;p&gt;不合理的是第一行：切牛肉的镜头里根本没有蒸汽，为什么和有蒸汽的成品镜头差不多慢？&lt;/p&gt;
&lt;p&gt;逐层关掉试：&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th align="center"&gt;3.6 秒这一帧&lt;/th&gt;
&lt;th align="center"&gt;每帧&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td align="center"&gt;原样&lt;/td&gt;
&lt;td align="center"&gt;1217ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td align="center"&gt;蒸汽层 &lt;code&gt;display: none&lt;/code&gt;&lt;/td&gt;
&lt;td align="center"&gt;509ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td align="center"&gt;再去掉胶片颗粒层&lt;/td&gt;
&lt;td align="center"&gt;376ms&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;蒸汽层一层就占了 700 多毫秒。没有蒸汽的镜头里，28 个椭圆的透明度全是 0，可滤镜挂在整组上。从耗时看，只要这一层还在渲染树里，Chrome 就照样在整片滤镜区域上算一遍湍流噪声、做一遍位移、再模糊一遍，算出来的是一片透明。&lt;/p&gt;
&lt;p&gt;改法只有一行：没有蒸汽的镜头，把整层藏起来。&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-js"&gt;var stOn = (cur &amp;gt;= 0 &amp;amp;&amp;amp; SH[cur].st) || 0;
// 没有蒸汽的镜头整层隐藏：滤镜挂在整层上，团块全透明时也照样逐像素计算
$(&amp;#39;steam&amp;#39;).style.display = stOn ? &amp;#39;&amp;#39; : &amp;#39;none&amp;#39;;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;改完要证明画面一个像素都没变。每隔 48 帧取一帧（共 30 帧），改前改后各截一张 PNG，比较 MD5：30 帧全部相同。整片重新跑一遍，两个进程分别用了 403 秒和 384 秒，原来是 906 秒和 876 秒，少了一半多；整片从 15 分钟降到不到 7 分钟。&lt;/p&gt;
&lt;p&gt;24 秒的片子里，有蒸汽的镜头只有 7.5 秒，剩下 16.5 秒白白多算了这一层。这和前端页面上的老问题是一回事：&lt;code&gt;opacity: 0&lt;/code&gt; 不等于不渲染，滤镜、&lt;code&gt;backdrop-filter&lt;/code&gt;、混合模式这些代价按面积算的东西，不用的时候要 &lt;code&gt;display: none&lt;/code&gt;。&lt;/p&gt;
&lt;h3 id="整片比对，67-帧对不上"&gt;整片比对，67 帧对不上&lt;/h3&gt;
&lt;p&gt;改完之后，我又把新渲染的 1440 帧和交付版逐帧比较字节：1373 帧完全相同，67 帧不同，集中在第 1072～1138 帧，也就是一桌饭的标签停稳之后。把两张图相减再放大 8 倍，差异只在标签的文字边缘，最大差 90 个色阶，放大才看得出。&lt;/p&gt;
&lt;p&gt;同一份代码、同一个 t，为什么画出来不一样？交付版的这一段是改了标题字号之后单独补渲的，起点和整片渲染不同。于是换几种起点画同一帧：&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th align="center"&gt;第 1100 帧怎么画的&lt;/th&gt;
&lt;th align="center"&gt;截图的 MD5&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td align="center"&gt;新开页面，直接画 1100&lt;/td&gt;
&lt;td align="center"&gt;2d8c54af&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td align="center"&gt;从 1040 一帧帧画到 1100&lt;/td&gt;
&lt;td align="center"&gt;b94e8950&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td align="center"&gt;从 1090 一帧帧画到 1100&lt;/td&gt;
&lt;td align="center"&gt;b94e8950&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;一帧的像素，和这个页面之前画过什么有关。&lt;/p&gt;
&lt;p&gt;出问题的标签同时满足两个条件：带 &lt;code&gt;backdrop-filter&lt;/code&gt;，会被单独提成一个合成层；入场动画的 &lt;code&gt;transform&lt;/code&gt; 里有 &lt;code&gt;scale&lt;/code&gt;，从 0.9 放大到 1。去掉任何一个，直接画和连续画就一致了。合成层是先画成位图再缩放、叠加的，Chrome 在层的缩放不断变化时怎么复用位图、怎么抗锯齿，和之前的状态有关。具体是哪一条规则，我没有再往下追。&lt;/p&gt;
&lt;p&gt;不改效果的修法也很简单：入场动画走完后，把 &lt;code&gt;transform&lt;/code&gt; 设成 &lt;code&gt;none&lt;/code&gt;，不要停在 &lt;code&gt;scale(1)&lt;/code&gt;：&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-js"&gt;S(e, on, pp &amp;lt; 1 ? &amp;#39;translateY(&amp;#39; + (1 - pp) * 24 + &amp;#39;px) scale(&amp;#39; + lerp(0.9, 1, pp) + &amp;#39;)&amp;#39; : &amp;#39;none&amp;#39;);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;改完，新开页面直接画和连续画出来的 PNG 一模一样。&lt;/p&gt;
&lt;p&gt;这点差异肉眼几乎看不见，可它说明「同一个 t 永远画出同一帧」在像素级上并不总是成立。要拿 MD5 做回归比对，两边的渲染历史得一样；凡是用到合成层缩放的元素，动画结束时最好回到 &lt;code&gt;none&lt;/code&gt;。&lt;/p&gt;
&lt;h2 id="四、没有音效库，就自己合成"&gt;四、没有音效库，就自己合成&lt;/h2&gt;
&lt;p&gt;美食广告里最抓人的声音往往不是音乐，而是做菜本身的声音：刀落砧板、油锅滋啦、铲子刮锅。这些声音同时还是剪辑的节拍，切一刀换一个镜头。&lt;/p&gt;
&lt;p&gt;我没有音效素材库，不想用来路不明的素材，就用 numpy 一个一个合成。所有声音都放进一个简单的混音器：&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-python"&gt;SR = 48000
L = np.zeros(N); R = np.zeros(N); RvL = np.zeros(N); RvR = np.zeros(N)
def put(sig, at, g=1.0, pan=0.0, rev=0.0):
    &amp;quot;&amp;quot;&amp;quot;把一段声音放到第 at 秒：g 是音量，pan 是左右（-1～1），rev 是送进混响的比例&amp;quot;&amp;quot;&amp;quot;
    s = int(round(at * SR)); e = min(N, s + len(sig)); seg = sig[:e - s] * g
    gl, gr = np.cos((pan + 1) * np.pi / 4) * 1.414, np.sin((pan + 1) * np.pi / 4) * 1.414  # 等功率声像
    L[s:e] += seg * gl; R[s:e] += seg * gr
    if rev: RvL[s:e] += seg * gl * rev; RvR[s:e] += seg * gr * rev
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;滤波统一用一个 FFT 函数：变换到频域，乘一条四阶的高通、低通幅度曲线，再变回来。离线处理，不在乎相位和实时性，比写 IIR 简单得多：&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-python"&gt;def filt(x, lo=None, hi=None):
    X = np.fft.rfft(x); f = np.fft.rfftfreq(len(x), 1 / SR); g = np.ones_like(f)
    if hi: g *= 1 / np.sqrt(1 + (f / hi) ** 4)
    if lo: g *= 1 / np.sqrt(1 + (lo / np.maximum(f, 1e-3)) ** 4)
    return np.fft.irfft(X * g, len(x))
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 id="油锅的滋啦声"&gt;油锅的滋啦声&lt;/h3&gt;
&lt;p&gt;滋啦声的本质是成千上万个油点在随机的时刻炸开。所以用泊松过程撒脉冲：每秒约 2500 个，位置随机，幅度服从指数分布、正负随机。再滤成 1.8k～9kHz 的「噼啪」，叠一层 3.5k～12kHz 的「嘶」：&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-python"&gt;def sizzle(dur, intensity=1.0, attack=0.02, decay=1.2):
    n = int(dur * SR); tt = np.arange(n) / SR
    k = rng.poisson(2500 * intensity * dur)                      # 这段时间里炸开多少个油点
    imp = np.zeros(n)
    np.add.at(imp, rng.integers(0, n, k), rng.exponential(1.0, k) * rng.choice([-1, 1], k))
    crack = filt(imp, lo=1800, hi=9000) * 0.12                   # 噼啪
    hiss = filt(rng.standard_normal(n), lo=3500, hi=12000) * 0.05  # 嘶
    env = np.minimum(1, tt / attack) * np.exp(-tt / decay) * 0.7 + 0.3 * np.exp(-tt / (decay * 2.5))
    return (crack + hiss) * env
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;intensity&lt;/code&gt; 控制密度。牛肉下锅那一下是 1.6，最响最密；成品镜头里的余温是 0.25，只剩零星几声。&lt;/p&gt;
&lt;h3 id="刀落砧板"&gt;刀落砧板&lt;/h3&gt;
&lt;p&gt;一刀下去是三个声音的叠加：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;刀和板子撞击的低频闷响，是一个从 155Hz 快速掉到 95Hz 的正弦；&lt;/li&gt;
&lt;li&gt;刀刃的「咔」，是 1.5k～5kHz 的噪声，几毫秒就衰减完；&lt;/li&gt;
&lt;li&gt;木板自己的短共振，是 410Hz 和 690Hz 两个衰减很快的正弦。&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code class="language-python"&gt;def chop(g=1.0):
    n = int(0.25 * SR); tt = np.arange(n) / SR
    thump = np.sin(2 * np.pi * (95 + 60 * np.exp(-tt * 60)) * tt) * np.exp(-tt * 38)
    click = filt(rng.standard_normal(n), lo=1500, hi=5000) * np.exp(-tt * 180) * 0.6
    res = np.sin(2 * np.pi * 410 * tt) * np.exp(-tt * 55) * 0.25 + np.sin(2 * np.pi * 690 * tt) * np.exp(-tt * 70) * 0.15
    return (thump * 0.9 + click + res) * 0.8 * g
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;其余几个声音也是同样的思路：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;点火&lt;/strong&gt;：三声极短的高频「咔」，接一团 60～700Hz 的低频噪声快速涌起、慢慢退下，再叠 11Hz 的颤动；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;淋汁&lt;/strong&gt;：低通噪声乘一条慢速随机的包络，听起来一股一股的；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;刮盘&lt;/strong&gt;：带通噪声乘一个 34Hz 的方波做颗粒感，再加几个金属共振频率；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;收尾的拨弦&lt;/strong&gt;：Karplus-Strong 算法，一小段随机噪声在延迟线里反复取平均，高频越磨越少，就成了琴弦的音色。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;混响也是合成的：用一段按指数衰减的滤波噪声当冲激响应，和需要混响的声音做 FFT 卷积。&lt;/p&gt;
&lt;h3 id="声音对着镜头放"&gt;声音对着镜头放&lt;/h3&gt;
&lt;p&gt;声音的时间线和镜头表是同一套时间：3.5 秒切牛肉三刀（3.5、4.0、4.5），5 秒切辣椒四刀快刀，7.5 秒点火，8.5 秒下锅，10 秒淋汁，11 秒刮盘，12.5 秒计时器到 15:00 时「叮」一声。3.5～12 秒底下垫一层很轻的 120 BPM 底鼓和沙锤，镜头都切在拍点上。&lt;/p&gt;
&lt;p&gt;&lt;img src="https://niu.hqxiaozou.top/img/promo-2026-10-09-spectrum.jpg" alt="整条声音的频谱：横轴是时间，纵轴是频率"&gt;&lt;/p&gt;
&lt;p&gt;频谱上能直接看出结构：前半段一根根竖线是刀声，大片的高频是滋啦，12 秒以后低处出现横向的条纹，是和弦铺底。&lt;/p&gt;
&lt;p&gt;最后做响度归一化，目标是流媒体常用的 -14 LUFS。ffmpeg 的 &lt;code&gt;loudnorm&lt;/code&gt; 用两遍：第一遍只测量，第二遍带上测量值、加 &lt;code&gt;linear=true&lt;/code&gt;，整条只乘一个增益，不做动态压缩，刀声和滋啦的冲击感就不会被压扁（前提是峰值有余量，否则它会自动退回动态模式）：&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-python"&gt;out = run([F, &amp;#39;-i&amp;#39;, &amp;#39;audio_raw.wav&amp;#39;, &amp;#39;-af&amp;#39;, &amp;#39;loudnorm=I=-14:TP=-1.5:LRA=11:print_format=json&amp;#39;, &amp;#39;-f&amp;#39;, &amp;#39;null&amp;#39;, &amp;#39;-&amp;#39;])
m = json.loads(out[out.rindex(&amp;#39;{&amp;#39;):out.rindex(&amp;#39;}&amp;#39;) + 1])
af = (f&amp;quot;loudnorm=I=-14:TP=-1.5:LRA=11:measured_I={m[&amp;#39;input_i&amp;#39;]}:measured_TP={m[&amp;#39;input_tp&amp;#39;]}&amp;quot;
      f&amp;quot;:measured_LRA={m[&amp;#39;input_lra&amp;#39;]}:measured_thresh={m[&amp;#39;input_thresh&amp;#39;]}:offset={m[&amp;#39;target_offset&amp;#39;]}:linear=true&amp;quot;)
run([F, &amp;#39;-y&amp;#39;, &amp;#39;-i&amp;#39;, &amp;#39;audio_raw.wav&amp;#39;, &amp;#39;-af&amp;#39;, af, &amp;#39;-ar&amp;#39;, &amp;#39;48000&amp;#39;, &amp;#39;audio.wav&amp;#39;])
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;成品是 -14.6 LUFS，峰值 -1.3 dBTP。整条声音合成只要 4.6 秒。&lt;/p&gt;
&lt;p&gt;合成声音没法边听边调，每改一个参数都要重跑一遍。所以检查分两步：先看频谱图、&lt;code&gt;ebur128&lt;/code&gt; 测出来的响度和峰值，以及各个声音之间的电平比例，确认结构和音量没问题；最后再整条听。听感这件事没法靠数字保证，所以另外出了一条静音音轨的版本，不满意可以直接在抖音曲库里换配乐。&lt;/p&gt;
&lt;h2 id="五、编码"&gt;五、编码&lt;/h2&gt;
&lt;pre&gt;&lt;code class="language-bash"&gt;ffmpeg -framerate 60 -i frames/%05d.jpg -i audio.wav \
  -c:v libx264 -preset slow -crf 16 -pix_fmt yuv420p -profile:v high -level 4.2 \
  -g 120 -bf 2 -r 60 \
  -c:a aac -b:a 192k -ar 48000 -shortest -movflags +faststart out.mp4
&lt;/code&gt;&lt;/pre&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;yuv420p&lt;/code&gt;：不指定的话 x264 会沿用输入的像素格式：JPEG 序列是全范围的 &lt;code&gt;yuvj420p&lt;/code&gt;，PNG 序列会编成 4:4:4，都有手机播放器处理不好。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;-crf 16&lt;/code&gt;：平台还会再转码一遍，上传的片子质量留得高一点，二次压缩后不至于糊成一片。成片 24 秒 36.5MB，约 12Mbps。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;-g 120&lt;/code&gt;：每 2 秒一个关键帧，拖动进度条时定位快。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;+faststart&lt;/code&gt;：把索引挪到文件开头，边下边播。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;静音版不用重新编码画面，直接拷贝视频流，配一条空音轨：&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-bash"&gt;ffmpeg -i out.mp4 -f lavfi -i anullsrc=channel_layout=stereo:sample_rate=48000 \
  -map 0:v -map 1:a -c:v copy -c:a aac -b:a 128k -shortest -movflags +faststart out_silent.mp4
&lt;/code&gt;&lt;/pre&gt;
&lt;h2 id="六、前四版为什么不行"&gt;六、前四版为什么不行&lt;/h2&gt;
&lt;p&gt;上面这些技术，前四版大部分都用上了：&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th align="center"&gt;版本&lt;/th&gt;
&lt;th align="center"&gt;时长&lt;/th&gt;
&lt;th align="center"&gt;素材&lt;/th&gt;
&lt;th align="center"&gt;做法&lt;/th&gt;
&lt;th align="center"&gt;问题&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td align="center"&gt;一&lt;/td&gt;
&lt;td align="center"&gt;30 秒&lt;/td&gt;
&lt;td align="center"&gt;界面截图&lt;/td&gt;
&lt;td align="center"&gt;截图依次滑过 + 字幕&lt;/td&gt;
&lt;td align="center"&gt;没有声音，像翻截图&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td align="center"&gt;二&lt;/td&gt;
&lt;td align="center"&gt;66 秒&lt;/td&gt;
&lt;td align="center"&gt;菜品成品图&lt;/td&gt;
&lt;td align="center"&gt;纪录片式旁白、金色宋体&lt;/td&gt;
&lt;td align="center"&gt;太长，节奏慢&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td align="center"&gt;三&lt;/td&gt;
&lt;td align="center"&gt;21 秒&lt;/td&gt;
&lt;td align="center"&gt;界面 + 成品图&lt;/td&gt;
&lt;td align="center"&gt;产品演示、60fps&lt;/td&gt;
&lt;td align="center"&gt;画面不精致，开头不抓人&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td align="center"&gt;四&lt;/td&gt;
&lt;td align="center"&gt;21 秒&lt;/td&gt;
&lt;td align="center"&gt;1024 方形成品图&lt;/td&gt;
&lt;td align="center"&gt;匹配剪辑、视差、蒸汽&lt;/td&gt;
&lt;td align="center"&gt;还是照片在动&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td align="center"&gt;五&lt;/td&gt;
&lt;td align="center"&gt;24 秒&lt;/td&gt;
&lt;td align="center"&gt;生成的过程镜头&lt;/td&gt;
&lt;td align="center"&gt;美食广告结构 + 做菜声&lt;/td&gt;
&lt;td align="center"&gt;—&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;第四版的动效其实最复杂：八道菜按盘子的圆心和半径对齐，在同一个圆窗里一拍一切；同一张图拆成两层做 2.5D 视差；整套合成配乐。看下来还是很一般。&lt;/p&gt;
&lt;p&gt;回头看，问题不在动效，在素材：前四版只有一张张成品图，菜已经做好了，摆在盘子里。不管怎么推、怎么切、怎么加蒸汽，观众看到的都是「几张菜的照片在动」。&lt;/p&gt;
&lt;p&gt;于是去查美食广告到底怎么拍。通行的做法可以归成三条：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;过程镜头&lt;/strong&gt;：切、下锅、颠锅、淋汁，让人看到菜是怎么变好吃的；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;微距质感&lt;/strong&gt;：切开的辣椒籽、挂着汁的肉片，近到能看见油光；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;声音做节奏&lt;/strong&gt;：刀声、滋啦声本身就是节拍，剪辑点卡在声音上。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;前四版一条都没做到，因为手里根本没有过程镜头。所以第五版先补素材：用炊想后端本来就在用的生图接口，按菜谱里的真实步骤生成了 11 张竖版过程镜头。&lt;/p&gt;
&lt;p&gt;&lt;img src="https://niu.hqxiaozou.top/img/promo-2026-10-09-shots.jpg" alt="11 张生成的过程镜头：切肉、备料、辣椒、点火、下锅、颠锅、淋汁、出锅、成品、夹起、一桌饭"&gt;&lt;/p&gt;
&lt;p&gt;几个经验：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;统一的风格后缀&lt;/strong&gt;。每条提示词前半句写镜头内容，后半句完全一样：电影感美食广告摄影、竖构图、暗调暖光、浅景深、高细节，不要文字、水印和人脸。11 张图的光线和色调因此能接得上。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;菜要和菜谱对得上&lt;/strong&gt;。最后一张「一桌饭」，配的是菜谱里真实的搭配：凉拌木耳、冬瓜虾皮汤、糙米饭。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;接口有尺寸上限&lt;/strong&gt;。请求 1440×2560，返回的还是 1024×1536，所以才有第二节的预放大。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;每张 25～40 秒&lt;/strong&gt;，最慢的一张 132 秒，11 张一共约 7 分半。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;有了过程镜头，片子的结构就照美食广告来：&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th align="center"&gt;时间&lt;/th&gt;
&lt;th align="center"&gt;画面&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td align="center"&gt;0～2 秒&lt;/td&gt;
&lt;td align="center"&gt;颠锅的火焰「今晚，」→ 成品「吃这个。」&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td align="center"&gt;2～3.5 秒&lt;/td&gt;
&lt;td align="center"&gt;片名：小炒黄牛肉，从切肉到上桌，十五分钟&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td align="center"&gt;3.5～12 秒&lt;/td&gt;
&lt;td align="center"&gt;七个步骤，一步一个镜头，计时器 00:00 走到 15:00&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td align="center"&gt;12～16.5 秒&lt;/td&gt;
&lt;td align="center"&gt;成品「香辣鲜嫩，十五分钟端上桌。」→ 夹起「趁热。」&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td align="center"&gt;16.5～20.5 秒&lt;/td&gt;
&lt;td align="center"&gt;一桌饭的标注，搜索框打出「小炒黄牛肉」&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td align="center"&gt;20.5～24 秒&lt;/td&gt;
&lt;td align="center"&gt;炊想图标和标语，停留 3 秒以上&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;&lt;img src="https://niu.hqxiaozou.top/img/promo-2026-10-09-frames.jpg" alt="成片关键帧"&gt;&lt;/p&gt;
&lt;p&gt;开头第一帧就是火焰，不放图标、不放片名，前两秒先把人留住。字幕用的是菜谱里的原文步骤，比如「大火滑炒 40 秒，肉片七成熟立刻盛出」，静音看也能看懂这道菜怎么做。&lt;/p&gt;
&lt;p&gt;画面是生成的，不是实拍。片尾写了「菜谱仅供参考 · 画面为示意」，发布时在抖音的「作品声明」里也要如实勾选。&lt;/p&gt;
&lt;h2 id="小结"&gt;小结&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;**视频写成 &lt;code&gt;render(t)&lt;/code&gt;**：页面里不放任何会自己动的东西，随机数用固定种子，截图前等字体。这样每一帧都能单独重画，可以只重渲一段，也可以拆开并行。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;先放大，别让浏览器放大&lt;/strong&gt;：素材预先用 Lanczos 放大到镜头层的尺寸，渲染时只剩少量推近。&lt;/li&gt;
&lt;li&gt;**按面积算钱的效果，不用就 &lt;code&gt;display: none&lt;/code&gt;**：透明的东西照样会渲染。这一行让无蒸汽镜头每帧从 1.2 秒降到 0.5 秒，画面一个像素都没变。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;逐帧渲染也不是绝对确定的&lt;/strong&gt;：带 &lt;code&gt;backdrop-filter&lt;/code&gt; 又在做缩放动画的元素，同一帧的像素和之前画过什么有关；动画结束时把 &lt;code&gt;transform&lt;/code&gt; 设回 &lt;code&gt;none&lt;/code&gt;。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;声音可以合成&lt;/strong&gt;：油锅是泊松随机脉冲加高频噪声，刀声是下滑正弦加噪声加共振；响度用两遍 &lt;code&gt;loudnorm&lt;/code&gt; 加 &lt;code&gt;linear=true&lt;/code&gt; 归到 -14 LUFS。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;动效救不了素材&lt;/strong&gt;：先想清楚观众要看什么（过程、质感、声音），再去补素材；镜头和动效是最后一步。&lt;/li&gt;
&lt;/ul&gt;</description>
      <pubDate>Fri, 09 Oct 2026 04:50:03 GMT</pubDate>
    </item>
    <item>
      <title>手机上被撑破的搜索弹层</title>
      <link>https://www.hqxiaozou.top/post/wz3Nyg9bDx5</link>
      <description>&lt;p&gt;10 月 8 日给博客的搜索弹层加了两组建议：热门标签和最近 5 篇文章，打开搜索不用输入就能直接点。我在 1440 宽的电脑上截了图，排得整整齐齐，就发版了。&lt;/p&gt;&#xD;
&lt;p&gt;没过多久收到一张手机截图：标签那一行最右边的「面试」只露出半个字，最近文章的标题直接伸出了弹层的右边框，「后台的『分享签名校验』」整段被切掉。&lt;/p&gt;&#xD;
&lt;p&gt;问题不难修，难堪的是它本来一眼就能看到——我根本没在手机尺寸下打开过这个弹层。这篇把这次踩到的三个坑记下来，最后附上之后用来量这类问题的办法。&lt;/p&gt;&#xD;
&lt;h2 id="-"&gt;现象：电脑上正常，手机上越界&lt;/h2&gt;&#xD;
&lt;p&gt;先用脚本量一遍线上版本：打开弹层，数一数弹层里有多少个元素的边框超出了弹层本身。&lt;/p&gt;&#xD;
&lt;table&gt;&#xD;
&lt;thead&gt;&#xD;
&lt;tr&gt;&#xD;
&lt;th style="text-align:center"&gt;视口&lt;/th&gt;&#xD;
&lt;th style="text-align:center"&gt;弹层宽&lt;/th&gt;&#xD;
&lt;th style="text-align:center"&gt;越界元素&lt;/th&gt;&#xD;
&lt;/tr&gt;&#xD;
&lt;/thead&gt;&#xD;
&lt;tbody&gt;&#xD;
&lt;tr&gt;&#xD;
&lt;td style="text-align:center"&gt;360×780&lt;/td&gt;&#xD;
&lt;td style="text-align:center"&gt;320px&lt;/td&gt;&#xD;
&lt;td style="text-align:center"&gt;20 个&lt;/td&gt;&#xD;
&lt;/tr&gt;&#xD;
&lt;tr&gt;&#xD;
&lt;td style="text-align:center"&gt;320×568&lt;/td&gt;&#xD;
&lt;td style="text-align:center"&gt;280px&lt;/td&gt;&#xD;
&lt;td style="text-align:center"&gt;20 个&lt;/td&gt;&#xD;
&lt;/tr&gt;&#xD;
&lt;tr&gt;&#xD;
&lt;td style="text-align:center"&gt;390×664&lt;/td&gt;&#xD;
&lt;td style="text-align:center"&gt;350px&lt;/td&gt;&#xD;
&lt;td style="text-align:center"&gt;19 个&lt;/td&gt;&#xD;
&lt;/tr&gt;&#xD;
&lt;tr&gt;&#xD;
&lt;td style="text-align:center"&gt;1440×900&lt;/td&gt;&#xD;
&lt;td style="text-align:center"&gt;580px&lt;/td&gt;&#xD;
&lt;td style="text-align:center"&gt;0 个&lt;/td&gt;&#xD;
&lt;/tr&gt;&#xD;
&lt;/tbody&gt;&#xD;
&lt;/table&gt;&#xD;
&lt;p&gt;电脑上一个都没有，手机上全军覆没。最长的那篇标题单行排开约 518px：比电脑上的 580px 弹层窄，比手机上的任何一个都宽。所以这个问题在电脑上根本不会出现。&lt;/p&gt;&#xD;
&lt;h2 id="-auto-"&gt;一、网格的 auto 列，不会比内容窄&lt;/h2&gt;&#xD;
&lt;p&gt;建议区是这样写的：&lt;/p&gt;&#xD;
&lt;pre&gt;&lt;code class="lang-css"&gt;&lt;span class="hljs-selector-class"&gt;.search-suggest&lt;/span&gt; { &lt;span class="hljs-attribute"&gt;display&lt;/span&gt;: grid; &lt;span class="hljs-attribute"&gt;gap&lt;/span&gt;: &lt;span class="hljs-number"&gt;14px&lt;/span&gt;; }&#xD;
&lt;span class="hljs-selector-class"&gt;.search-suggest-posts&lt;/span&gt; { &lt;span class="hljs-attribute"&gt;display&lt;/span&gt;: grid; &lt;span class="hljs-attribute"&gt;gap&lt;/span&gt;: &lt;span class="hljs-number"&gt;2px&lt;/span&gt;; }&#xD;
&lt;span class="hljs-selector-class"&gt;.search-suggest-posts&lt;/span&gt; &lt;span class="hljs-selector-tag"&gt;a&lt;/span&gt; {&#xD;
    &lt;span class="hljs-attribute"&gt;display&lt;/span&gt;: block;&#xD;
    &lt;span class="hljs-attribute"&gt;overflow&lt;/span&gt;: hidden;&#xD;
    &lt;span class="hljs-attribute"&gt;white-space&lt;/span&gt;: nowrap;&#xD;
    &lt;span class="hljs-attribute"&gt;text-overflow&lt;/span&gt;: ellipsis;&#xD;
}&#xD;
&lt;/code&gt;&lt;/pre&gt;&#xD;
&lt;p&gt;本意是标题太长就在末尾显示省略号。可省略号一个都没出现，标题反而把整块撑了出去。&lt;/p&gt;&#xD;
&lt;p&gt;原因在 &lt;code&gt;display: grid&lt;/code&gt; 没写 &lt;code&gt;grid-template-columns&lt;/code&gt;。这时网格只有一条隐式的列，宽度是 &lt;code&gt;auto&lt;/code&gt;。auto 列有个下限：不能比里面内容的「最小内容宽度」更窄。而一段 &lt;code&gt;nowrap&lt;/code&gt; 的文字不能在任何地方断开，它的最小内容宽度就是整行的长度。&lt;/p&gt;&#xD;
&lt;p&gt;于是标题有多长，这一列就有多宽。链接自己的 &lt;code&gt;overflow: hidden&lt;/code&gt; 根本没机会生效：它拿到的宽度本来就放得下整行，没有什么可截的。&lt;/p&gt;&#xD;
&lt;p&gt;用一个 300px 宽的容器单独复现，量到的数字是这样的：&lt;/p&gt;&#xD;
&lt;table&gt;&#xD;
&lt;thead&gt;&#xD;
&lt;tr&gt;&#xD;
&lt;th style="text-align:center"&gt;写法&lt;/th&gt;&#xD;
&lt;th style="text-align:center"&gt;列宽&lt;/th&gt;&#xD;
&lt;th style="text-align:center"&gt;链接宽&lt;/th&gt;&#xD;
&lt;/tr&gt;&#xD;
&lt;/thead&gt;&#xD;
&lt;tbody&gt;&#xD;
&lt;tr&gt;&#xD;
&lt;td style="text-align:center"&gt;默认 auto 列&lt;/td&gt;&#xD;
&lt;td style="text-align:center"&gt;518px&lt;/td&gt;&#xD;
&lt;td style="text-align:center"&gt;518px&lt;/td&gt;&#xD;
&lt;/tr&gt;&#xD;
&lt;tr&gt;&#xD;
&lt;td style="text-align:center"&gt;只改外层&lt;/td&gt;&#xD;
&lt;td style="text-align:center"&gt;300px&lt;/td&gt;&#xD;
&lt;td style="text-align:center"&gt;518px&lt;/td&gt;&#xD;
&lt;/tr&gt;&#xD;
&lt;tr&gt;&#xD;
&lt;td style="text-align:center"&gt;两层都改&lt;/td&gt;&#xD;
&lt;td style="text-align:center"&gt;300px&lt;/td&gt;&#xD;
&lt;td style="text-align:center"&gt;300px&lt;/td&gt;&#xD;
&lt;/tr&gt;&#xD;
&lt;/tbody&gt;&#xD;
&lt;/table&gt;&#xD;
&lt;p&gt;第二行是我修第一遍时踩的：外层网格改成 &lt;code&gt;minmax(0, 1fr)&lt;/code&gt; 之后，外层的列确实缩回了 300px。可最近文章的列表 &lt;code&gt;ul&lt;/code&gt; 自己也是一个网格，它的列还是 auto，链接照样是 518px。线上量出来的越界元素从 20 个降到 10 个，没有归零。&lt;/p&gt;&#xD;
&lt;p&gt;两层都改过之后，列宽回到 300px，省略号也出来了：&lt;/p&gt;&#xD;
&lt;pre&gt;&lt;code class="lang-css"&gt;&lt;span class="hljs-selector-class"&gt;.search-suggest&lt;/span&gt; {&#xD;
    &lt;span class="hljs-attribute"&gt;display&lt;/span&gt;: grid;&#xD;
    &lt;span class="hljs-attribute"&gt;grid-template-columns&lt;/span&gt;: &lt;span class="hljs-built_in"&gt;minmax&lt;/span&gt;(0, 1fr);&#xD;
}&#xD;
&lt;span class="hljs-selector-class"&gt;.search-suggest-posts&lt;/span&gt; {&#xD;
    &lt;span class="hljs-attribute"&gt;display&lt;/span&gt;: grid;&#xD;
    &lt;span class="hljs-attribute"&gt;grid-template-columns&lt;/span&gt;: &lt;span class="hljs-built_in"&gt;minmax&lt;/span&gt;(0, 1fr);&#xD;
}&#xD;
&lt;span class="hljs-selector-class"&gt;.search-suggest-posts&lt;/span&gt; &lt;span class="hljs-selector-tag"&gt;li&lt;/span&gt; { &lt;span class="hljs-attribute"&gt;min-width&lt;/span&gt;: &lt;span class="hljs-number"&gt;0&lt;/span&gt;; }&#xD;
&lt;/code&gt;&lt;/pre&gt;&#xD;
&lt;p&gt;&lt;code&gt;minmax(0, 1fr)&lt;/code&gt; 的意思是：这一列最小可以是 0，最大占满剩余空间。下限从「内容的最小宽度」改成了 0，列就会老老实实服从容器的宽度。&lt;/p&gt;&#xD;
&lt;p&gt;标签那一行被切掉也是同一个原因。标签是 &lt;code&gt;flex-wrap: wrap&lt;/code&gt;，按容器宽度换行，可它的容器已经被标题撑到了 518px，标签就排到 518px 才换行。手机上看，就是最右边那个标签被弹层的边框切掉。&lt;/p&gt;&#xD;
&lt;p&gt;flex 布局里也有同样的规则：flex 子项默认 &lt;code&gt;min-width: auto&lt;/code&gt;，同样不会比内容窄。碰到不换行的长文字、长网址、长代码，要么给子项写 &lt;code&gt;min-width: 0&lt;/code&gt;，要么给列写 &lt;code&gt;minmax(0, 1fr)&lt;/code&gt;。&lt;/p&gt;&#xD;
&lt;h2 id="-"&gt;二、两行截断，漏出了第三行的半截&lt;/h2&gt;&#xD;
&lt;p&gt;列宽对了，省略号也出来了，可手机上 5 篇标题被截掉 4 篇，每篇只剩一行，长标题的后半句全看不到。&lt;/p&gt;&#xD;
&lt;table&gt;&#xD;
&lt;thead&gt;&#xD;
&lt;tr&gt;&#xD;
&lt;th style="text-align:center"&gt;视口&lt;/th&gt;&#xD;
&lt;th style="text-align:center"&gt;单行省略&lt;/th&gt;&#xD;
&lt;th style="text-align:center"&gt;两行截断&lt;/th&gt;&#xD;
&lt;/tr&gt;&#xD;
&lt;/thead&gt;&#xD;
&lt;tbody&gt;&#xD;
&lt;tr&gt;&#xD;
&lt;td style="text-align:center"&gt;360×780&lt;/td&gt;&#xD;
&lt;td style="text-align:center"&gt;截 4 篇&lt;/td&gt;&#xD;
&lt;td style="text-align:center"&gt;截 1 篇&lt;/td&gt;&#xD;
&lt;/tr&gt;&#xD;
&lt;tr&gt;&#xD;
&lt;td style="text-align:center"&gt;320×568&lt;/td&gt;&#xD;
&lt;td style="text-align:center"&gt;截 5 篇&lt;/td&gt;&#xD;
&lt;td style="text-align:center"&gt;截 2 篇&lt;/td&gt;&#xD;
&lt;/tr&gt;&#xD;
&lt;tr&gt;&#xD;
&lt;td style="text-align:center"&gt;390×664&lt;/td&gt;&#xD;
&lt;td style="text-align:center"&gt;截 4 篇&lt;/td&gt;&#xD;
&lt;td style="text-align:center"&gt;截 0 篇&lt;/td&gt;&#xD;
&lt;/tr&gt;&#xD;
&lt;/tbody&gt;&#xD;
&lt;/table&gt;&#xD;
&lt;p&gt;于是改成最多显示两行：&lt;/p&gt;&#xD;
&lt;pre&gt;&lt;code class="lang-css"&gt;&lt;span class="hljs-selector-class"&gt;.search-suggest-posts&lt;/span&gt; &lt;span class="hljs-selector-tag"&gt;a&lt;/span&gt; {&#xD;
    &lt;span class="hljs-attribute"&gt;display&lt;/span&gt;: -webkit-box;&#xD;
    &lt;span class="hljs-attribute"&gt;-webkit-line-clamp&lt;/span&gt;: &lt;span class="hljs-number"&gt;2&lt;/span&gt;;&#xD;
    &lt;span class="hljs-attribute"&gt;-webkit-box-orient&lt;/span&gt;: vertical;&#xD;
    &lt;span class="hljs-attribute"&gt;overflow&lt;/span&gt;: hidden;&#xD;
    &lt;span class="hljs-attribute"&gt;padding&lt;/span&gt;: &lt;span class="hljs-number"&gt;7px&lt;/span&gt; &lt;span class="hljs-number"&gt;10px&lt;/span&gt;;&#xD;
}&#xD;
&lt;/code&gt;&lt;/pre&gt;&#xD;
&lt;p&gt;截图一看，两行之下还能看到第三行文字的上半截，像被人用尺子沿着字腰划了一刀。（站里另一篇《&lt;a href="/post/Jfe2duvz4qC"&gt;基于 -webkit-line-clamp 的评论折叠组件开发&lt;/a&gt;》写过怎么用这个属性做折叠，这次碰到的是它和内边距之间的关系。）&lt;/p&gt;&#xD;
&lt;p&gt;&lt;code&gt;-webkit-line-clamp&lt;/code&gt; 只负责在第二行末尾加省略号，后面的文字照样排版，只是被 &lt;code&gt;overflow: hidden&lt;/code&gt; 裁掉。而 &lt;code&gt;overflow&lt;/code&gt; 是按内边距的外缘裁的：内边距本身也算可见区域。&lt;/p&gt;&#xD;
&lt;p&gt;按 20px 行高算：&lt;/p&gt;&#xD;
&lt;ul&gt;&#xD;
&lt;li&gt;两行文字占 40px，上下内边距各 7px，元素的可见高度是 54px；&lt;/li&gt;&#xD;
&lt;li&gt;第三行从第 47px（7 + 40）开始，到第 54px 被裁掉，正好露出 7px，差不多是半个字。&lt;/li&gt;&#xD;
&lt;/ul&gt;&#xD;
&lt;p&gt;量到的也是这样：可见高度 54px，内容总高 74px。&lt;/p&gt;&#xD;
&lt;p&gt;解决办法是把上下的留白从内边距换成透明边框：&lt;/p&gt;&#xD;
&lt;pre&gt;&lt;code class="lang-css"&gt;&lt;span class="hljs-selector-class"&gt;.search-suggest-posts&lt;/span&gt; &lt;span class="hljs-selector-tag"&gt;a&lt;/span&gt; {&#xD;
    &lt;span class="hljs-attribute"&gt;display&lt;/span&gt;: -webkit-box;&#xD;
    &lt;span class="hljs-attribute"&gt;-webkit-line-clamp&lt;/span&gt;: &lt;span class="hljs-number"&gt;2&lt;/span&gt;;&#xD;
    &lt;span class="hljs-attribute"&gt;-webkit-box-orient&lt;/span&gt;: vertical;&#xD;
    &lt;span class="hljs-attribute"&gt;overflow&lt;/span&gt;: hidden;&#xD;
    &lt;span class="hljs-attribute"&gt;padding&lt;/span&gt;: &lt;span class="hljs-number"&gt;0&lt;/span&gt; &lt;span class="hljs-number"&gt;10px&lt;/span&gt;;&#xD;
    &lt;span class="hljs-attribute"&gt;border&lt;/span&gt;: &lt;span class="hljs-number"&gt;7px&lt;/span&gt; solid transparent;&#xD;
    &lt;span class="hljs-attribute"&gt;border-left&lt;/span&gt;: &lt;span class="hljs-number"&gt;0&lt;/span&gt;;&#xD;
    &lt;span class="hljs-attribute"&gt;border-right&lt;/span&gt;: &lt;span class="hljs-number"&gt;0&lt;/span&gt;;&#xD;
}&#xD;
&lt;/code&gt;&lt;/pre&gt;&#xD;
&lt;p&gt;边框不属于裁切区域，可见高度变成正好 40px，第三行一个像素都不露。背景默认会铺到边框底下，所以鼠标悬停时那块浅色背景的大小和原来一样。&lt;/p&gt;&#xD;
&lt;h2 id="-"&gt;三、固定的弹层，底边出了屏幕&lt;/h2&gt;&#xD;
&lt;p&gt;两行截断让每篇标题高了一截，在 iPhone SE 这种 320×568 的小屏上，弹层从第 102px 开始，到第 681px 才结束，比屏幕底部多出 113px。&lt;/p&gt;&#xD;
&lt;p&gt;如果只是长一点，往下滑就行。可这个弹层是固定定位的，打开时页面又被锁住了滚动（&lt;code&gt;body&lt;/code&gt; 设成了 &lt;code&gt;position: fixed&lt;/code&gt;），整个屏幕没有任何地方能滚。最后两篇文章就在屏幕下面，怎么都点不到。&lt;/p&gt;&#xD;
&lt;p&gt;做法是让弹层自己限高，超出的部分在弹层里面滚：&lt;/p&gt;&#xD;
&lt;pre&gt;&lt;code class="lang-css"&gt;&lt;span class="hljs-selector-class"&gt;.search-dialog&lt;/span&gt; {&#xD;
    &lt;span class="hljs-attribute"&gt;display&lt;/span&gt;: flex;&#xD;
    &lt;span class="hljs-attribute"&gt;flex-direction&lt;/span&gt;: column;&#xD;
    &lt;span class="hljs-attribute"&gt;max-height&lt;/span&gt;: &lt;span class="hljs-built_in"&gt;calc&lt;/span&gt;(100vh - 120px);&#xD;
    &lt;span class="hljs-attribute"&gt;max-height&lt;/span&gt;: &lt;span class="hljs-built_in"&gt;calc&lt;/span&gt;(100dvh - 120px);&#xD;
}&#xD;
&lt;span class="hljs-selector-class"&gt;.search-input-area&lt;/span&gt; { &lt;span class="hljs-attribute"&gt;flex&lt;/span&gt;: none; }&#xD;
&lt;span class="hljs-selector-class"&gt;.search-suggest&lt;/span&gt; {&#xD;
    &lt;span class="hljs-attribute"&gt;flex&lt;/span&gt;: &lt;span class="hljs-number"&gt;1&lt;/span&gt; &lt;span class="hljs-number"&gt;1&lt;/span&gt; auto;&#xD;
    &lt;span class="hljs-attribute"&gt;min-height&lt;/span&gt;: &lt;span class="hljs-number"&gt;0&lt;/span&gt;;&#xD;
    &lt;span class="hljs-attribute"&gt;overflow-y&lt;/span&gt;: auto;&#xD;
    &lt;span class="hljs-attribute"&gt;overscroll-behavior&lt;/span&gt;: contain;&#xD;
}&#xD;
&lt;/code&gt;&lt;/pre&gt;&#xD;
&lt;p&gt;几个细节：&lt;/p&gt;&#xD;
&lt;ul&gt;&#xD;
&lt;li&gt;&lt;code&gt;100dvh&lt;/code&gt; 是去掉浏览器地址栏之后的可见高度，不认识它的旧浏览器会用上面那行 &lt;code&gt;100vh&lt;/code&gt;；&lt;/li&gt;&#xD;
&lt;li&gt;输入框不参与收缩，保证搜索框始终在；&lt;/li&gt;&#xD;
&lt;li&gt;&lt;code&gt;min-height: 0&lt;/code&gt; 又是第一节那条规则：flex 子项默认 &lt;code&gt;min-height: auto&lt;/code&gt;，不会比内容矮，不写这一句，&lt;code&gt;overflow-y: auto&lt;/code&gt; 永远等不到需要滚动的那一天；&lt;/li&gt;&#xD;
&lt;li&gt;&lt;code&gt;overscroll-behavior: contain&lt;/code&gt; 让滑到头时不会带着后面的页面一起动。&lt;/li&gt;&#xD;
&lt;/ul&gt;&#xD;
&lt;p&gt;改完再量：&lt;/p&gt;&#xD;
&lt;table&gt;&#xD;
&lt;thead&gt;&#xD;
&lt;tr&gt;&#xD;
&lt;th style="text-align:center"&gt;视口&lt;/th&gt;&#xD;
&lt;th style="text-align:center"&gt;改前底边&lt;/th&gt;&#xD;
&lt;th style="text-align:center"&gt;改后底边&lt;/th&gt;&#xD;
&lt;/tr&gt;&#xD;
&lt;/thead&gt;&#xD;
&lt;tbody&gt;&#xD;
&lt;tr&gt;&#xD;
&lt;td style="text-align:center"&gt;320×568&lt;/td&gt;&#xD;
&lt;td style="text-align:center"&gt;681px&lt;/td&gt;&#xD;
&lt;td style="text-align:center"&gt;550px&lt;/td&gt;&#xD;
&lt;/tr&gt;&#xD;
&lt;tr&gt;&#xD;
&lt;td style="text-align:center"&gt;390×400&lt;/td&gt;&#xD;
&lt;td style="text-align:center"&gt;594px&lt;/td&gt;&#xD;
&lt;td style="text-align:center"&gt;352px&lt;/td&gt;&#xD;
&lt;/tr&gt;&#xD;
&lt;/tbody&gt;&#xD;
&lt;/table&gt;&#xD;
&lt;p&gt;390×400 模拟的是键盘弹起后只剩一半屏幕的情况。&lt;/p&gt;&#xD;
&lt;p&gt;最后用触摸手势在建议区里往上划了 300px：建议区滚动了 130px，最后一篇完整露出来，背后的页面一动没动。&lt;/p&gt;&#xD;
&lt;h2 id="-"&gt;之后怎么测&lt;/h2&gt;&#xD;
&lt;p&gt;这次的根本问题不在 CSS，而在于没测。之后凡是改了弹层、网格、不换行文字、固定定位的地方，发版前都按这个办法在几个尺寸下各量一遍：&lt;/p&gt;&#xD;
&lt;pre&gt;&lt;code class="lang-js"&gt;&lt;span class="hljs-comment"&gt;// 打开弹层之后在页面里执行：弹层里有哪些元素越过了弹层的左右边界&lt;/span&gt;&#xD;
&lt;span class="hljs-keyword"&gt;const&lt;/span&gt; dialog = &lt;span class="hljs-built_in"&gt;document&lt;/span&gt;.querySelector(&lt;span class="hljs-string"&gt;'#search-overlay .search-dialog'&lt;/span&gt;);&#xD;
&lt;span class="hljs-keyword"&gt;const&lt;/span&gt; box = dialog.getBoundingClientRect();&#xD;
&lt;span class="hljs-keyword"&gt;const&lt;/span&gt; outside = [...dialog.querySelectorAll(&lt;span class="hljs-string"&gt;'*'&lt;/span&gt;)].filter(&lt;span class="hljs-function"&gt;&lt;span class="hljs-keyword"&gt;function&lt;/span&gt; (&lt;span class="hljs-params"&gt;el&lt;/span&gt;) &lt;/span&gt;{&#xD;
    &lt;span class="hljs-keyword"&gt;const&lt;/span&gt; r = el.getBoundingClientRect();&#xD;
    &lt;span class="hljs-keyword"&gt;return&lt;/span&gt; r.width &amp;gt; &lt;span class="hljs-number"&gt;0&lt;/span&gt; &amp;amp;&amp;amp; (r.right &amp;gt; box.right + &lt;span class="hljs-number"&gt;0.5&lt;/span&gt; || r.left &amp;lt; box.left - &lt;span class="hljs-number"&gt;0.5&lt;/span&gt;);&#xD;
});&#xD;
&lt;span class="hljs-keyword"&gt;const&lt;/span&gt; result = {&#xD;
    &lt;span class="hljs-attr"&gt;outside&lt;/span&gt;: outside.length,&#xD;
    &lt;span class="hljs-attr"&gt;pageOverflow&lt;/span&gt;: &lt;span class="hljs-built_in"&gt;document&lt;/span&gt;.documentElement.scrollWidth &amp;gt; innerWidth,&#xD;
    &lt;span class="hljs-attr"&gt;bottomOut&lt;/span&gt;: box.bottom &amp;gt; innerHeight&#xD;
};&#xD;
&lt;/code&gt;&lt;/pre&gt;&#xD;
&lt;ul&gt;&#xD;
&lt;li&gt;尺寸至少覆盖 360×780、320×568、390×664 三个手机尺寸和一个电脑尺寸，浅色和深色各一遍；&lt;/li&gt;&#xD;
&lt;li&gt;三个数都要是 0、&lt;code&gt;false&lt;/code&gt;、&lt;code&gt;false&lt;/code&gt;，再截图人工看一眼；&lt;/li&gt;&#xD;
&lt;li&gt;要滚动的地方用触摸手势真的划一下（Chrome 开发者协议里的 &lt;code&gt;Input.synthesizeScrollGesture&lt;/code&gt;），看滚的是不是该滚的那个元素。&lt;/li&gt;&#xD;
&lt;/ul&gt;&#xD;
&lt;h2 id="-"&gt;小结&lt;/h2&gt;&#xD;
&lt;ul&gt;&#xD;
&lt;li&gt;网格的 auto 列和 flex 子项默认都不会比内容的最小宽度更窄，碰到不换行的长文字，写 &lt;code&gt;minmax(0, 1fr)&lt;/code&gt; 或 &lt;code&gt;min-width: 0&lt;/code&gt;；嵌套的每一层都要检查。&lt;/li&gt;&#xD;
&lt;li&gt;&lt;code&gt;overflow: hidden&lt;/code&gt; 按内边距的外缘裁切，多行截断要留白时用透明边框，别用上下内边距。&lt;/li&gt;&#xD;
&lt;li&gt;固定定位的弹层要自己限高、自己滚，&lt;code&gt;flex&lt;/code&gt; 子项记得写 &lt;code&gt;min-height: 0&lt;/code&gt;。&lt;/li&gt;&#xD;
&lt;li&gt;「在电脑上看过」不等于「测过」。&lt;/li&gt;&#xD;
&lt;/ul&gt;</description>
      <pubDate>Thu, 08 Oct 2026 06:29:12 GMT</pubDate>
    </item>
    <item>
      <title>菜谱要写 90 秒时三道超时该怎么排</title>
      <link>https://www.hqxiaozou.top/post/LHpND493kXR</link>
      <description>&lt;blockquote&gt;
&lt;p&gt;当前实现：3.1.0 提交新搭配后先取得任务号，再轮询服务端进度，HTTP 提交请求不再等待整份菜谱生成。离开等待页、切后台或断网后，重新进入会接上同一个任务；没有结果时保留查询或重试入口，不把一次网络断开直接当成生成失败。本文的 75 / 120 / 150 秒是早期同步请求的故障现场，不能直接当作当前整条流程的总等待时间。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;&lt;a href="https://www.hqxiaozou.top/post/bhEgnGf1Nq6"&gt;《炊想》&lt;/a&gt;是个小程序：输入一道菜名，后端让大模型写出一整份菜谱——食材、步骤、配菜、汤品、营养，再配一张成品图。上线第一天，点一道站里没有的新菜，连续两次都失败了，小程序收到的是网关的 504。&lt;/p&gt;
&lt;p&gt;这篇记录排查过程：一份菜谱为什么要写 90 秒，为什么 75 秒的读超时注定会断，以及后端、nginx、小程序三道超时应该怎么排。&lt;/p&gt;
&lt;h2 id="一、两次失败的时间线"&gt;一、两次失败的时间线&lt;/h2&gt;
&lt;p&gt;后端日志和 nginx 访问日志对起来，第一次是这样的：&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th align="center"&gt;时刻&lt;/th&gt;
&lt;th align="center"&gt;发生了什么&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td align="center"&gt;15:32:20 前后&lt;/td&gt;
&lt;td align="center"&gt;小程序发起搭配&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td align="center"&gt;15:33:36&lt;/td&gt;
&lt;td align="center"&gt;后端读上游 75 秒超时，自动重试&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td align="center"&gt;15:34:20&lt;/td&gt;
&lt;td align="center"&gt;nginx 等满 120 秒，给小程序 504&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td align="center"&gt;15:34:52&lt;/td&gt;
&lt;td align="center"&gt;后端重试也超时，返回 502&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;第二次一模一样，每次尝试都卡满 76 秒左右。注意最后一行：后端 152 秒时才给出 502，而 nginx 在 120 秒就已经替它回了 504，这个 502 根本没人收。&lt;/p&gt;
&lt;h2 id="二、慢在哪里"&gt;二、慢在哪里&lt;/h2&gt;
&lt;p&gt;先排除网络。从服务器直接调上游接口，连接不到 1 秒；让它用一句话推荐一道菜，7 秒就回来了。&lt;/p&gt;
&lt;p&gt;再按线上完全一样的请求测：同一段系统提示词、要求输出 JSON、&lt;code&gt;max_tokens&lt;/code&gt; 3000。结果是：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;整份写完用了 89.9 秒；&lt;/li&gt;
&lt;li&gt;输出 1602 个 token，每秒约 18 个；&lt;/li&gt;
&lt;li&gt;内容合格，能解析成菜谱。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;慢在输出量。提示词要的东西很多：6～12 个步骤、每步 60～160 字，食材分主料辅料调料三组，配菜汤品主食饮品四项搭配，营养数据，再加一段 80～120 词的英文图片描述。按每秒十几个 token 算，90 秒是正常速度，不是故障。&lt;/p&gt;
&lt;h2 id="三、读超时不等于总时长"&gt;三、读超时不等于总时长&lt;/h2&gt;
&lt;p&gt;同一次测试里，curl 报的首字节时间是 0.8 秒、总时间 89.9 秒。响应头很快就回来了，正文却要等模型全部写完才一次性给出。&lt;/p&gt;
&lt;p&gt;后端用的是 Spring 的 &lt;code&gt;SimpleClientHttpRequestFactory&lt;/code&gt;，它的 &lt;code&gt;readTimeout&lt;/code&gt; 是 socket 每次读的等待上限，也就是两次收到数据之间最多等多久。正文 90 秒后才到，中间一个字节都没有，所以在 75 秒处断开。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;对非流式请求来说，读超时实际上就是「整份必须在多少秒内写完」。&lt;/strong&gt;输出一变长，它迟早会断。&lt;/p&gt;
&lt;p&gt;换成流式（请求里带 &lt;code&gt;stream: true&lt;/code&gt;），上游每写出一小段就发一段 SSE。同样的请求实测：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;一共 1597 段；&lt;/li&gt;
&lt;li&gt;第一段正文 6.2 秒到；&lt;/li&gt;
&lt;li&gt;两段之间最长隔 4.1 秒；&lt;/li&gt;
&lt;li&gt;98.4 秒写完，拼起来的 JSON 同样合格。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;这时读超时只需要覆盖「两段之间的最长间隔」，30 秒绰绰有余；整次生成要多久，交给另一个总时限去管。&lt;/p&gt;
&lt;h2 id="四、三道超时要排好队"&gt;四、三道超时要排好队&lt;/h2&gt;
&lt;p&gt;一次搭配从小程序到上游经过三层，每层都有自己的超时。改前改后：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;后端读上游&lt;/strong&gt;：原来 75 秒、失败再试一次；改成两段输出之间最多等 30 秒，整次最多 140 秒；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;nginx&lt;/strong&gt;：120 秒改成 150 秒；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;小程序&lt;/strong&gt;：150 秒，不变。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;原则是：&lt;strong&gt;越靠近上游的越先放弃。&lt;/strong&gt;后端先结束，小程序拿到的是一条明确的「搭配师走神了，再试一次」，而不是网关的 504。&lt;/p&gt;
&lt;p&gt;原来的顺序正好反了：后端 75 秒加一次重试，最长要 150 秒，比 nginx 的 120 秒还长，后端的失败信息永远送不到用户手里。&lt;/p&gt;
&lt;p&gt;重试也跟着改：只有在开头 20 秒内就失败（比如上游直接报 5xx）才重试；写到一半、或者写完才发现解析不了的，不再重来。生成一次就要一分半，再来一遍必然超过总时限。&lt;/p&gt;
&lt;h2 id="五、代码"&gt;五、代码&lt;/h2&gt;
&lt;p&gt;后端用 &lt;code&gt;RestTemplate.execute&lt;/code&gt; 自己读响应流，不再用 &lt;code&gt;postForObject&lt;/code&gt; 一次取回整段：&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-java"&gt;content = restTemplate.execute(url, HttpMethod.POST, request -&amp;gt; {
    request.getHeaders().putAll(headers);
    try (OutputStream out = request.getBody()) {
        out.write(body);
    }
}, response -&amp;gt; readContent(response, deadline));
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;readContent&lt;/code&gt; 先看 Content-Type：是 &lt;code&gt;text/event-stream&lt;/code&gt; 就逐行读，把每段的 &lt;code&gt;choices[0].delta.content&lt;/code&gt; 拼起来；万一上游没按流式回、直接给了整段 JSON，就按原来的方式解析。逐行读的部分大致是：&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-java"&gt;while ((line = reader.readLine()) != null) {
    checkDeadline(deadline);
    if (!line.startsWith(&amp;quot;data:&amp;quot;)) continue;
    String data = line.substring(5).trim();
    if (&amp;quot;[DONE]&amp;quot;.equals(data)) break;
    JsonNode delta = objectMapper.readTree(data).path(&amp;quot;choices&amp;quot;).path(0).path(&amp;quot;delta&amp;quot;);
    if (delta.path(&amp;quot;content&amp;quot;).isTextual()) content.append(delta.path(&amp;quot;content&amp;quot;).asText());
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;每读一行都检查一次总时限，过了就不再读。只有推理过程、没有正文，或者流里出现 error，都按失败处理。拼好的全文交给原来的菜谱解析器，返回给小程序的接口格式一个字没变，小程序不用改。&lt;/p&gt;
&lt;p&gt;nginx 这边只改一行：&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-nginx"&gt;location ^~ /recipe/api/ {
    proxy_read_timeout 150s;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;服务器上的环境变量也要跟着改：&lt;code&gt;RECIPE_TEXT_TIMEOUT_MS&lt;/code&gt; 的含义从「读超时」变成了「总时限」，值从 75000 改成 140000，并新增两段间隔的 &lt;code&gt;RECIPE_TEXT_IDLE_TIMEOUT_MS&lt;/code&gt;（30000）。只改代码不改配置，140 秒的总时限会被旧值 75 秒顶掉，等于没改。&lt;/p&gt;
&lt;h2 id="六、上线以后"&gt;六、上线以后&lt;/h2&gt;
&lt;p&gt;当晚有 3 次真实的新菜生成，文本部分分别用了 92、86、81 秒，全部成功，成品图也都出来了（其中一次图片上游返回 503，30 秒后自动重试成功）。以前这三次会全部卡在 75 秒。&lt;/p&gt;
&lt;p&gt;后来又把搭配改成了异步任务：提交后马上返回，小程序轮询结果，发版时也会先等进行中的搭配做完再重启。这样连「一个请求挂一分半」本身也没有了。&lt;/p&gt;
&lt;h2 id="七、换个更快的模型？"&gt;七、换个更快的模型？&lt;/h2&gt;
&lt;p&gt;等一分半毕竟太久，第二天上午把同一个菜谱请求同时发给 9 个模型，每个流式读取，看合不合格、要多久：&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th align="center"&gt;模型&lt;/th&gt;
&lt;th align="center"&gt;合格 / 耗时&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td align="center"&gt;gpt-6-luna（当时使用）&lt;/td&gt;
&lt;td align="center"&gt;3/3，21～34 秒&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td align="center"&gt;gpt-5.6-luna&lt;/td&gt;
&lt;td align="center"&gt;3/3，27～35 秒&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td align="center"&gt;deepseek-v4-flash&lt;/td&gt;
&lt;td align="center"&gt;1/3，14～16 秒&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td align="center"&gt;claude-haiku-4-5&lt;/td&gt;
&lt;td align="center"&gt;2/3，19～26 秒&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td align="center"&gt;qwen3.7-plus&lt;/td&gt;
&lt;td align="center"&gt;1/1，83 秒&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td align="center"&gt;doubao-seed-2.1-turbo&lt;/td&gt;
&lt;td align="center"&gt;1/1，123 秒&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td align="center"&gt;MiniMax-M3-highspeed&lt;/td&gt;
&lt;td align="center"&gt;0/1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td align="center"&gt;glm-5.3-flash&lt;/td&gt;
&lt;td align="center"&gt;0/1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td align="center"&gt;step-3.7-flash&lt;/td&gt;
&lt;td align="center"&gt;0/1&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;不合格的几个：claude-haiku 有一次 JSON 写坏；MiniMax 写满 3000 个 token 仍不成 JSON；glm 第一段正文要等 86 秒，写出来的 JSON 也是坏的；step 的 JSON 同样坏了。qwen 和 doubao 虽然合格，但第一段正文分别要等 57 秒和 107 秒。&lt;/p&gt;
&lt;p&gt;deepseek-v4-flash 是最快的，但 3 次里有 2 次把 3000 个 token 的输出额度用满了：一次正文一个字都没有（额度花在了正文以外的输出上），一次 JSON 写到一半被截断。速度快一倍，换来三分之二的失败率，不划算。&lt;/p&gt;
&lt;p&gt;最有意思的是当时使用的 gpt-6-luna：同一个模型，上午只要 21～34 秒，前一天晚上却要 80～92 秒。等得久主要是上游晚高峰慢，跟选哪个模型关系不大，所以那一轮没有换模型。&lt;/p&gt;
&lt;h2 id="小结"&gt;小结&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;非流式请求的读超时，实际上就是「整份响应必须在多少秒内写完」。输出一长就会断，而且断得很规律。&lt;/li&gt;
&lt;li&gt;流式读取把两件事拆开：读超时管两段之间的间隔，另设一个总时限管整次生成。&lt;/li&gt;
&lt;li&gt;一条请求经过几层，超时就要排好队：越靠近上游越短，让离用户最近的那层收到的是明确的失败信息，而不是网关超时。&lt;/li&gt;
&lt;li&gt;重试要看剩下的时间够不够，晚失败的不重试。&lt;/li&gt;
&lt;li&gt;改了超时的含义，服务器上的配置要一起改，否则旧值会把新代码顶掉。&lt;/li&gt;
&lt;li&gt;嫌慢想换模型，先在不同时段多测几轮：这次慢的是时段，不是模型。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;相关：&lt;a href="https://www.hqxiaozou.top/post/Y7jeJBkv790"&gt;SSE与Nginx流式传输排障实战&lt;/a&gt; 讲的是另一头——nginx 缓冲会把流式响应攒成一整块再发出去。&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;&lt;strong&gt;2026-10-07 补充&lt;/strong&gt;：后来提示词做过一轮精简（简单的菜 4～6 步、复杂的不超过 10 步，每步 40～120 字，英文图片描述 60～110 词），文本模型仍是 gpt-6-luna。上线一周的真实数据里，一份新菜谱的文本部分平均 68 秒左右；搭配早已改成异步任务，常见的五十道家常菜也提前搭好了，大多数人点开的其实是现成做法，不用等。&lt;/p&gt;
&lt;h2 id="当前任务与通道的时间边界"&gt;当前任务与通道的时间边界&lt;/h2&gt;
&lt;p&gt;现在需要分开看三件事：一次提交请求是否成功、后端任务是否结束、某个 AI 通道是否超时。前端普通请求和任务提交配置为 20 秒；提交超时会先查询现有任务，不能直接再创建一份。菜谱生成与成品图准备各有状态，轮询暂时失败会显示重新连接，重新进入也能恢复。&lt;/p&gt;
&lt;p&gt;后端可启用优先通道，失败后回退原供应商，文字与图片分别处理。代码默认连接超时 2 秒、文字读取空闲超时 60 秒、单份菜谱文字预算 90 秒；显式 JSON 调用给优先通道的预算取配置上限与该操作预算一半中的较小值，余下时间留给回退。通道失败后的冷却期为 30 秒。图片的 90 秒设置是读取超时，不等于整个生成流程一定在 90 秒内结束。&lt;/p&gt;
&lt;p&gt;这些预算也不是强制终止线程的保证：文字总时限在读取流时检查，阻塞读取还受空闲超时影响；普通菜谱方法的优先与回退分别调用，不能把 90 秒误写成两边合计的硬上限。应结合真实请求、排队和回退日志核对实际等待时间，不能只改一个配置数字就宣称解决。&lt;/p&gt;</description>
      <pubDate>Tue, 06 Oct 2026 07:44:25 GMT</pubDate>
    </item>
    <item>
      <title>Canvas分享海报</title>
      <link>https://www.hqxiaozou.top/post/CzwUr1jOOes</link>
      <description>&lt;p&gt;文章页的“分享”会生成一张海报：canvas 画好标题、摘要和二维码，存成图片发出去。三月写过一篇&lt;a href="/post/MiEIfd58x96"&gt;《博客文章分享卡片功能设计与实现》&lt;/a&gt;，记录了最初的做法。这次把海报重做了一遍，加上封面、分类、标签和头像。改的过程中，把旧实现的几个问题量了一下。&lt;/p&gt;&#xD;
&lt;p&gt;先放结果：&lt;/p&gt;&#xD;
&lt;table&gt;&#xD;
&lt;thead&gt;&#xD;
&lt;tr&gt;&#xD;
&lt;th style="text-align:center"&gt;项目&lt;/th&gt;&#xD;
&lt;th style="text-align:center"&gt;改前&lt;/th&gt;&#xD;
&lt;th style="text-align:center"&gt;改后&lt;/th&gt;&#xD;
&lt;/tr&gt;&#xD;
&lt;/thead&gt;&#xD;
&lt;tbody&gt;&#xD;
&lt;tr&gt;&#xD;
&lt;td style="text-align:center"&gt;排版出错的海报&lt;/td&gt;&#xD;
&lt;td style="text-align:center"&gt;288 / 372 篇&lt;/td&gt;&#xD;
&lt;td style="text-align:center"&gt;0&lt;/td&gt;&#xD;
&lt;/tr&gt;&#xD;
&lt;tr&gt;&#xD;
&lt;td style="text-align:center"&gt;二维码灰色像素&lt;/td&gt;&#xD;
&lt;td style="text-align:center"&gt;32.0%&lt;/td&gt;&#xD;
&lt;td style="text-align:center"&gt;0%&lt;/td&gt;&#xD;
&lt;/tr&gt;&#xD;
&lt;tr&gt;&#xD;
&lt;td style="text-align:center"&gt;封面&lt;/td&gt;&#xD;
&lt;td style="text-align:center"&gt;无&lt;/td&gt;&#xD;
&lt;td style="text-align:center"&gt;有&lt;/td&gt;&#xD;
&lt;/tr&gt;&#xD;
&lt;tr&gt;&#xD;
&lt;td style="text-align:center"&gt;手机上看全海报&lt;/td&gt;&#xD;
&lt;td style="text-align:center"&gt;要在框里滑&lt;/td&gt;&#xD;
&lt;td style="text-align:center"&gt;一屏放下&lt;/td&gt;&#xD;
&lt;/tr&gt;&#xD;
&lt;/tbody&gt;&#xD;
&lt;/table&gt;&#xD;
&lt;p&gt;下面按这四件事来写。文中的数据都是 2026-10-03 实测的，测试用的是本机 Chrome 和 Playwright 自带的 WebKit（模拟 iPhone）。&lt;/p&gt;&#xD;
&lt;h2 id="-288-"&gt;一、逐字换行：288 篇的海报排错了&lt;/h2&gt;&#xD;
&lt;p&gt;canvas 不会自动换行，要自己量宽度、自己断行。旧版的做法是把文字拆成单个字符，一个个往行里加，超宽就换行：&lt;/p&gt;&#xD;
&lt;pre&gt;&lt;code class="lang-javascript"&gt;&lt;span class="hljs-function"&gt;&lt;span class="hljs-keyword"&gt;function&lt;/span&gt; &lt;span class="hljs-title"&gt;calculateTextLines&lt;/span&gt;(&lt;span class="hljs-params"&gt;ctx, text, maxWidth, lineHeight&lt;/span&gt;) &lt;/span&gt;{&#xD;
    &lt;span class="hljs-keyword"&gt;var&lt;/span&gt; words = text.split(&lt;span class="hljs-string"&gt;''&lt;/span&gt;);&#xD;
    &lt;span class="hljs-keyword"&gt;var&lt;/span&gt; line = &lt;span class="hljs-string"&gt;''&lt;/span&gt;;&#xD;
    &lt;span class="hljs-keyword"&gt;var&lt;/span&gt; lines = [];&#xD;
    &lt;span class="hljs-keyword"&gt;for&lt;/span&gt; (&lt;span class="hljs-keyword"&gt;var&lt;/span&gt; n = &lt;span class="hljs-number"&gt;0&lt;/span&gt;; n &amp;lt; words.length; n++) {&#xD;
        &lt;span class="hljs-keyword"&gt;var&lt;/span&gt; testLine = line + words[n];&#xD;
        &lt;span class="hljs-keyword"&gt;if&lt;/span&gt; (ctx.measureText(testLine).width &amp;gt; maxWidth &amp;amp;&amp;amp; n &amp;gt; &lt;span class="hljs-number"&gt;0&lt;/span&gt;) {&#xD;
            lines.push(line);&#xD;
            line = words[n];&#xD;
        } &lt;span class="hljs-keyword"&gt;else&lt;/span&gt; {&#xD;
            line = testLine;&#xD;
        }&#xD;
    }&#xD;
    lines.push(line);&#xD;
    &lt;span class="hljs-keyword"&gt;return&lt;/span&gt; lines;&#xD;
}&#xD;
&lt;/code&gt;&lt;/pre&gt;&#xD;
&lt;p&gt;纯中文没什么问题，中英混排就会出事：英文单词会从中间断开，逗号句号可能跑到下一行开头。到底有多少篇受影响？我把站里全部 372 篇已发布文章的标题和摘要拿出来，在 Chrome 里用和海报相同的字体、宽度和行数限制，分别跑旧版和新版的断行，统计海报上看得见的问题：&lt;/p&gt;&#xD;
&lt;table&gt;&#xD;
&lt;thead&gt;&#xD;
&lt;tr&gt;&#xD;
&lt;th style="text-align:center"&gt;问题&lt;/th&gt;&#xD;
&lt;th style="text-align:center"&gt;旧版&lt;/th&gt;&#xD;
&lt;th style="text-align:center"&gt;新版&lt;/th&gt;&#xD;
&lt;/tr&gt;&#xD;
&lt;/thead&gt;&#xD;
&lt;tbody&gt;&#xD;
&lt;tr&gt;&#xD;
&lt;td style="text-align:center"&gt;英文单词被拆开&lt;/td&gt;&#xD;
&lt;td style="text-align:center"&gt;193 篇 278 处&lt;/td&gt;&#xD;
&lt;td style="text-align:center"&gt;0&lt;/td&gt;&#xD;
&lt;/tr&gt;&#xD;
&lt;tr&gt;&#xD;
&lt;td style="text-align:center"&gt;标点在行首&lt;/td&gt;&#xD;
&lt;td style="text-align:center"&gt;155 篇 193 处&lt;/td&gt;&#xD;
&lt;td style="text-align:center"&gt;0&lt;/td&gt;&#xD;
&lt;/tr&gt;&#xD;
&lt;tr&gt;&#xD;
&lt;td style="text-align:center"&gt;行首多出一个空格&lt;/td&gt;&#xD;
&lt;td style="text-align:center"&gt;34 篇 37 处&lt;/td&gt;&#xD;
&lt;td style="text-align:center"&gt;0&lt;/td&gt;&#xD;
&lt;/tr&gt;&#xD;
&lt;tr&gt;&#xD;
&lt;td style="text-align:center"&gt;省略号前留着标点&lt;/td&gt;&#xD;
&lt;td style="text-align:center"&gt;22 篇 22 处&lt;/td&gt;&#xD;
&lt;td style="text-align:center"&gt;0&lt;/td&gt;&#xD;
&lt;/tr&gt;&#xD;
&lt;tr&gt;&#xD;
&lt;td style="text-align:center"&gt;开括号留在行尾&lt;/td&gt;&#xD;
&lt;td style="text-align:center"&gt;12 篇 12 处&lt;/td&gt;&#xD;
&lt;td style="text-align:center"&gt;0&lt;/td&gt;&#xD;
&lt;/tr&gt;&#xD;
&lt;tr&gt;&#xD;
&lt;td style="text-align:center"&gt;至少有一处&lt;/td&gt;&#xD;
&lt;td style="text-align:center"&gt;288 篇&lt;/td&gt;&#xD;
&lt;td style="text-align:center"&gt;0&lt;/td&gt;&#xD;
&lt;/tr&gt;&#xD;
&lt;/tbody&gt;&#xD;
&lt;/table&gt;&#xD;
&lt;p&gt;海报上的摘要取自页面的 description，也就是文章摘要截到 160 个字。统计时按同样的规则截取，没有拿全文去算。“单词被拆开”只算整个词本来放得进一行的情况；一行都放不下的长词只能拆，不算错。&lt;/p&gt;&#xD;
&lt;p&gt;被拆开的单词都是站里文章常见的词：Myba/tis、Ali/baba、Ki/bana、HashSe/t、C/oncurrentHashMap、QRCo/deWriter。行首空格是因为按字符拆的时候，空格也是一个“字”，刚好落在换行处就跑到了下一行的开头。&lt;/p&gt;&#xD;
&lt;p&gt;新版先把文字切成三种片段：英文和数字连在一起的一段算一个词，空格单独一段，其余每个字一段。&lt;/p&gt;&#xD;
&lt;pre&gt;&lt;code class="lang-javascript"&gt;&lt;span class="hljs-keyword"&gt;var&lt;/span&gt; tokens = text.replace(&lt;span class="hljs-regexp"&gt;/\s+/g&lt;/span&gt;, &lt;span class="hljs-string"&gt;' '&lt;/span&gt;).trim()&#xD;
    .match(&lt;span class="hljs-regexp"&gt;/[A-Za-z0-9@#][A-Za-z0-9_\-\u2010\u2011.\/+#%'@&amp;amp;]*| |[\s\S]/g&lt;/span&gt;) || [];&#xD;
&lt;/code&gt;&lt;/pre&gt;&#xD;
&lt;p&gt;词可以用 &lt;code&gt;@&lt;/code&gt; 或 &lt;code&gt;#&lt;/code&gt; 开头，中间允许出现 &lt;code&gt;_ - . / + # % &amp;#39; @ &amp;amp;&lt;/code&gt; 和不断行连字符（U+2011），所以 &lt;code&gt;@RestController&lt;/code&gt;、&lt;code&gt;C#&lt;/code&gt;、&lt;code&gt;v1.2&lt;/code&gt;、&lt;code&gt;SCOPE_PROTOTYPE&lt;/code&gt;、&lt;code&gt;Mybatis‑Plus&lt;/code&gt; 都不会被拆开。然后照常往行里装，装不下的时候按这几条规则处理：&lt;/p&gt;&#xD;
&lt;ul&gt;&#xD;
&lt;li&gt;&lt;strong&gt;行首不能是闭合标点&lt;/strong&gt;：&lt;code&gt;，。、；：？！）》」』】〕〉”’…—～·&lt;/code&gt; 和对应的半角标点放不下时，把上一行的最后一个字（或最后一个英文词）连同它一起移到下一行。&lt;/li&gt;&#xD;
&lt;li&gt;&lt;strong&gt;行尾不能是开括号、开引号&lt;/strong&gt;：&lt;code&gt;（《「『【〔〈“‘([{&lt;/code&gt; 留在行尾时，跟着下一行走。&lt;/li&gt;&#xD;
&lt;li&gt;&lt;strong&gt;一行都放不下的长词&lt;/strong&gt;（网址、包名、长常量名）只能拆，尽量在 &lt;code&gt;/&lt;/code&gt; 或 &lt;code&gt;.&lt;/code&gt; 后面断，其次是 &lt;code&gt;_&lt;/code&gt; 和 &lt;code&gt;-&lt;/code&gt;，都没有才按字符拆。&lt;/li&gt;&#xD;
&lt;li&gt;&lt;strong&gt;超出行数的最后一行&lt;/strong&gt;加省略号，省略号前面的逗号、句号和开括号去掉。&lt;/li&gt;&#xD;
&lt;li&gt;摘要里的 Markdown 反引号去掉，海报上它只是一个个孤零零的符号。&lt;/li&gt;&#xD;
&lt;/ul&gt;&#xD;
&lt;p&gt;处理“行首标点”的核心代码：&lt;/p&gt;&#xD;
&lt;pre&gt;&lt;code class="lang-javascript"&gt;&lt;span class="hljs-keyword"&gt;if&lt;/span&gt; (line &amp;amp;&amp;amp; POSTER_NO_LINE_START.indexOf(token) &amp;gt;= &lt;span class="hljs-number"&gt;0&lt;/span&gt;) {&#xD;
    &lt;span class="hljs-comment"&gt;// 往回跳过行尾的空格和同样不能放行首的标点，找到前面那个字或英文词&lt;/span&gt;&#xD;
    &lt;span class="hljs-keyword"&gt;var&lt;/span&gt; head = line.replace(&lt;span class="hljs-regexp"&gt;/ +$/&lt;/span&gt;, &lt;span class="hljs-string"&gt;''&lt;/span&gt;);&#xD;
    &lt;span class="hljs-keyword"&gt;var&lt;/span&gt; cut = head.length;&#xD;
    &lt;span class="hljs-keyword"&gt;while&lt;/span&gt; (cut &amp;gt; &lt;span class="hljs-number"&gt;0&lt;/span&gt; &amp;amp;&amp;amp; POSTER_NO_LINE_START.indexOf(head.charAt(cut - &lt;span class="hljs-number"&gt;1&lt;/span&gt;)) &amp;gt;= &lt;span class="hljs-number"&gt;0&lt;/span&gt;) cut--;&#xD;
    &lt;span class="hljs-keyword"&gt;var&lt;/span&gt; unit = head.slice(&lt;span class="hljs-number"&gt;0&lt;/span&gt;, cut).match(&lt;span class="hljs-regexp"&gt;/(?:[A-Za-z0-9@#][A-Za-z0-9_\-\u2010\u2011.\/+#%'@&amp;amp;]*|[^ ])$/&lt;/span&gt;);&#xD;
    &lt;span class="hljs-keyword"&gt;var&lt;/span&gt; start = unit ? cut - unit[&lt;span class="hljs-number"&gt;0&lt;/span&gt;].length : &lt;span class="hljs-number"&gt;0&lt;/span&gt;;&#xD;
    &lt;span class="hljs-comment"&gt;// 它前面紧挨着的开括号也一起移走&lt;/span&gt;&#xD;
    &lt;span class="hljs-keyword"&gt;while&lt;/span&gt; (start &amp;gt; &lt;span class="hljs-number"&gt;0&lt;/span&gt; &amp;amp;&amp;amp; POSTER_NO_LINE_END.indexOf(line.charAt(start - &lt;span class="hljs-number"&gt;1&lt;/span&gt;)) &amp;gt;= &lt;span class="hljs-number"&gt;0&lt;/span&gt;) start--;&#xD;
    &lt;span class="hljs-keyword"&gt;if&lt;/span&gt; (start &amp;gt; &lt;span class="hljs-number"&gt;0&lt;/span&gt;) {&#xD;
        &lt;span class="hljs-keyword"&gt;var&lt;/span&gt; moved = line.slice(start);&#xD;
        line = line.slice(&lt;span class="hljs-number"&gt;0&lt;/span&gt;, start);&#xD;
        pushLine();&#xD;
        line = moved + token;&#xD;
        &lt;span class="hljs-keyword"&gt;return&lt;/span&gt;;&#xD;
    }&#xD;
    &lt;span class="hljs-comment"&gt;// 整行就是一个词、前面没有能换下去的字：这一个标点挂在行尾&lt;/span&gt;&#xD;
    line += token;&#xD;
    &lt;span class="hljs-keyword"&gt;return&lt;/span&gt;;&#xD;
}&#xD;
&lt;/code&gt;&lt;/pre&gt;&#xD;
&lt;p&gt;这段代码不是一次写对的，每改一版，就拿 372 篇重跑一遍统计。&lt;/p&gt;&#xD;
&lt;p&gt;第一版只移走了词本身，没管它前面的括号，还剩 4 篇出错。比如标题“NC100 把字符串转换成整数(atoi)”：第一行能放下“整数(atoi”，放不下后面的“)”，于是把“atoi”移到第二行，却把“(”留在了第一行的末尾。现在会往回跳过开括号，排成“整数”和“(atoi)”两行。&lt;/p&gt;&#xD;
&lt;p&gt;另一种是一整行只有一个带括号的长词，后面跟着的全角“）”放不下。往回找不到能移走的字，第一版的结果是“(”单独占一行。我先改成在长词的“.”后面断开，又把一个本来放得下一行的词拆开了。最后的做法是让这一个“）”挂在行尾，也就是排版里常说的标点悬挂：海报上那一行就是 &lt;code&gt;(ConfigurableBeanFactory.SCOPE_PROTOTYPE)&lt;/code&gt; 加上一个全角“）”，比别的行宽出一个字。全角标点的笔画在左半边，挂出去的部分还在卡片的留白里。372 篇里只有这一处用到了它。&lt;/p&gt;&#xD;
&lt;p&gt;再往后，把统计的口径放严，带 &lt;code&gt;@&lt;/code&gt; 的词也算进去，又发现第一版会把 &lt;code&gt;@RestController&lt;/code&gt; 拆成 &lt;code&gt;@&lt;/code&gt; 和 &lt;code&gt;RestController&lt;/code&gt;，因为 &lt;code&gt;@&lt;/code&gt; 不能作为一个词的开头。于是改成了上面那个正则。最后一轮重跑，372 篇都是 0。&lt;/p&gt;&#xD;
&lt;h2 id="-canvas-"&gt;二、封面：跨域图片会让 canvas 导出失败&lt;/h2&gt;&#xD;
&lt;p&gt;新海报要画文章封面和作者头像。封面在七牛 CDN 上，头像原来用的是 QQ 头像接口，两个都和博客不同源。&lt;/p&gt;&#xD;
&lt;p&gt;canvas 有一条安全规则：画过跨域图片、又没得到对方许可的 canvas 会被标记为“被污染”，之后 &lt;code&gt;toDataURL&lt;/code&gt; 和 &lt;code&gt;getImageData&lt;/code&gt; 都会直接抛异常。海报最后要导出成图片，一旦被污染就整张都存不了。&lt;/p&gt;&#xD;
&lt;p&gt;我在博客页面里用 Chrome 和 WebKit 各试了四种组合：七牛的图片带不带 &lt;code&gt;crossOrigin&lt;/code&gt;，QQ 头像带不带 &lt;code&gt;crossOrigin&lt;/code&gt;。两个浏览器的结果一样：&lt;/p&gt;&#xD;
&lt;table&gt;&#xD;
&lt;thead&gt;&#xD;
&lt;tr&gt;&#xD;
&lt;th style="text-align:center"&gt;图片&lt;/th&gt;&#xD;
&lt;th style="text-align:center"&gt;crossOrigin&lt;/th&gt;&#xD;
&lt;th style="text-align:center"&gt;结果&lt;/th&gt;&#xD;
&lt;/tr&gt;&#xD;
&lt;/thead&gt;&#xD;
&lt;tbody&gt;&#xD;
&lt;tr&gt;&#xD;
&lt;td style="text-align:center"&gt;七牛&lt;/td&gt;&#xD;
&lt;td style="text-align:center"&gt;设置&lt;/td&gt;&#xD;
&lt;td style="text-align:center"&gt;能导出&lt;/td&gt;&#xD;
&lt;/tr&gt;&#xD;
&lt;tr&gt;&#xD;
&lt;td style="text-align:center"&gt;七牛&lt;/td&gt;&#xD;
&lt;td style="text-align:center"&gt;不设置&lt;/td&gt;&#xD;
&lt;td style="text-align:center"&gt;导出报错&lt;/td&gt;&#xD;
&lt;/tr&gt;&#xD;
&lt;tr&gt;&#xD;
&lt;td style="text-align:center"&gt;QQ 头像&lt;/td&gt;&#xD;
&lt;td style="text-align:center"&gt;设置&lt;/td&gt;&#xD;
&lt;td style="text-align:center"&gt;加载失败&lt;/td&gt;&#xD;
&lt;/tr&gt;&#xD;
&lt;tr&gt;&#xD;
&lt;td style="text-align:center"&gt;QQ 头像&lt;/td&gt;&#xD;
&lt;td style="text-align:center"&gt;不设置&lt;/td&gt;&#xD;
&lt;td style="text-align:center"&gt;导出报错&lt;/td&gt;&#xD;
&lt;/tr&gt;&#xD;
&lt;/tbody&gt;&#xD;
&lt;/table&gt;&#xD;
&lt;p&gt;报错的写法不一样：&lt;/p&gt;&#xD;
&lt;ul&gt;&#xD;
&lt;li&gt;Chrome：&lt;code&gt;Tainted canvases may not be exported.&lt;/code&gt;，完整的是 &lt;code&gt;SecurityError: Failed to execute &amp;#39;toDataURL&amp;#39; on &amp;#39;HTMLCanvasElement&amp;#39;&lt;/code&gt; 加上这一句；&lt;/li&gt;&#xD;
&lt;li&gt;WebKit：&lt;code&gt;SecurityError: The operation is insecure.&lt;/code&gt;&lt;/li&gt;&#xD;
&lt;/ul&gt;&#xD;
&lt;p&gt;原因看响应头就清楚了：七牛的响应带着 &lt;code&gt;access-control-allow-origin: *&lt;/code&gt;，QQ 头像的响应没有任何跨域头。&lt;/p&gt;&#xD;
&lt;p&gt;能导出要同时满足两个条件：&lt;/p&gt;&#xD;
&lt;ul&gt;&#xD;
&lt;li&gt;服务器在响应里允许跨域；&lt;/li&gt;&#xD;
&lt;li&gt;页面在设置 &lt;code&gt;src&lt;/code&gt; 之前写上 &lt;code&gt;img.crossOrigin = &amp;#39;anonymous&amp;#39;&lt;/code&gt;。&lt;/li&gt;&#xD;
&lt;/ul&gt;&#xD;
&lt;p&gt;第二行说明，七牛就算允许跨域，不写这个属性也一样会污染画布：浏览器发的是不带跨域模式的普通请求，根本不检查跨域头。第三行说明，对不允许跨域的服务器写上这个属性，图片会直接加载失败，不会再污染画布。&lt;/p&gt;&#xD;
&lt;p&gt;所以海报这样处理：&lt;/p&gt;&#xD;
&lt;ul&gt;&#xD;
&lt;li&gt;&lt;strong&gt;头像&lt;/strong&gt;：换成七牛上同一张图（和 QQ 头像是同一张照片）。&lt;/li&gt;&#xD;
&lt;li&gt;&lt;strong&gt;封面&lt;/strong&gt;：按海报需要的尺寸从七牛取。封面在海报上是 630 个设备像素宽，取 640 宽、质量 80 的 JPEG。我抽了三张封面对比，平均 58 KB，原来 720 宽、质量 85 是 87 KB。&lt;/li&gt;&#xD;
&lt;li&gt;&lt;strong&gt;别的站的封面&lt;/strong&gt;：也带上 &lt;code&gt;crossOrigin&lt;/code&gt; 试一次。对方不允许跨域时只是加载失败，海报改用渐变底，不会污染画布。头像加载失败就画一个“召”字。&lt;/li&gt;&#xD;
&lt;/ul&gt;&#xD;
&lt;p&gt;这里还有一层防盗链：七牛会检查 Referer。带 &lt;code&gt;crossOrigin&lt;/code&gt; 的请求照样会带上页面的来源，线上没有影响。但在本机 &lt;code&gt;127.0.0.1&lt;/code&gt; 上测试时，七牛会拒绝这些请求，测试脚本要替浏览器补上站点的 Referer。&lt;/p&gt;&#xD;
&lt;h3 id="-"&gt;等多久&lt;/h3&gt;&#xD;
&lt;p&gt;第一版给每张图最多等 2.5 秒，超时就用渐变底。在我这台电脑上，Chrome 第一次打开海报，三次都在 2.6 秒左右出来，用的都是渐变底，而 WebKit 1.6 秒就带着封面出来了。&lt;/p&gt;&#xD;
&lt;p&gt;我的网络到七牛要绕一层代理，建立一次加密连接要一两秒。按理说，打开文章页时页面已经和七牛建好了连接，取封面不用再握手。我把每个请求的连接耗时打出来看（海报的两个请求都带 &lt;code&gt;crossOrigin&lt;/code&gt;）：&lt;/p&gt;&#xD;
&lt;table&gt;&#xD;
&lt;thead&gt;&#xD;
&lt;tr&gt;&#xD;
&lt;th style="text-align:center"&gt;请求&lt;/th&gt;&#xD;
&lt;th style="text-align:center"&gt;Chrome&lt;/th&gt;&#xD;
&lt;th style="text-align:center"&gt;WebKit&lt;/th&gt;&#xD;
&lt;/tr&gt;&#xD;
&lt;/thead&gt;&#xD;
&lt;tbody&gt;&#xD;
&lt;tr&gt;&#xD;
&lt;td style="text-align:center"&gt;页面里的图片&lt;/td&gt;&#xD;
&lt;td style="text-align:center"&gt;复用连接&lt;/td&gt;&#xD;
&lt;td style="text-align:center"&gt;复用连接&lt;/td&gt;&#xD;
&lt;/tr&gt;&#xD;
&lt;tr&gt;&#xD;
&lt;td style="text-align:center"&gt;海报封面&lt;/td&gt;&#xD;
&lt;td style="text-align:center"&gt;新建，握手 2.2 秒&lt;/td&gt;&#xD;
&lt;td style="text-align:center"&gt;复用连接&lt;/td&gt;&#xD;
&lt;/tr&gt;&#xD;
&lt;tr&gt;&#xD;
&lt;td style="text-align:center"&gt;海报头像&lt;/td&gt;&#xD;
&lt;td style="text-align:center"&gt;复用封面那条&lt;/td&gt;&#xD;
&lt;td style="text-align:center"&gt;复用连接&lt;/td&gt;&#xD;
&lt;/tr&gt;&#xD;
&lt;/tbody&gt;&#xD;
&lt;/table&gt;&#xD;
&lt;p&gt;Chrome 会按请求带不带凭据（Cookie 等）把连接分开：&lt;code&gt;&amp;lt;img&amp;gt;&lt;/code&gt; 和 CSS 里的图片是带凭据的请求，&lt;code&gt;crossOrigin=&amp;quot;anonymous&amp;quot;&lt;/code&gt; 的请求不带凭据，用不了它们的连接。所以海报的第一张图要重新握手，这一下在我的网络上就是 2.2 秒，封面一共用了 2.9 秒，没赶上 2.5 秒的期限。&lt;code&gt;&amp;lt;link rel=&amp;quot;preconnect&amp;quot;&amp;gt;&lt;/code&gt; 要写 &lt;code&gt;crossorigin&lt;/code&gt; 属性，也是这个原因。&lt;/p&gt;&#xD;
&lt;p&gt;用户的网络不一定比这好，所以改成了下面这样：&lt;/p&gt;&#xD;
&lt;pre&gt;&lt;code class="lang-javascript"&gt;&lt;span class="hljs-comment"&gt;// 同一张图只下一次；超时不取消下载，关了再开就能用上&lt;/span&gt;&#xD;
&lt;span class="hljs-keyword"&gt;var&lt;/span&gt; posterImages = {};&#xD;
&#xD;
&lt;span class="hljs-function"&gt;&lt;span class="hljs-keyword"&gt;function&lt;/span&gt; &lt;span class="hljs-title"&gt;fetchPosterImage&lt;/span&gt;(&lt;span class="hljs-params"&gt;src&lt;/span&gt;) &lt;/span&gt;{&#xD;
    &lt;span class="hljs-keyword"&gt;if&lt;/span&gt; (!posterImages[src]) {&#xD;
        posterImages[src] = &lt;span class="hljs-keyword"&gt;new&lt;/span&gt; &lt;span class="hljs-built_in"&gt;Promise&lt;/span&gt;(&lt;span class="hljs-function"&gt;&lt;span class="hljs-keyword"&gt;function&lt;/span&gt; (&lt;span class="hljs-params"&gt;resolve&lt;/span&gt;) &lt;/span&gt;{&#xD;
            &lt;span class="hljs-keyword"&gt;var&lt;/span&gt; image = &lt;span class="hljs-keyword"&gt;new&lt;/span&gt; Image();&#xD;
            image.crossOrigin = &lt;span class="hljs-string"&gt;'anonymous'&lt;/span&gt;;&#xD;
            image.onload = &lt;span class="hljs-function"&gt;&lt;span class="hljs-keyword"&gt;function&lt;/span&gt; (&lt;span class="hljs-params"&gt;&lt;/span&gt;) &lt;/span&gt;{ resolve(image); };&#xD;
            image.onerror = &lt;span class="hljs-function"&gt;&lt;span class="hljs-keyword"&gt;function&lt;/span&gt; (&lt;span class="hljs-params"&gt;&lt;/span&gt;) &lt;/span&gt;{ &lt;span class="hljs-keyword"&gt;delete&lt;/span&gt; posterImages[src]; resolve(&lt;span class="hljs-literal"&gt;null&lt;/span&gt;); };&#xD;
            image.src = src;&#xD;
        });&#xD;
    }&#xD;
    &lt;span class="hljs-keyword"&gt;return&lt;/span&gt; posterImages[src];&#xD;
}&#xD;
&#xD;
&lt;span class="hljs-comment"&gt;// 每次生成海报最多等 6 秒&lt;/span&gt;&#xD;
&lt;span class="hljs-function"&gt;&lt;span class="hljs-keyword"&gt;function&lt;/span&gt; &lt;span class="hljs-title"&gt;loadPosterImage&lt;/span&gt;(&lt;span class="hljs-params"&gt;src&lt;/span&gt;) &lt;/span&gt;{&#xD;
    &lt;span class="hljs-keyword"&gt;return&lt;/span&gt; &lt;span class="hljs-built_in"&gt;Promise&lt;/span&gt;.race([fetchPosterImage(src), &lt;span class="hljs-keyword"&gt;new&lt;/span&gt; &lt;span class="hljs-built_in"&gt;Promise&lt;/span&gt;(&lt;span class="hljs-function"&gt;&lt;span class="hljs-keyword"&gt;function&lt;/span&gt; (&lt;span class="hljs-params"&gt;resolve&lt;/span&gt;) &lt;/span&gt;{&#xD;
        setTimeout(&lt;span class="hljs-function"&gt;&lt;span class="hljs-keyword"&gt;function&lt;/span&gt; (&lt;span class="hljs-params"&gt;&lt;/span&gt;) &lt;/span&gt;{ resolve(&lt;span class="hljs-literal"&gt;null&lt;/span&gt;); }, &lt;span class="hljs-number"&gt;6000&lt;/span&gt;);&#xD;
    })]);&#xD;
}&#xD;
&lt;/code&gt;&lt;/pre&gt;&#xD;
&lt;p&gt;另外，鼠标移到、手指按到、键盘移到分享按钮上时，就开始下载封面和头像，那次握手也跟着提前了。没有在页面里加 &lt;code&gt;preconnect&lt;/code&gt;，因为那样每个访问者都会多建一条连接，而会点分享的人并不多。&lt;/p&gt;&#xD;
&lt;p&gt;我用 Playwright 把封面请求人为延迟 8 秒，测了三件事：&lt;/p&gt;&#xD;
&lt;ul&gt;&#xD;
&lt;li&gt;&lt;strong&gt;鼠标移上去后&lt;/strong&gt;，封面请求在 116 毫秒后发出；&lt;/li&gt;&#xD;
&lt;li&gt;&lt;strong&gt;第一次打开&lt;/strong&gt;，6.1 秒出海报，用的是渐变底；&lt;/li&gt;&#xD;
&lt;li&gt;&lt;strong&gt;封面下完后关掉再打开&lt;/strong&gt;，0.2 秒出海报，带封面，封面一共只请求了一次。&lt;/li&gt;&#xD;
&lt;/ul&gt;&#xD;
&lt;p&gt;改完再在线上测：同样的网络，Chrome 第一次打开要 2.4 到 3.9 秒，WebKit 1.3 秒，两边都带着封面。图片已经缓存时再次打开，要 90 到 180 毫秒。&lt;/p&gt;&#xD;
&lt;h2 id="-"&gt;三、发虚的二维码&lt;/h2&gt;&#xD;
&lt;p&gt;旧版把二维码生成在 84×84 的小 canvas 上，再画到海报对应的位置。海报导出是 2 倍分辨率，这一步相当于把二维码放大了一倍。&lt;/p&gt;&#xD;
&lt;p&gt;更糟的是，这篇文章的链接生成的是第 3 版二维码，每边 29 个模块。84 除以 29，每个模块约 2.9 个像素，本来就对不齐像素格，边缘已经是灰色的过渡像素，放大后就更虚了。&lt;/p&gt;&#xD;
&lt;p&gt;新版直接按导出的像素生成：二维码在海报上占 88×88，就生成 176×176，再一比一画上去，关掉图像平滑：&lt;/p&gt;&#xD;
&lt;pre&gt;&lt;code class="lang-javascript"&gt;&lt;span class="hljs-keyword"&gt;new&lt;/span&gt; QRCode(qrContainer, {&#xD;
    text: data.url,&#xD;
    width: qr.size * pixelRatio,   &lt;span class="hljs-comment"&gt;// 88 × 2&lt;/span&gt;&#xD;
    height: qr.size * pixelRatio,&#xD;
    colorDark: &lt;span class="hljs-string"&gt;'#151e2f'&lt;/span&gt;,&#xD;
    colorLight: &lt;span class="hljs-string"&gt;'#ffffff'&lt;/span&gt;,&#xD;
    correctLevel: QRCode.CorrectLevel.M&#xD;
});&#xD;
&lt;span class="hljs-comment"&gt;// ……&lt;/span&gt;&#xD;
ctx.imageSmoothingEnabled = &lt;span class="hljs-literal"&gt;false&lt;/span&gt;;&#xD;
ctx.drawImage(qrCanvas, qr.x, qr.y, qr.size, qr.size);&#xD;
&lt;/code&gt;&lt;/pre&gt;&#xD;
&lt;p&gt;同一篇文章新旧各生成一张海报，统计二维码区域里既不黑也不白的像素（亮度在 40 到 215 之间）：旧版占 32.0%，新版是 0%。&lt;/p&gt;&#xD;
&lt;p&gt;两张都能被 jsQR 解出正确的链接，旧版只是边缘发虚。不过二维码常常被拍屏、被聊天软件压缩以后再扫，边缘越清楚，能扫的余量越大。&lt;/p&gt;&#xD;
&lt;p&gt;顺带一提，旧版生成二维码后要等 120 毫秒再取图。qrcode.js 在 canvas 模式下其实是同步画完的，这个等待可以去掉，现在只留 30 毫秒。&lt;/p&gt;&#xD;
&lt;h2 id="-"&gt;四、弹窗：整张海报一屏放下&lt;/h2&gt;&#xD;
&lt;p&gt;旧的弹窗：&lt;/p&gt;&#xD;
&lt;ul&gt;&#xD;
&lt;li&gt;电脑上分左右两栏，右栏是几段说明文字；&lt;/li&gt;&#xD;
&lt;li&gt;手机上海报放在一个可以滚动的框里，框最高是 &lt;code&gt;calc(94dvh - 218px)&lt;/code&gt;。以可视区 390×664 的手机为例，这个框最高 406 像素，海报却有 541 像素高，下面的二维码要在框里往下滑才能看到。&lt;/li&gt;&#xD;
&lt;/ul&gt;&#xD;
&lt;p&gt;新弹窗只有标题、海报和“复制链接”“保存图片”两个按钮，说明文字全删了。海报的宽高直接由屏幕高度算出来：&lt;/p&gt;&#xD;
&lt;pre&gt;&lt;code class="lang-css"&gt;&lt;span class="hljs-selector-id"&gt;#shareImageCanvas&lt;/span&gt;, &lt;span class="hljs-selector-id"&gt;#shareImagePreviewImg&lt;/span&gt; {&#xD;
    &lt;span class="hljs-attribute"&gt;width&lt;/span&gt;: &lt;span class="hljs-built_in"&gt;calc&lt;/span&gt;(min(600px, 100vh - 196px) * .&lt;span class="hljs-number"&gt;625&lt;/span&gt;) &lt;span class="hljs-meta"&gt;!important&lt;/span&gt;;&#xD;
    &lt;span class="hljs-attribute"&gt;height&lt;/span&gt;: &lt;span class="hljs-built_in"&gt;min&lt;/span&gt;(600px, 100vh - 196px) &lt;span class="hljs-meta"&gt;!important&lt;/span&gt;;&#xD;
    &lt;span class="hljs-attribute"&gt;width&lt;/span&gt;: &lt;span class="hljs-built_in"&gt;calc&lt;/span&gt;(min(600px, 100dvh - 196px) * .&lt;span class="hljs-number"&gt;625&lt;/span&gt;) &lt;span class="hljs-meta"&gt;!important&lt;/span&gt;;&#xD;
    &lt;span class="hljs-attribute"&gt;height&lt;/span&gt;: &lt;span class="hljs-built_in"&gt;min&lt;/span&gt;(600px, 100dvh - 196px) &lt;span class="hljs-meta"&gt;!important&lt;/span&gt;;&#xD;
}&#xD;
&lt;/code&gt;&lt;/pre&gt;&#xD;
&lt;p&gt;这里有几个细节：&lt;/p&gt;&#xD;
&lt;ul&gt;&#xD;
&lt;li&gt;&lt;strong&gt;196 像素&lt;/strong&gt;是海报以外的部分：弹窗上下留白、标题栏、内边距、按钮。&lt;/li&gt;&#xD;
&lt;li&gt;&lt;strong&gt;宽高都写死&lt;/strong&gt;，而不是用 &lt;code&gt;max-width&lt;/code&gt; 加 &lt;code&gt;max-height&lt;/code&gt;。脚本会给 canvas 写行内的 &lt;code&gt;width&lt;/code&gt; 和 &lt;code&gt;height&lt;/code&gt;，所以要加 &lt;code&gt;!important&lt;/code&gt;。宽高一起写，比例就固定是 375∶600（0.625）。&lt;/li&gt;&#xD;
&lt;li&gt;&lt;strong&gt;先写一组 &lt;code&gt;vh&lt;/code&gt; 再写一组 &lt;code&gt;dvh&lt;/code&gt;&lt;/strong&gt;：不认识 &lt;code&gt;dvh&lt;/code&gt; 的旧内核会丢掉后一组，用前一组。&lt;/li&gt;&#xD;
&lt;/ul&gt;&#xD;
&lt;p&gt;手机上的弹窗是贴在屏幕底部的抽屉。除了高度，还要按宽度限制：&lt;code&gt;min(600px, 100dvh - 170px - 底部安全区, (100vw - 32px) * 1.6)&lt;/code&gt;。&lt;/p&gt;&#xD;
&lt;p&gt;几种屏幕实测下来，海报的显示尺寸是：&lt;/p&gt;&#xD;
&lt;table&gt;&#xD;
&lt;thead&gt;&#xD;
&lt;tr&gt;&#xD;
&lt;th style="text-align:center"&gt;屏幕（CSS 像素）&lt;/th&gt;&#xD;
&lt;th style="text-align:center"&gt;海报显示尺寸&lt;/th&gt;&#xD;
&lt;/tr&gt;&#xD;
&lt;/thead&gt;&#xD;
&lt;tbody&gt;&#xD;
&lt;tr&gt;&#xD;
&lt;td style="text-align:center"&gt;电脑 1440×900&lt;/td&gt;&#xD;
&lt;td style="text-align:center"&gt;375×600&lt;/td&gt;&#xD;
&lt;/tr&gt;&#xD;
&lt;tr&gt;&#xD;
&lt;td style="text-align:center"&gt;笔记本 1366×657&lt;/td&gt;&#xD;
&lt;td style="text-align:center"&gt;288×461&lt;/td&gt;&#xD;
&lt;/tr&gt;&#xD;
&lt;tr&gt;&#xD;
&lt;td style="text-align:center"&gt;Pixel 7（412×839）&lt;/td&gt;&#xD;
&lt;td style="text-align:center"&gt;375×600&lt;/td&gt;&#xD;
&lt;/tr&gt;&#xD;
&lt;tr&gt;&#xD;
&lt;td style="text-align:center"&gt;iPhone 13（可视区 390×664）&lt;/td&gt;&#xD;
&lt;td style="text-align:center"&gt;309×494&lt;/td&gt;&#xD;
&lt;/tr&gt;&#xD;
&lt;tr&gt;&#xD;
&lt;td style="text-align:center"&gt;iPhone SE（320×568）&lt;/td&gt;&#xD;
&lt;td style="text-align:center"&gt;249×398&lt;/td&gt;&#xD;
&lt;/tr&gt;&#xD;
&lt;/tbody&gt;&#xD;
&lt;/table&gt;&#xD;
&lt;p&gt;五种屏幕上，整个弹窗都没有出现滚动。&lt;/p&gt;&#xD;
&lt;h2 id="-"&gt;五、其他几处&lt;/h2&gt;&#xD;
&lt;ul&gt;&#xD;
&lt;li&gt;&lt;strong&gt;微信里保存&lt;/strong&gt;：微信内置浏览器长按 canvas 不会出现“保存图片”，&lt;code&gt;&amp;lt;a download&amp;gt;&lt;/code&gt; 下载也没反应。所以海报画好以后转成 &lt;code&gt;&amp;lt;img&amp;gt;&lt;/code&gt; 显示，长按就能存；在微信里点“保存图片”，按钮会提示“请长按图片保存”。&lt;/li&gt;&#xD;
&lt;li&gt;&lt;strong&gt;生成过程中关掉又打开&lt;/strong&gt;：每次生成都有一个编号，图片下载回来时如果已经不是最新的那次，就不再往画布上画，免得两次生成互相覆盖。&lt;/li&gt;&#xD;
&lt;li&gt;&lt;strong&gt;海报上的数据从哪来&lt;/strong&gt;：分类、标签、字数和阅读时长，由服务端渲染在分享弹窗的 &lt;code&gt;data-*&lt;/code&gt; 属性上。博客有六套主题，文章页的结构各不相同，从各自的页面里去抓这些信息并不可靠。&lt;/li&gt;&#xD;
&lt;/ul&gt;&#xD;
&lt;h2 id="-"&gt;总结&lt;/h2&gt;&#xD;
&lt;ul&gt;&#xD;
&lt;li&gt;&lt;strong&gt;中文断行&lt;/strong&gt;不能逐字拆，要先把英文和数字切成词，再加上“行首不放闭合标点、行尾不放开括号”这两条规则。改完要拿真实数据跑一遍：旧算法在 372 篇里错了 288 篇，新算法的前几版也还漏了 &lt;code&gt;(atoi)&lt;/code&gt; 和 &lt;code&gt;@RestController&lt;/code&gt; 这样的情况。&lt;/li&gt;&#xD;
&lt;li&gt;&lt;strong&gt;canvas 画跨域图片&lt;/strong&gt;，要服务器在响应里允许跨域，页面还要设置 &lt;code&gt;crossOrigin&lt;/code&gt;，两者缺一不可。还要想好图片加载失败或太慢时海报怎么画。另外，在 Chrome 里这种请求要单独建连接。&lt;/li&gt;&#xD;
&lt;li&gt;&lt;strong&gt;二维码按导出的像素生成&lt;/strong&gt;，一比一画上去，不要先生成小图再放大。&lt;/li&gt;&#xD;
&lt;li&gt;&lt;strong&gt;按屏幕尺寸算海报的宽高&lt;/strong&gt;，比把海报放进一个能滚动的框里好用。&lt;/li&gt;&#xD;
&lt;/ul&gt;</description>
      <pubDate>Sun, 04 Oct 2026 14:11:26 GMT</pubDate>
    </item>
    <item>
      <title>清掉没用上的字体后博客冷加载流量减半</title>
      <link>https://www.hqxiaozou.top/post/Yb2LyynjDJG</link>
      <description>&lt;p&gt;博客的默认主题换成流萤之后，上一轮测的是页面动起来卡不卡：滚动、动画、明暗切换。这一轮看的是页面来得快不快：手机尺寸、不带缓存，第一次打开要下载多少东西，最大的那块内容什么时候画出来，首屏有没有东西跳来跳去。&lt;/p&gt;&#xD;
&lt;p&gt;先放结果，四个页面的冷加载流量：&lt;/p&gt;&#xD;
&lt;table&gt;&#xD;
&lt;thead&gt;&#xD;
&lt;tr&gt;&#xD;
&lt;th style="text-align:center"&gt;页面&lt;/th&gt;&#xD;
&lt;th style="text-align:center"&gt;改前&lt;/th&gt;&#xD;
&lt;th style="text-align:center"&gt;改后&lt;/th&gt;&#xD;
&lt;/tr&gt;&#xD;
&lt;/thead&gt;&#xD;
&lt;tbody&gt;&#xD;
&lt;tr&gt;&#xD;
&lt;td style="text-align:center"&gt;首页&lt;/td&gt;&#xD;
&lt;td style="text-align:center"&gt;1495 KB&lt;/td&gt;&#xD;
&lt;td style="text-align:center"&gt;638 KB&lt;/td&gt;&#xD;
&lt;/tr&gt;&#xD;
&lt;tr&gt;&#xD;
&lt;td style="text-align:center"&gt;文章页&lt;/td&gt;&#xD;
&lt;td style="text-align:center"&gt;1606 KB&lt;/td&gt;&#xD;
&lt;td style="text-align:center"&gt;749 KB&lt;/td&gt;&#xD;
&lt;/tr&gt;&#xD;
&lt;tr&gt;&#xD;
&lt;td style="text-align:center"&gt;归档&lt;/td&gt;&#xD;
&lt;td style="text-align:center"&gt;1266 KB&lt;/td&gt;&#xD;
&lt;td style="text-align:center"&gt;409 KB&lt;/td&gt;&#xD;
&lt;/tr&gt;&#xD;
&lt;tr&gt;&#xD;
&lt;td style="text-align:center"&gt;留言板&lt;/td&gt;&#xD;
&lt;td style="text-align:center"&gt;2599 KB&lt;/td&gt;&#xD;
&lt;td style="text-align:center"&gt;1508 KB&lt;/td&gt;&#xD;
&lt;/tr&gt;&#xD;
&lt;/tbody&gt;&#xD;
&lt;/table&gt;&#xD;
&lt;p&gt;下面按发现的顺序记录：一个没人用的字体，一张发现得太晚的大图，一组尺寸不对的评论照片，以及改照片时撞上的一个渲染问题。&lt;/p&gt;&#xD;
&lt;h2 id="-"&gt;一、怎么测&lt;/h2&gt;&#xD;
&lt;p&gt;用本机 Chrome 的无头模式，通过 CDP 控制。视口设成 390×844、3 倍屏，开触摸模拟，禁用缓存，打开页面后等 15 秒，收集四类数据：&lt;/p&gt;&#xD;
&lt;ul&gt;&#xD;
&lt;li&gt;每个请求的传输字节数，按资源类型和域名汇总；&lt;/li&gt;&#xD;
&lt;li&gt;最大内容绘制（LCP）和布局偏移（CLS），都带上对应的元素；&lt;/li&gt;&#xD;
&lt;li&gt;head 里阻塞渲染的样式表和脚本；&lt;/li&gt;&#xD;
&lt;li&gt;页面上每张图的原始宽度和显示宽度。&lt;/li&gt;&#xD;
&lt;/ul&gt;&#xD;
&lt;p&gt;字节数来自 Network 域的事件，LCP 和 CLS 用页面里的 PerformanceObserver 记录（节选）：&lt;/p&gt;&#xD;
&lt;pre&gt;&lt;code class="lang-javascript"&gt;&lt;span class="hljs-regexp"&gt;//&lt;/span&gt; 每个请求结束时记下实际传输的字节数&#xD;
&lt;span class="hljs-keyword"&gt;if&lt;/span&gt; (x.method === &lt;span class="hljs-string"&gt;'Network.loadingFinished'&lt;/span&gt;) {&#xD;
  const r = reqs.get(x.params.requestId);&#xD;
  &lt;span class="hljs-keyword"&gt;if&lt;/span&gt; (r) r.bytes = x.params.encodedDataLength;&#xD;
}&#xD;
&#xD;
&lt;span class="hljs-regexp"&gt;//&lt;/span&gt; 页面里：最大内容绘制和布局偏移，都带上元素&#xD;
&lt;span class="hljs-keyword"&gt;new&lt;/span&gt; PerformanceObserver(&lt;span class="hljs-function"&gt;&lt;span class="hljs-params"&gt;(list)&lt;/span&gt; =&amp;gt;&lt;/span&gt; list.getEntries().forEach(&lt;span class="hljs-function"&gt;&lt;span class="hljs-params"&gt;(e)&lt;/span&gt; =&amp;gt;&lt;/span&gt; {&#xD;
  lcp.push({ t: Math.round(e.startTime), size: e.size, el: e.element &amp;amp;&amp;amp; e.element.className });&#xD;
})).observe({ type: &lt;span class="hljs-string"&gt;'largest-contentful-paint'&lt;/span&gt;, buffered: &lt;span class="hljs-literal"&gt;true&lt;/span&gt; });&#xD;
&lt;span class="hljs-keyword"&gt;new&lt;/span&gt; PerformanceObserver(&lt;span class="hljs-function"&gt;&lt;span class="hljs-params"&gt;(list)&lt;/span&gt; =&amp;gt;&lt;/span&gt; list.getEntries().forEach(&lt;span class="hljs-function"&gt;&lt;span class="hljs-params"&gt;(e)&lt;/span&gt; =&amp;gt;&lt;/span&gt; {&#xD;
  &lt;span class="hljs-keyword"&gt;if&lt;/span&gt; (!e.hadRecentInput) cls.push({ v: e.value, src: e.sources.map(&lt;span class="hljs-function"&gt;&lt;span class="hljs-params"&gt;(s)&lt;/span&gt; =&amp;gt;&lt;/span&gt; s.node &amp;amp;&amp;amp; s.node.className) });&#xD;
})).observe({ type: &lt;span class="hljs-string"&gt;'layout-shift'&lt;/span&gt;, buffered: &lt;span class="hljs-literal"&gt;true&lt;/span&gt; });&#xD;
&lt;/code&gt;&lt;/pre&gt;&#xD;
&lt;p&gt;有一点要先说明：从这台电脑连服务器，首字节时间在 2.6 到 5.1 秒之间跳，前后两次测出来的 LCP 毫秒数没法直接比。字节数和请求数不受网络波动影响，下面主要看它们。&lt;/p&gt;&#xD;
&lt;h2 id="-849-kb-"&gt;二、849 KB 的字体，页面上一个字都没用&lt;/h2&gt;&#xD;
&lt;p&gt;首页一共 44 个请求、1495 KB，其中字体 4 个、887 KB。单是一个 &lt;code&gt;lxgwwenkaiscreen-site-22cb6c14a1.woff2&lt;/code&gt; 就有 849 KB，占了整页的一半以上。&lt;/p&gt;&#xD;
&lt;p&gt;这是霞鹜文楷的站点子集，是原主题“云笺”的正文字体，按全站实际用到的字裁过，3637 个码点。五套新主题共用一个页头片段，里面照搬了云笺的预加载：&lt;/p&gt;&#xD;
&lt;pre&gt;&lt;code class="lang-html"&gt;&amp;lt;link rel=&lt;span class="hljs-string"&gt;"preload"&lt;/span&gt; href=&lt;span class="hljs-string"&gt;"https://niu.hqxiaozou.top/blog-static/fonts/lxgwwenkaiscreen-site-22cb6c14a1.woff2"&lt;/span&gt;&#xD;
      as=&lt;span class="hljs-string"&gt;"font"&lt;/span&gt; &lt;span class="hljs-built_in"&gt;type&lt;/span&gt;=&lt;span class="hljs-string"&gt;"font/woff2"&lt;/span&gt; crossorigin=&lt;span class="hljs-string"&gt;"anonymous"&lt;/span&gt;&amp;gt;&#xD;
&lt;/code&gt;&lt;/pre&gt;&#xD;
&lt;p&gt;可新主题都有自己的字体栈，把正文字体的变量换掉了。到底还有谁在用它？我在每个主题的首页、文章页和留言板上逐个检查有文字的元素，看 &lt;code&gt;getComputedStyle&lt;/code&gt; 算出来的字体栈第一项是不是文楷：&lt;/p&gt;&#xD;
&lt;table&gt;&#xD;
&lt;thead&gt;&#xD;
&lt;tr&gt;&#xD;
&lt;th style="text-align:center"&gt;主题&lt;/th&gt;&#xD;
&lt;th style="text-align:center"&gt;首页&lt;/th&gt;&#xD;
&lt;th style="text-align:center"&gt;文章页&lt;/th&gt;&#xD;
&lt;th style="text-align:center"&gt;留言板&lt;/th&gt;&#xD;
&lt;/tr&gt;&#xD;
&lt;/thead&gt;&#xD;
&lt;tbody&gt;&#xD;
&lt;tr&gt;&#xD;
&lt;td style="text-align:center"&gt;画报&lt;/td&gt;&#xD;
&lt;td style="text-align:center"&gt;0&lt;/td&gt;&#xD;
&lt;td style="text-align:center"&gt;0&lt;/td&gt;&#xD;
&lt;td style="text-align:center"&gt;0&lt;/td&gt;&#xD;
&lt;/tr&gt;&#xD;
&lt;tr&gt;&#xD;
&lt;td style="text-align:center"&gt;格致&lt;/td&gt;&#xD;
&lt;td style="text-align:center"&gt;0&lt;/td&gt;&#xD;
&lt;td style="text-align:center"&gt;0&lt;/td&gt;&#xD;
&lt;td style="text-align:center"&gt;0&lt;/td&gt;&#xD;
&lt;/tr&gt;&#xD;
&lt;tr&gt;&#xD;
&lt;td style="text-align:center"&gt;星河&lt;/td&gt;&#xD;
&lt;td style="text-align:center"&gt;0&lt;/td&gt;&#xD;
&lt;td style="text-align:center"&gt;0&lt;/td&gt;&#xD;
&lt;td style="text-align:center"&gt;0&lt;/td&gt;&#xD;
&lt;/tr&gt;&#xD;
&lt;tr&gt;&#xD;
&lt;td style="text-align:center"&gt;流萤&lt;/td&gt;&#xD;
&lt;td style="text-align:center"&gt;0&lt;/td&gt;&#xD;
&lt;td style="text-align:center"&gt;0&lt;/td&gt;&#xD;
&lt;td style="text-align:center"&gt;0&lt;/td&gt;&#xD;
&lt;/tr&gt;&#xD;
&lt;tr&gt;&#xD;
&lt;td style="text-align:center"&gt;纸墨&lt;/td&gt;&#xD;
&lt;td style="text-align:center"&gt;85&lt;/td&gt;&#xD;
&lt;td style="text-align:center"&gt;298&lt;/td&gt;&#xD;
&lt;td style="text-align:center"&gt;136&lt;/td&gt;&#xD;
&lt;/tr&gt;&#xD;
&lt;/tbody&gt;&#xD;
&lt;/table&gt;&#xD;
&lt;p&gt;只有纸墨还拿文楷当正文字体，另外四套一个字都没用到。&lt;/p&gt;&#xD;
&lt;p&gt;这里的关键在预加载本身。用 &lt;code&gt;@font-face&lt;/code&gt; 声明的字体本来是按需下载的：页面上有文字要用它，浏览器才去拉，没人用就不下。预加载把这一步变成了无条件下载。所以光留着 &lt;code&gt;@font-face&lt;/code&gt; 声明并不费流量，真正的问题是这条预加载。&lt;/p&gt;&#xD;
&lt;p&gt;改法是加个条件，只给纸墨。顺手把那个写满 &lt;code&gt;unicode-range&lt;/code&gt; 的 &lt;code&gt;@font-face&lt;/code&gt; 样式表（未压缩 24.8 KB，阻塞渲染）也一起加上条件：&lt;/p&gt;&#xD;
&lt;pre&gt;&lt;code class="lang-html"&gt;&amp;lt;th:block th:&lt;span class="hljs-keyword"&gt;if&lt;/span&gt;=&lt;span class="hljs-string"&gt;"&lt;span class="hljs-variable"&gt;${blogTheme == 'paper'}&lt;/span&gt;"&lt;/span&gt;&amp;gt;&#xD;
    &amp;lt;link rel=&lt;span class="hljs-string"&gt;"preload"&lt;/span&gt; href=&lt;span class="hljs-string"&gt;"https://niu.hqxiaozou.top/blog-static/fonts/lxgwwenkaiscreen-site-22cb6c14a1.woff2"&lt;/span&gt;&#xD;
          as=&lt;span class="hljs-string"&gt;"font"&lt;/span&gt; &lt;span class="hljs-built_in"&gt;type&lt;/span&gt;=&lt;span class="hljs-string"&gt;"font/woff2"&lt;/span&gt; crossorigin=&lt;span class="hljs-string"&gt;"anonymous"&lt;/span&gt;&amp;gt;&#xD;
&amp;lt;/th:block&amp;gt;&#xD;
&amp;lt;link rel=&lt;span class="hljs-string"&gt;"stylesheet"&lt;/span&gt; th:&lt;span class="hljs-keyword"&gt;if&lt;/span&gt;=&lt;span class="hljs-string"&gt;"&lt;span class="hljs-variable"&gt;${blogTheme == 'paper'}&lt;/span&gt;"&lt;/span&gt;&#xD;
      th:href=&lt;span class="hljs-string"&gt;"@{/fonts/lxgw-wenkai-screen/lxgwwenkaiscreen.css(v=site-20260924a)}"&lt;/span&gt;&amp;gt;&#xD;
&lt;/code&gt;&lt;/pre&gt;&#xD;
&lt;p&gt;改完首页的字体从 887 KB 降到 38 KB，剩下的是两个图标字体子集和一个等宽字体。开头那张表里，首页、文章页、归档各少了 857 KB，基本都是它。&lt;/p&gt;&#xD;
&lt;h2 id="-css-"&gt;三、最大内容是一张 CSS 背景图&lt;/h2&gt;&#xD;
&lt;p&gt;文章页、归档和留言板的 LCP 元素都是 &lt;code&gt;div.ff-banner-img&lt;/code&gt;，也就是首屏那张大图。它不是 &lt;code&gt;&amp;lt;img&amp;gt;&lt;/code&gt;，而是写在 style 上的 CSS 背景，电脑和手机各用一个尺寸：&lt;/p&gt;&#xD;
&lt;pre&gt;&lt;code class="lang-html"&gt;&amp;lt;div &lt;span class="hljs-class"&gt;&lt;span class="hljs-keyword"&gt;class&lt;/span&gt;&lt;/span&gt;=&lt;span class="hljs-string"&gt;"ff-banner-img"&lt;/span&gt; style=&lt;span class="hljs-string"&gt;"--ff-bg-lg:url(…/thumbnail/1920x/…);--ff-bg-sm:url(…/thumbnail/900x/…)"&lt;/span&gt;&amp;gt;&lt;span class="xml"&gt;&lt;span class="hljs-tag"&gt;&amp;lt;/&lt;span class="hljs-name"&gt;div&lt;/span&gt;&amp;gt;&lt;/span&gt;&lt;/span&gt;&#xD;
&lt;/code&gt;&lt;/pre&gt;&#xD;
&lt;pre&gt;&lt;code class="lang-css"&gt;&lt;span class="hljs-selector-class"&gt;.ff-banner-img&lt;/span&gt; { &lt;span class="hljs-attribute"&gt;background-image&lt;/span&gt;: &lt;span class="hljs-built_in"&gt;var&lt;/span&gt;(--ff-bg-lg); }&#xD;
@&lt;span class="hljs-keyword"&gt;media&lt;/span&gt; (max-width: &lt;span class="hljs-number"&gt;767px&lt;/span&gt;) {&#xD;
    &lt;span class="hljs-selector-class"&gt;.ff-banner-img&lt;/span&gt; { &lt;span class="hljs-attribute"&gt;background-image&lt;/span&gt;: &lt;span class="hljs-built_in"&gt;var&lt;/span&gt;(--ff-bg-sm, var(--ff-bg-lg)); }&#xD;
}&#xD;
&lt;/code&gt;&lt;/pre&gt;&#xD;
&lt;p&gt;浏览器有个预加载扫描器，HTML 还没解析完就会先扫出里面的 &lt;code&gt;&amp;lt;img&amp;gt;&lt;/code&gt;、&lt;code&gt;&amp;lt;link&amp;gt;&lt;/code&gt;、&lt;code&gt;&amp;lt;script&amp;gt;&lt;/code&gt;，提前开始下载。CSS 背景图它看不见：要等样式表下载完、样式算完，浏览器才知道这个元素要用哪张图。而首页 head 里有 13 个阻塞渲染的样式表。&lt;/p&gt;&#xD;
&lt;p&gt;改法是在大图前面放两条图片预加载，断点和 CSS 一模一样，同一时刻只会命中一条：&lt;/p&gt;&#xD;
&lt;pre&gt;&lt;code class="lang-html"&gt;&amp;lt;&lt;span class="hljs-keyword"&gt;link&lt;/span&gt; rel=&lt;span class="hljs-string"&gt;"preload"&lt;/span&gt; as=&lt;span class="hljs-string"&gt;"image"&lt;/span&gt; th:href=&lt;span class="hljs-string"&gt;"&lt;span class="hljs-subst"&gt;${commons.bannerSmall(image)}&lt;/span&gt;"&lt;/span&gt; media=&lt;span class="hljs-string"&gt;"(max-width: 767px)"&lt;/span&gt; fetchpriority=&lt;span class="hljs-string"&gt;"high"&lt;/span&gt;&amp;gt;&#xD;
&amp;lt;&lt;span class="hljs-keyword"&gt;link&lt;/span&gt; rel=&lt;span class="hljs-string"&gt;"preload"&lt;/span&gt; as=&lt;span class="hljs-string"&gt;"image"&lt;/span&gt; th:href=&lt;span class="hljs-string"&gt;"&lt;span class="hljs-subst"&gt;${commons.banner(image)}&lt;/span&gt;"&lt;/span&gt; media=&lt;span class="hljs-string"&gt;"(min-width: 768px)"&lt;/span&gt; fetchpriority=&lt;span class="hljs-string"&gt;"high"&lt;/span&gt;&amp;gt;&#xD;
&lt;/code&gt;&lt;/pre&gt;&#xD;
&lt;p&gt;更顺手的写法是 &lt;code&gt;imagesrcset&lt;/code&gt; 加 &lt;code&gt;imagesizes&lt;/code&gt;，让浏览器自己挑，但这里不能用。CSS 是按视口宽度切换的，不看像素密度：390 宽的 3 倍屏，CSS 用的是 900 宽那张；&lt;code&gt;imagesrcset&lt;/code&gt; 却会按 390×3=1170 去挑 1920 宽那张。结果是预加载一张、背景再下一张，同一张图下两遍。所以预加载的条件必须照抄 CSS 的媒体查询。&lt;/p&gt;&#xD;
&lt;p&gt;改完实测，每个视口只发出一个大图请求，手机是 900 宽，电脑是 1920 宽。&lt;/p&gt;&#xD;
&lt;h2 id="-220px-1080px"&gt;四、显示 220px 的照片，下载的是 1080px&lt;/h2&gt;&#xD;
&lt;p&gt;留言板是最重的页面：198 个请求、2599 KB，其中图片 1407 KB。&lt;/p&gt;&#xD;
&lt;p&gt;留言里的照片在手机上最多显示 220px 宽，竖图还要再被 260px 的最大高度压一下，实际只有 117px 宽；电脑上最多 300 到 320px。下载的却是 1080 宽的缩略图，单张 128 到 138 KB。&lt;/p&gt;&#xD;
&lt;p&gt;这些地址是服务端渲染时统一补的七牛处理参数，只有 1080 这一档。改成给浏览器三档，让它按屏幕挑：&lt;/p&gt;&#xD;
&lt;pre&gt;&lt;code class="lang-html"&gt;&lt;span class="hljs-tag"&gt;&amp;lt;&lt;span class="hljs-name"&gt;img&lt;/span&gt; &lt;span class="hljs-attr"&gt;src&lt;/span&gt;=&lt;span class="hljs-string"&gt;"…/a.jpg?imageMogr2/thumbnail/1080x/quality/78/format/webp/ignore-error/1"&lt;/span&gt;&#xD;
     &lt;span class="hljs-attr"&gt;srcset&lt;/span&gt;=&lt;span class="hljs-string"&gt;"…/a.jpg?imageMogr2/thumbnail/480x/quality/78/format/webp/ignore-error/1 480w,&#xD;
             …/a.jpg?imageMogr2/thumbnail/720x/quality/78/format/webp/ignore-error/1 720w,&#xD;
             …/a.jpg?imageMogr2/thumbnail/1080x/quality/78/format/webp/ignore-error/1 1080w"&lt;/span&gt;&#xD;
     &lt;span class="hljs-attr"&gt;sizes&lt;/span&gt;=&lt;span class="hljs-string"&gt;"(max-width: 768px) 240px, 320px"&lt;/span&gt;&amp;gt;&lt;/span&gt;&#xD;
&lt;/code&gt;&lt;/pre&gt;&#xD;
&lt;p&gt;有两个地方要留意。&lt;/p&gt;&#xD;
&lt;p&gt;&lt;strong&gt;src 留着 1080 宽。&lt;/strong&gt;点开照片看大图的预览，读的是 &lt;code&gt;img.src&lt;/code&gt;，而 &lt;code&gt;src&lt;/code&gt; 返回的永远是属性里写的地址，不是浏览器从 srcset 里挑中的那张（那张在 &lt;code&gt;currentSrc&lt;/code&gt; 里）。所以列表里加载小图，点开照样是 1080 宽。&lt;/p&gt;&#xD;
&lt;p&gt;&lt;strong&gt;邮件不能带 srcset。&lt;/strong&gt;有人回复时，评论正文也会发进邮件。邮件那边会把图片下载下来作为内嵌附件，测试要求 HTML 里不能留下图床地址，srcset 里的地址会漏过去。所以只给网页那条渲染路径加。&lt;/p&gt;&#xD;
&lt;p&gt;改完手机 3 倍屏挑的是 720 宽，电脑 1 倍屏挑的是 480 宽，点开大图仍然是 1080 宽。&lt;/p&gt;&#xD;
&lt;h3 id="-srcset-"&gt;改完一看，srcset 没了&lt;/h3&gt;&#xD;
&lt;p&gt;单元测试都过了，本地起服务一看留言板的源码，照片的 src 带上了处理参数，srcset 却一个都没有。单独调那个规范化函数，输出里明明有 srcset。&lt;/p&gt;&#xD;
&lt;p&gt;原因是评论正文在列表查询里被规范化了两遍，分页查询内部一次，外面又一次，代码注释里写着“同一批记录可能被处理两次”。规范化的第一步是安全重建：把 &lt;code&gt;&amp;lt;img&amp;gt;&lt;/code&gt; 标签拆开，只保留 src、alt、style 三个属性重新拼。于是第二遍时，srcset 被当成不认识的属性丢掉了；随后补 srcset 的那一步看到 src 已经带了参数，按“已经处理过的不动”跳了过去。&lt;/p&gt;&#xD;
&lt;table&gt;&#xD;
&lt;thead&gt;&#xD;
&lt;tr&gt;&#xD;
&lt;th style="text-align:center"&gt;阶段&lt;/th&gt;&#xD;
&lt;th style="text-align:center"&gt;src&lt;/th&gt;&#xD;
&lt;th style="text-align:center"&gt;srcset&lt;/th&gt;&#xD;
&lt;/tr&gt;&#xD;
&lt;/thead&gt;&#xD;
&lt;tbody&gt;&#xD;
&lt;tr&gt;&#xD;
&lt;td style="text-align:center"&gt;库里存的&lt;/td&gt;&#xD;
&lt;td style="text-align:center"&gt;原图地址&lt;/td&gt;&#xD;
&lt;td style="text-align:center"&gt;没有&lt;/td&gt;&#xD;
&lt;/tr&gt;&#xD;
&lt;tr&gt;&#xD;
&lt;td style="text-align:center"&gt;第一遍&lt;/td&gt;&#xD;
&lt;td style="text-align:center"&gt;补上参数&lt;/td&gt;&#xD;
&lt;td style="text-align:center"&gt;补上&lt;/td&gt;&#xD;
&lt;/tr&gt;&#xD;
&lt;tr&gt;&#xD;
&lt;td style="text-align:center"&gt;第二遍重建&lt;/td&gt;&#xD;
&lt;td style="text-align:center"&gt;带着参数&lt;/td&gt;&#xD;
&lt;td style="text-align:center"&gt;丢了&lt;/td&gt;&#xD;
&lt;/tr&gt;&#xD;
&lt;tr&gt;&#xD;
&lt;td style="text-align:center"&gt;第二遍补 srcset&lt;/td&gt;&#xD;
&lt;td style="text-align:center"&gt;带着参数&lt;/td&gt;&#xD;
&lt;td style="text-align:center"&gt;跳过&lt;/td&gt;&#xD;
&lt;/tr&gt;&#xD;
&lt;/tbody&gt;&#xD;
&lt;/table&gt;&#xD;
&lt;p&gt;修法是让这一步幂等：src 结尾恰好是自己加的那串默认参数时，按原图重新生成一遍。&lt;/p&gt;&#xD;
&lt;pre&gt;&lt;code class="lang-java"&gt;&lt;span class="hljs-comment"&gt;// 第二遍时 src 已带默认参数、srcset 已经丢了：去掉默认参数，按原图重新生成&lt;/span&gt;&#xD;
&lt;span class="hljs-built_in"&gt;String&lt;/span&gt; bare = url.endsWith(PARAMS) ? url.substring(&lt;span class="hljs-number"&gt;0&lt;/span&gt;, url.length() - PARAMS.length()) : url;&#xD;
&lt;/code&gt;&lt;/pre&gt;&#xD;
&lt;p&gt;再补一条测试，规范化两遍和一遍的结果必须完全一样。&lt;/p&gt;&#xD;
&lt;p&gt;改完留言板从 2599 KB 降到 1508 KB，其中图片从 1407 KB 降到 1174 KB。图片省得不算多，因为再往下的照片本来就是懒加载，首屏附近只有 4 张换成了小图。&lt;/p&gt;&#xD;
&lt;h2 id="-"&gt;五、首屏上下跳&lt;/h2&gt;&#xD;
&lt;p&gt;最后一个问题不在流量上。首页大标题下面有一行打字机效果的副标题，在 390 宽的手机上会折成两行。打字时它一会儿一行一会儿两行，而大图里的内容是垂直居中的，于是上面的标题和下面的按钮跟着上下跳。15 秒里记到 84 次布局偏移，来源就是它们：&lt;/p&gt;&#xD;
&lt;table&gt;&#xD;
&lt;thead&gt;&#xD;
&lt;tr&gt;&#xD;
&lt;th style="text-align:center"&gt;元素&lt;/th&gt;&#xD;
&lt;th style="text-align:center"&gt;位置变化&lt;/th&gt;&#xD;
&lt;/tr&gt;&#xD;
&lt;/thead&gt;&#xD;
&lt;tbody&gt;&#xD;
&lt;tr&gt;&#xD;
&lt;td style="text-align:center"&gt;标题 h1&lt;/td&gt;&#xD;
&lt;td style="text-align:center"&gt;302 → 313&lt;/td&gt;&#xD;
&lt;/tr&gt;&#xD;
&lt;tr&gt;&#xD;
&lt;td style="text-align:center"&gt;按钮组&lt;/td&gt;&#xD;
&lt;td style="text-align:center"&gt;453 → 464&lt;/td&gt;&#xD;
&lt;/tr&gt;&#xD;
&lt;/tbody&gt;&#xD;
&lt;/table&gt;&#xD;
&lt;p&gt;改法是先量一遍每句话完整打出来时这一行有多高，取最大值写成最小高度，窗口尺寸变了再量一次：&lt;/p&gt;&#xD;
&lt;pre&gt;&lt;code class="lang-javascript"&gt;&lt;span class="hljs-function"&gt;&lt;span class="hljs-keyword"&gt;function&lt;/span&gt; &lt;span class="hljs-title"&gt;reserve&lt;/span&gt;&lt;span class="hljs-params"&gt;()&lt;/span&gt; &lt;/span&gt;{&#xD;
    &lt;span class="hljs-keyword"&gt;var&lt;/span&gt; keep = el.textContent, max = &lt;span class="hljs-number"&gt;0&lt;/span&gt;;&#xD;
    line.style.minHeight = &lt;span class="hljs-string"&gt;''&lt;/span&gt;;&#xD;
    &lt;span class="hljs-keyword"&gt;list&lt;/span&gt;.&lt;span class="hljs-keyword"&gt;forEach&lt;/span&gt;(&lt;span class="hljs-function"&gt;&lt;span class="hljs-keyword"&gt;function&lt;/span&gt; &lt;span class="hljs-params"&gt;(text)&lt;/span&gt; &lt;/span&gt;{&#xD;
        el.textContent = text;&#xD;
        max = Math.max(max, line.offsetHeight);&#xD;
    });&#xD;
    el.textContent = keep;&#xD;
    line.style.minHeight = max + &lt;span class="hljs-string"&gt;'px'&lt;/span&gt;;&#xD;
}&#xD;
&lt;/code&gt;&lt;/pre&gt;&#xD;
&lt;p&gt;手机上这一行固定成两行高，标题和按钮不再跳。累计布局偏移从 0.054 降到 0.021，剩下的是居中的文字逐字变长时自身的位移。&lt;/p&gt;&#xD;
&lt;h2 id="-"&gt;总结&lt;/h2&gt;&#xD;
&lt;table&gt;&#xD;
&lt;thead&gt;&#xD;
&lt;tr&gt;&#xD;
&lt;th style="text-align:center"&gt;问题&lt;/th&gt;&#xD;
&lt;th style="text-align:center"&gt;根因&lt;/th&gt;&#xD;
&lt;th style="text-align:center"&gt;改法&lt;/th&gt;&#xD;
&lt;/tr&gt;&#xD;
&lt;/thead&gt;&#xD;
&lt;tbody&gt;&#xD;
&lt;tr&gt;&#xD;
&lt;td style="text-align:center"&gt;字体白下 849 KB&lt;/td&gt;&#xD;
&lt;td style="text-align:center"&gt;预加载照搬旧主题&lt;/td&gt;&#xD;
&lt;td style="text-align:center"&gt;只给纸墨&lt;/td&gt;&#xD;
&lt;/tr&gt;&#xD;
&lt;tr&gt;&#xD;
&lt;td style="text-align:center"&gt;大图发现得晚&lt;/td&gt;&#xD;
&lt;td style="text-align:center"&gt;CSS 背景图&lt;/td&gt;&#xD;
&lt;td style="text-align:center"&gt;同断点预加载&lt;/td&gt;&#xD;
&lt;/tr&gt;&#xD;
&lt;tr&gt;&#xD;
&lt;td style="text-align:center"&gt;照片下载过大&lt;/td&gt;&#xD;
&lt;td style="text-align:center"&gt;只有 1080 一档&lt;/td&gt;&#xD;
&lt;td style="text-align:center"&gt;srcset 三档&lt;/td&gt;&#xD;
&lt;/tr&gt;&#xD;
&lt;tr&gt;&#xD;
&lt;td style="text-align:center"&gt;srcset 消失&lt;/td&gt;&#xD;
&lt;td style="text-align:center"&gt;规范化跑两遍&lt;/td&gt;&#xD;
&lt;td style="text-align:center"&gt;改写做成幂等&lt;/td&gt;&#xD;
&lt;/tr&gt;&#xD;
&lt;tr&gt;&#xD;
&lt;td style="text-align:center"&gt;首屏上下跳&lt;/td&gt;&#xD;
&lt;td style="text-align:center"&gt;副标题折行&lt;/td&gt;&#xD;
&lt;td style="text-align:center"&gt;预留最高一行&lt;/td&gt;&#xD;
&lt;/tr&gt;&#xD;
&lt;/tbody&gt;&#xD;
&lt;/table&gt;&#xD;
&lt;p&gt;这一轮的几条经验：&lt;/p&gt;&#xD;
&lt;p&gt;&lt;strong&gt;预加载是强制下载。&lt;/strong&gt;&lt;code&gt;@font-face&lt;/code&gt; 没人用就不下，预加载不管用不用都下。多个主题共用的片段尤其要回头查一遍，当初为谁加的。&lt;/p&gt;&#xD;
&lt;p&gt;&lt;strong&gt;背景图当最大内容时，浏览器发现得晚。&lt;/strong&gt;可以预加载，但条件要和 CSS 的切换条件完全一致，不然会下两遍。&lt;/p&gt;&#xD;
&lt;p&gt;&lt;strong&gt;加 srcset 之前，先想清楚还有谁在读 src。&lt;/strong&gt;预览读 src，邮件不能带 srcset。渲染链路里可能有一段会跑两遍，改写要做成幂等的。&lt;/p&gt;&#xD;
&lt;p&gt;&lt;strong&gt;网络波动大的时候，看字节数和请求数，比看毫秒稳。&lt;/strong&gt;&lt;/p&gt;</description>
      <pubDate>Fri, 02 Oct 2026 09:32:23 GMT</pubDate>
    </item>
    <item>
      <title>position fixed 为什么不固定了</title>
      <link>https://www.hqxiaozou.top/post/Z8R39GVz5wk</link>
      <description>&lt;h2 id="-64px-"&gt;一、一个只有 64px 高的抽屉&lt;/h2&gt;&#xD;
&lt;p&gt;流萤主题的手机菜单，原来是从顶栏右上角弹出的一张小卡片，后来照着参考站改成了右侧抽屉：宽 70%，从屏幕右边滑出，高度铺满整屏。&lt;/p&gt;&#xD;
&lt;p&gt;菜单按钮在顶栏里，原来的下拉卡片也写在顶栏里，所以第一版很自然地把抽屉也放进了顶栏：&lt;/p&gt;&#xD;
&lt;pre&gt;&lt;code class="lang-html"&gt;&amp;lt;header &lt;span class="hljs-class"&gt;&lt;span class="hljs-keyword"&gt;class&lt;/span&gt;&lt;/span&gt;=&lt;span class="hljs-string"&gt;"ff-nav"&lt;/span&gt;&amp;gt;              &lt;span class="xml"&gt;&lt;span class="hljs-comment"&gt;&amp;lt;!-- 毛玻璃顶栏 --&amp;gt;&lt;/span&gt;&#xD;
    &lt;span class="hljs-tag"&gt;&amp;lt;&lt;span class="hljs-name"&gt;a&lt;/span&gt; &lt;span class="hljs-attr"&gt;class&lt;/span&gt;=&lt;span class="hljs-string"&gt;"ff-brand"&lt;/span&gt;&amp;gt;&lt;/span&gt;…&lt;span class="hljs-tag"&gt;&amp;lt;/&lt;span class="hljs-name"&gt;a&lt;/span&gt;&amp;gt;&lt;/span&gt;&#xD;
    &lt;span class="hljs-tag"&gt;&amp;lt;&lt;span class="hljs-name"&gt;button&lt;/span&gt; &lt;span class="hljs-attr"&gt;id&lt;/span&gt;=&lt;span class="hljs-string"&gt;"ff-menu-btn"&lt;/span&gt;&amp;gt;&lt;/span&gt;…&lt;span class="hljs-tag"&gt;&amp;lt;/&lt;span class="hljs-name"&gt;button&lt;/span&gt;&amp;gt;&lt;/span&gt;&#xD;
    &lt;span class="hljs-tag"&gt;&amp;lt;&lt;span class="hljs-name"&gt;aside&lt;/span&gt; &lt;span class="hljs-attr"&gt;class&lt;/span&gt;=&lt;span class="hljs-string"&gt;"ff-drawer"&lt;/span&gt;&amp;gt;&lt;/span&gt;…&lt;span class="hljs-tag"&gt;&amp;lt;/&lt;span class="hljs-name"&gt;aside&lt;/span&gt;&amp;gt;&lt;/span&gt;  &lt;span class="hljs-comment"&gt;&amp;lt;!-- 抽屉 --&amp;gt;&lt;/span&gt;&#xD;
&lt;span class="hljs-tag"&gt;&amp;lt;/&lt;span class="hljs-name"&gt;header&lt;/span&gt;&amp;gt;&lt;/span&gt;&lt;/span&gt;&#xD;
&lt;/code&gt;&lt;/pre&gt;&#xD;
&lt;pre&gt;&lt;code class="lang-css"&gt;&lt;span class="hljs-selector-class"&gt;.ff-nav&lt;/span&gt; {&#xD;
    &lt;span class="hljs-attribute"&gt;position&lt;/span&gt;: fixed; &lt;span class="hljs-attribute"&gt;top&lt;/span&gt;: &lt;span class="hljs-number"&gt;0&lt;/span&gt;; &lt;span class="hljs-attribute"&gt;left&lt;/span&gt;: &lt;span class="hljs-number"&gt;0&lt;/span&gt;; &lt;span class="hljs-attribute"&gt;right&lt;/span&gt;: &lt;span class="hljs-number"&gt;0&lt;/span&gt;; &lt;span class="hljs-attribute"&gt;height&lt;/span&gt;: &lt;span class="hljs-number"&gt;64px&lt;/span&gt;;&#xD;
    &lt;span class="hljs-attribute"&gt;backdrop-filter&lt;/span&gt;: &lt;span class="hljs-built_in"&gt;blur&lt;/span&gt;(20px) &lt;span class="hljs-built_in"&gt;saturate&lt;/span&gt;(1.6);   &lt;span class="hljs-comment"&gt;/* 毛玻璃 */&lt;/span&gt;&#xD;
    &lt;span class="hljs-attribute"&gt;transition&lt;/span&gt;: transform .&lt;span class="hljs-number"&gt;3s&lt;/span&gt; ease;&#xD;
}&#xD;
&lt;span class="hljs-selector-class"&gt;.ff-nav&lt;/span&gt;&lt;span class="hljs-selector-class"&gt;.is-hidden&lt;/span&gt; { &lt;span class="hljs-attribute"&gt;transform&lt;/span&gt;: &lt;span class="hljs-built_in"&gt;translateY&lt;/span&gt;(-100%); }   &lt;span class="hljs-comment"&gt;/* 往下滚时收起顶栏 */&lt;/span&gt;&#xD;
&#xD;
&lt;span class="hljs-selector-class"&gt;.ff-drawer&lt;/span&gt; {&#xD;
    &lt;span class="hljs-attribute"&gt;position&lt;/span&gt;: fixed; &lt;span class="hljs-attribute"&gt;top&lt;/span&gt;: &lt;span class="hljs-number"&gt;0&lt;/span&gt;; &lt;span class="hljs-attribute"&gt;right&lt;/span&gt;: &lt;span class="hljs-number"&gt;0&lt;/span&gt;; &lt;span class="hljs-attribute"&gt;bottom&lt;/span&gt;: &lt;span class="hljs-number"&gt;0&lt;/span&gt;;&#xD;
    &lt;span class="hljs-attribute"&gt;width&lt;/span&gt;: &lt;span class="hljs-number"&gt;70%&lt;/span&gt;;&#xD;
}&#xD;
&lt;/code&gt;&lt;/pre&gt;&#xD;
&lt;p&gt;按直觉，&lt;code&gt;position: fixed&lt;/code&gt; 再加上 &lt;code&gt;top: 0&lt;/code&gt; 和 &lt;code&gt;bottom: 0&lt;/code&gt;，抽屉就应该铺满整屏。&lt;/p&gt;&#xD;
&lt;p&gt;在 390 × 844 的手机视口里实测，结果是这样的：&lt;/p&gt;&#xD;
&lt;table&gt;&#xD;
&lt;thead&gt;&#xD;
&lt;tr&gt;&#xD;
&lt;th style="text-align:center"&gt;抽屉放在哪&lt;/th&gt;&#xD;
&lt;th style="text-align:center"&gt;抽屉的尺寸和顶边&lt;/th&gt;&#xD;
&lt;th style="text-align:center"&gt;offsetParent&lt;/th&gt;&#xD;
&lt;/tr&gt;&#xD;
&lt;/thead&gt;&#xD;
&lt;tbody&gt;&#xD;
&lt;tr&gt;&#xD;
&lt;td style="text-align:center"&gt;顶栏里面&lt;/td&gt;&#xD;
&lt;td style="text-align:center"&gt;273 × 64，顶边 0&lt;/td&gt;&#xD;
&lt;td style="text-align:center"&gt;顶栏&lt;/td&gt;&#xD;
&lt;/tr&gt;&#xD;
&lt;tr&gt;&#xD;
&lt;td style="text-align:center"&gt;顶栏里面，顶栏收起后&lt;/td&gt;&#xD;
&lt;td style="text-align:center"&gt;273 × 64，顶边 -64&lt;/td&gt;&#xD;
&lt;td style="text-align:center"&gt;顶栏&lt;/td&gt;&#xD;
&lt;/tr&gt;&#xD;
&lt;tr&gt;&#xD;
&lt;td style="text-align:center"&gt;挪到顶栏外面&lt;/td&gt;&#xD;
&lt;td style="text-align:center"&gt;273 × 844，顶边 0&lt;/td&gt;&#xD;
&lt;td style="text-align:center"&gt;null&lt;/td&gt;&#xD;
&lt;/tr&gt;&#xD;
&lt;/tbody&gt;&#xD;
&lt;/table&gt;&#xD;
&lt;p&gt;抽屉的高度刚好等于顶栏的 64px；页面往下滚、顶栏收起时，抽屉还跟着顶栏一起被移出了屏幕。&lt;/p&gt;&#xD;
&lt;p&gt;问题不在抽屉本身，而在它的祖先：&lt;strong&gt;顶栏的 backdrop-filter 让顶栏成了抽屉的包含块。&lt;/strong&gt;&lt;/p&gt;&#xD;
&lt;h2 id="-fixed-"&gt;二、fixed 元素的包含块，什么时候不是视口&lt;/h2&gt;&#xD;
&lt;p&gt;&lt;code&gt;top&lt;/code&gt;、&lt;code&gt;right&lt;/code&gt;、&lt;code&gt;bottom&lt;/code&gt;、&lt;code&gt;left&lt;/code&gt; 这几个值，都是相对“包含块”计算的：&lt;/p&gt;&#xD;
&lt;ul&gt;&#xD;
&lt;li&gt;&lt;code&gt;position: absolute&lt;/code&gt;：包含块是最近的、&lt;code&gt;position&lt;/code&gt; 不为 &lt;code&gt;static&lt;/code&gt; 的祖先；&lt;/li&gt;&#xD;
&lt;li&gt;&lt;code&gt;position: fixed&lt;/code&gt;：一般情况下包含块是视口，所以页面滚动时它不动。&lt;/li&gt;&#xD;
&lt;/ul&gt;&#xD;
&lt;p&gt;“一般情况下”这几个字很关键。规范还规定了一组例外：当某个祖先满足下面这些条件时，它会为自己的&lt;strong&gt;所有后代&lt;/strong&gt;建立包含块，fixed 后代也不例外。&lt;/p&gt;&#xD;
&lt;p&gt;这时 fixed 元素就不再相对视口，而是相对这个祖先：尺寸被它限制，滚动时也跟着它走。&lt;/p&gt;&#xD;
&lt;pre&gt;&lt;code class="lang-mermaid"&gt;&lt;span class="hljs-attribute"&gt;graph&lt;/span&gt; LR&#xD;
    &lt;span class="hljs-attribute"&gt;A&lt;/span&gt;[&lt;span class="hljs-string"&gt;"fixed 元素"&lt;/span&gt;] --&amp;gt; B{&lt;span class="hljs-string"&gt;"逐个往上看祖先"&lt;/span&gt;}&#xD;
    &lt;span class="hljs-attribute"&gt;B&lt;/span&gt; --&amp;gt;|&lt;span class="hljs-string"&gt;"某个祖先满足条件"&lt;/span&gt;| C[&lt;span class="hljs-string"&gt;"包含块 = 这个祖先&amp;lt;br/&amp;gt;尺寸受它限制，跟着它滚动"&lt;/span&gt;]&#xD;
    &lt;span class="hljs-attribute"&gt;B&lt;/span&gt; --&amp;gt;|&lt;span class="hljs-string"&gt;"一直到根都没有"&lt;/span&gt;| D[&lt;span class="hljs-string"&gt;"包含块 = 视口&amp;lt;br/&amp;gt;滚动时不动"&lt;/span&gt;]&#xD;
&lt;/code&gt;&lt;/pre&gt;&#xD;
&lt;p&gt;这些条件分散在好几份规范里：&lt;/p&gt;&#xD;
&lt;table&gt;&#xD;
&lt;thead&gt;&#xD;
&lt;tr&gt;&#xD;
&lt;th style="text-align:center"&gt;规范&lt;/th&gt;&#xD;
&lt;th style="text-align:center"&gt;会让祖先成为 fixed 后代包含块的写法&lt;/th&gt;&#xD;
&lt;/tr&gt;&#xD;
&lt;/thead&gt;&#xD;
&lt;tbody&gt;&#xD;
&lt;tr&gt;&#xD;
&lt;td style="text-align:center"&gt;CSS Transforms&lt;/td&gt;&#xD;
&lt;td style="text-align:center"&gt;transform、translate、rotate、scale、perspective 不为 none&lt;/td&gt;&#xD;
&lt;/tr&gt;&#xD;
&lt;tr&gt;&#xD;
&lt;td style="text-align:center"&gt;Filter Effects&lt;/td&gt;&#xD;
&lt;td style="text-align:center"&gt;filter、backdrop-filter 不为 none（filter 用在根元素上除外）&lt;/td&gt;&#xD;
&lt;/tr&gt;&#xD;
&lt;tr&gt;&#xD;
&lt;td style="text-align:center"&gt;CSS Will Change&lt;/td&gt;&#xD;
&lt;td style="text-align:center"&gt;will-change 里写了上面这些会建立包含块的属性&lt;/td&gt;&#xD;
&lt;/tr&gt;&#xD;
&lt;tr&gt;&#xD;
&lt;td style="text-align:center"&gt;CSS Containment&lt;/td&gt;&#xD;
&lt;td style="text-align:center"&gt;contain 含 layout 或 paint；content-visibility: auto 也会带上这两种约束&lt;/td&gt;&#xD;
&lt;/tr&gt;&#xD;
&lt;/tbody&gt;&#xD;
&lt;/table&gt;&#xD;
&lt;p&gt;注意判断标准是&lt;strong&gt;值是不是 none&lt;/strong&gt;，而不是这个属性有没有产生可见的效果。下面的实测里，&lt;code&gt;filter: blur(0)&lt;/code&gt; 和 &lt;code&gt;translate: 0px&lt;/code&gt; 什么都没改变，照样把 fixed 后代截了下来。&lt;/p&gt;&#xD;
&lt;h2 id="-20-"&gt;三、实测：20 种写法，哪些会“截胡”&lt;/h2&gt;&#xD;
&lt;p&gt;规范读起来很分散，所以干脆把常见写法逐个测一遍。&lt;/p&gt;&#xD;
&lt;p&gt;测试页很简单：父元素高 72px、距页面顶部 150px；子元素 &lt;code&gt;position: fixed; inset: 0&lt;/code&gt;；页面下面再垫 3000px 高的内容，让页面能滚动。&lt;/p&gt;&#xD;
&lt;pre&gt;&lt;code class="lang-html"&gt;&lt;span class="hljs-tag"&gt;&amp;lt;&lt;span class="hljs-name"&gt;header&lt;/span&gt; &lt;span class="hljs-attr"&gt;id&lt;/span&gt;=&lt;span class="hljs-string"&gt;"p"&lt;/span&gt; &lt;span class="hljs-attr"&gt;style&lt;/span&gt;=&lt;span class="hljs-string"&gt;"margin-top: 150px; height: 72px; /* 被测的写法 */"&lt;/span&gt;&amp;gt;&lt;/span&gt;&#xD;
    &lt;span class="hljs-tag"&gt;&amp;lt;&lt;span class="hljs-name"&gt;div&lt;/span&gt; &lt;span class="hljs-attr"&gt;id&lt;/span&gt;=&lt;span class="hljs-string"&gt;"c"&lt;/span&gt; &lt;span class="hljs-attr"&gt;style&lt;/span&gt;=&lt;span class="hljs-string"&gt;"position: fixed; inset: 0"&lt;/span&gt;&amp;gt;&lt;/span&gt;&lt;span class="hljs-tag"&gt;&amp;lt;/&lt;span class="hljs-name"&gt;div&lt;/span&gt;&amp;gt;&lt;/span&gt;&#xD;
&lt;span class="hljs-tag"&gt;&amp;lt;/&lt;span class="hljs-name"&gt;header&lt;/span&gt;&amp;gt;&lt;/span&gt;&#xD;
&lt;span class="hljs-tag"&gt;&amp;lt;&lt;span class="hljs-name"&gt;div&lt;/span&gt; &lt;span class="hljs-attr"&gt;style&lt;/span&gt;=&lt;span class="hljs-string"&gt;"height: 3000px"&lt;/span&gt;&amp;gt;&lt;/span&gt;&lt;span class="hljs-tag"&gt;&amp;lt;/&lt;span class="hljs-name"&gt;div&lt;/span&gt;&amp;gt;&lt;/span&gt;&#xD;
&lt;/code&gt;&lt;/pre&gt;&#xD;
&lt;p&gt;测试环境是 macOS 上的 Chrome 154（无头模式），视口 800 × 600。每种写法记录三个值：子元素的高度、页面向下滚动 400px 后子元素顶边的位置、子元素的 &lt;code&gt;offsetParent&lt;/code&gt;。&lt;/p&gt;&#xD;
&lt;p&gt;判断方法：&lt;/p&gt;&#xD;
&lt;ul&gt;&#xD;
&lt;li&gt;&lt;strong&gt;相对视口&lt;/strong&gt;：子元素高 600px，滚动后顶边仍是 0；&lt;/li&gt;&#xD;
&lt;li&gt;&lt;strong&gt;被父元素截胡&lt;/strong&gt;：子元素只有 72px 高，滚动后跟着父元素，从 150 移到 -250。&lt;/li&gt;&#xD;
&lt;/ul&gt;&#xD;
&lt;h3 id="1-"&gt;1. 会截胡的写法&lt;/h3&gt;&#xD;
&lt;p&gt;下面这些写法，子元素都只有 72px 高，滚动后顶边从 150 变成 -250，&lt;code&gt;offsetParent&lt;/code&gt; 都指向父元素：&lt;/p&gt;&#xD;
&lt;table&gt;&#xD;
&lt;thead&gt;&#xD;
&lt;tr&gt;&#xD;
&lt;th style="text-align:center"&gt;父元素上的写法&lt;/th&gt;&#xD;
&lt;th style="text-align:center"&gt;子元素高度&lt;/th&gt;&#xD;
&lt;th style="text-align:center"&gt;滚动 400px 后顶边&lt;/th&gt;&#xD;
&lt;/tr&gt;&#xD;
&lt;/thead&gt;&#xD;
&lt;tbody&gt;&#xD;
&lt;tr&gt;&#xD;
&lt;td style="text-align:center"&gt;&lt;code&gt;transform: translateZ(0)&lt;/code&gt;&lt;/td&gt;&#xD;
&lt;td style="text-align:center"&gt;72px&lt;/td&gt;&#xD;
&lt;td style="text-align:center"&gt;-250&lt;/td&gt;&#xD;
&lt;/tr&gt;&#xD;
&lt;tr&gt;&#xD;
&lt;td style="text-align:center"&gt;&lt;code&gt;translate: 0px&lt;/code&gt;&lt;/td&gt;&#xD;
&lt;td style="text-align:center"&gt;72px&lt;/td&gt;&#xD;
&lt;td style="text-align:center"&gt;-250&lt;/td&gt;&#xD;
&lt;/tr&gt;&#xD;
&lt;tr&gt;&#xD;
&lt;td style="text-align:center"&gt;&lt;code&gt;filter: blur(0)&lt;/code&gt;&lt;/td&gt;&#xD;
&lt;td style="text-align:center"&gt;72px&lt;/td&gt;&#xD;
&lt;td style="text-align:center"&gt;-250&lt;/td&gt;&#xD;
&lt;/tr&gt;&#xD;
&lt;tr&gt;&#xD;
&lt;td style="text-align:center"&gt;&lt;code&gt;backdrop-filter: blur(8px)&lt;/code&gt;&lt;/td&gt;&#xD;
&lt;td style="text-align:center"&gt;72px&lt;/td&gt;&#xD;
&lt;td style="text-align:center"&gt;-250&lt;/td&gt;&#xD;
&lt;/tr&gt;&#xD;
&lt;tr&gt;&#xD;
&lt;td style="text-align:center"&gt;&lt;code&gt;perspective: 1000px&lt;/code&gt;&lt;/td&gt;&#xD;
&lt;td style="text-align:center"&gt;72px&lt;/td&gt;&#xD;
&lt;td style="text-align:center"&gt;-250&lt;/td&gt;&#xD;
&lt;/tr&gt;&#xD;
&lt;tr&gt;&#xD;
&lt;td style="text-align:center"&gt;&lt;code&gt;will-change: transform&lt;/code&gt;&lt;/td&gt;&#xD;
&lt;td style="text-align:center"&gt;72px&lt;/td&gt;&#xD;
&lt;td style="text-align:center"&gt;-250&lt;/td&gt;&#xD;
&lt;/tr&gt;&#xD;
&lt;tr&gt;&#xD;
&lt;td style="text-align:center"&gt;&lt;code&gt;contain: paint&lt;/code&gt;&lt;/td&gt;&#xD;
&lt;td style="text-align:center"&gt;72px&lt;/td&gt;&#xD;
&lt;td style="text-align:center"&gt;-250&lt;/td&gt;&#xD;
&lt;/tr&gt;&#xD;
&lt;tr&gt;&#xD;
&lt;td style="text-align:center"&gt;&lt;code&gt;contain: layout&lt;/code&gt;&lt;/td&gt;&#xD;
&lt;td style="text-align:center"&gt;72px&lt;/td&gt;&#xD;
&lt;td style="text-align:center"&gt;-250&lt;/td&gt;&#xD;
&lt;/tr&gt;&#xD;
&lt;tr&gt;&#xD;
&lt;td style="text-align:center"&gt;&lt;code&gt;content-visibility: auto&lt;/code&gt;&lt;/td&gt;&#xD;
&lt;td style="text-align:center"&gt;72px&lt;/td&gt;&#xD;
&lt;td style="text-align:center"&gt;-250&lt;/td&gt;&#xD;
&lt;/tr&gt;&#xD;
&lt;/tbody&gt;&#xD;
&lt;/table&gt;&#xD;
&lt;p&gt;其中最容易踩到的是三个：&lt;/p&gt;&#xD;
&lt;ul&gt;&#xD;
&lt;li&gt;&lt;code&gt;backdrop-filter&lt;/code&gt;：毛玻璃顶栏几乎都会用到，顶栏里面又常常放着菜单、弹层；&lt;/li&gt;&#xD;
&lt;li&gt;&lt;code&gt;will-change: transform&lt;/code&gt; 和 &lt;code&gt;transform: translateZ(0)&lt;/code&gt;：常被当作“开启 GPU 加速”的写法随手加上，加在一个包着弹窗、提示条的容器上，里面的 fixed 元素就全部失效了；&lt;/li&gt;&#xD;
&lt;li&gt;&lt;code&gt;content-visibility: auto&lt;/code&gt;：用来优化长页面渲染，加在文章区块上时，区块里的 fixed 元素也会被截下来。&lt;/li&gt;&#xD;
&lt;/ul&gt;&#xD;
&lt;h3 id="2-"&gt;2. 不会截胡的写法&lt;/h3&gt;&#xD;
&lt;p&gt;下面这些写法，子元素都是 600px 高、滚动后顶边仍是 0，&lt;code&gt;offsetParent&lt;/code&gt; 都是 null。第二列另做了一个命中测试：父元素里放一个 &lt;code&gt;z-index: -1&lt;/code&gt; 的子元素，看它会不会沉到父元素背景下面，没沉下去就说明父元素建立了层叠上下文：&lt;/p&gt;&#xD;
&lt;table&gt;&#xD;
&lt;thead&gt;&#xD;
&lt;tr&gt;&#xD;
&lt;th style="text-align:center"&gt;父元素上的写法&lt;/th&gt;&#xD;
&lt;th style="text-align:center"&gt;建立层叠上下文&lt;/th&gt;&#xD;
&lt;th style="text-align:center"&gt;截胡 fixed 后代&lt;/th&gt;&#xD;
&lt;/tr&gt;&#xD;
&lt;/thead&gt;&#xD;
&lt;tbody&gt;&#xD;
&lt;tr&gt;&#xD;
&lt;td style="text-align:center"&gt;无（对照组）&lt;/td&gt;&#xD;
&lt;td style="text-align:center"&gt;否&lt;/td&gt;&#xD;
&lt;td style="text-align:center"&gt;否&lt;/td&gt;&#xD;
&lt;/tr&gt;&#xD;
&lt;tr&gt;&#xD;
&lt;td style="text-align:center"&gt;&lt;code&gt;opacity: .99&lt;/code&gt;&lt;/td&gt;&#xD;
&lt;td style="text-align:center"&gt;是&lt;/td&gt;&#xD;
&lt;td style="text-align:center"&gt;否&lt;/td&gt;&#xD;
&lt;/tr&gt;&#xD;
&lt;tr&gt;&#xD;
&lt;td style="text-align:center"&gt;&lt;code&gt;isolation: isolate&lt;/code&gt;&lt;/td&gt;&#xD;
&lt;td style="text-align:center"&gt;是&lt;/td&gt;&#xD;
&lt;td style="text-align:center"&gt;否&lt;/td&gt;&#xD;
&lt;/tr&gt;&#xD;
&lt;tr&gt;&#xD;
&lt;td style="text-align:center"&gt;&lt;code&gt;mix-blend-mode: multiply&lt;/code&gt;&lt;/td&gt;&#xD;
&lt;td style="text-align:center"&gt;是&lt;/td&gt;&#xD;
&lt;td style="text-align:center"&gt;否&lt;/td&gt;&#xD;
&lt;/tr&gt;&#xD;
&lt;tr&gt;&#xD;
&lt;td style="text-align:center"&gt;&lt;code&gt;position: relative; z-index: 1&lt;/code&gt;&lt;/td&gt;&#xD;
&lt;td style="text-align:center"&gt;是&lt;/td&gt;&#xD;
&lt;td style="text-align:center"&gt;否&lt;/td&gt;&#xD;
&lt;/tr&gt;&#xD;
&lt;tr&gt;&#xD;
&lt;td style="text-align:center"&gt;&lt;code&gt;will-change: opacity&lt;/code&gt;&lt;/td&gt;&#xD;
&lt;td style="text-align:center"&gt;是&lt;/td&gt;&#xD;
&lt;td style="text-align:center"&gt;否&lt;/td&gt;&#xD;
&lt;/tr&gt;&#xD;
&lt;tr&gt;&#xD;
&lt;td style="text-align:center"&gt;&lt;code&gt;container-type: inline-size&lt;/code&gt;&lt;/td&gt;&#xD;
&lt;td style="text-align:center"&gt;否&lt;/td&gt;&#xD;
&lt;td style="text-align:center"&gt;否&lt;/td&gt;&#xD;
&lt;/tr&gt;&#xD;
&lt;tr&gt;&#xD;
&lt;td style="text-align:center"&gt;父元素本身 &lt;code&gt;position: fixed&lt;/code&gt;&lt;/td&gt;&#xD;
&lt;td style="text-align:center"&gt;是&lt;/td&gt;&#xD;
&lt;td style="text-align:center"&gt;否&lt;/td&gt;&#xD;
&lt;/tr&gt;&#xD;
&lt;/tbody&gt;&#xD;
&lt;/table&gt;&#xD;
&lt;p&gt;这一张表要说明的是：&lt;strong&gt;建立层叠上下文和建立包含块是两回事。&lt;/strong&gt;&lt;/p&gt;&#xD;
&lt;p&gt;opacity、isolation、z-index 这些写法会改变元素的“绘制顺序”，影响的是谁盖住谁；它们不会改变 fixed 后代“相对谁定位”。排查 fixed 元素位置不对时，不用在这些属性上花时间。&lt;/p&gt;&#xD;
&lt;h2 id="-html-body-"&gt;四、“哀悼灰”放在 html 上没事，放在 body 上就会出问题&lt;/h2&gt;&#xD;
&lt;p&gt;需要全站置灰的时候，很多网站会加一行：&lt;/p&gt;&#xD;
&lt;pre&gt;&lt;code class="lang-css"&gt;&lt;span class="hljs-selector-tag"&gt;html&lt;/span&gt; { &lt;span class="hljs-attribute"&gt;filter&lt;/span&gt;: &lt;span class="hljs-built_in"&gt;grayscale&lt;/span&gt;(1); }&#xD;
&lt;/code&gt;&lt;/pre&gt;&#xD;
&lt;p&gt;Filter Effects 规范专门给根元素开了例外：filter 用在根元素上，不会让它成为 fixed 后代的包含块。实测也是这样，fixed 子元素仍然是 600px 高，滚动时不动。&lt;/p&gt;&#xD;
&lt;p&gt;但如果这一行写在 body 上：&lt;/p&gt;&#xD;
&lt;pre&gt;&lt;code class="lang-css"&gt;&lt;span class="hljs-selector-tag"&gt;body&lt;/span&gt; { &lt;span class="hljs-attribute"&gt;filter&lt;/span&gt;: &lt;span class="hljs-built_in"&gt;grayscale&lt;/span&gt;(1); }&#xD;
&lt;/code&gt;&lt;/pre&gt;&#xD;
&lt;p&gt;实测 fixed 子元素的 &lt;code&gt;offsetParent&lt;/code&gt; 变成了 body，高度变成了 3072px，也就是整个 body 的高度。&lt;/p&gt;&#xD;
&lt;p&gt;页面一滚动，顶栏、回到顶部按钮这些本该固定在屏幕上的元素，就会跟着正文一起滚走。&lt;/p&gt;&#xD;
&lt;table&gt;&#xD;
&lt;thead&gt;&#xD;
&lt;tr&gt;&#xD;
&lt;th style="text-align:center"&gt;写法&lt;/th&gt;&#xD;
&lt;th style="text-align:center"&gt;子元素高度&lt;/th&gt;&#xD;
&lt;th style="text-align:center"&gt;滚动 400px 后顶边&lt;/th&gt;&#xD;
&lt;th style="text-align:center"&gt;offsetParent&lt;/th&gt;&#xD;
&lt;/tr&gt;&#xD;
&lt;/thead&gt;&#xD;
&lt;tbody&gt;&#xD;
&lt;tr&gt;&#xD;
&lt;td style="text-align:center"&gt;&lt;code&gt;html { filter: grayscale(1) }&lt;/code&gt;&lt;/td&gt;&#xD;
&lt;td style="text-align:center"&gt;600px&lt;/td&gt;&#xD;
&lt;td style="text-align:center"&gt;0&lt;/td&gt;&#xD;
&lt;td style="text-align:center"&gt;null&lt;/td&gt;&#xD;
&lt;/tr&gt;&#xD;
&lt;tr&gt;&#xD;
&lt;td style="text-align:center"&gt;&lt;code&gt;body { filter: grayscale(1) }&lt;/code&gt;&lt;/td&gt;&#xD;
&lt;td style="text-align:center"&gt;3072px&lt;/td&gt;&#xD;
&lt;td style="text-align:center"&gt;-250&lt;/td&gt;&#xD;
&lt;td style="text-align:center"&gt;body&lt;/td&gt;&#xD;
&lt;/tr&gt;&#xD;
&lt;/tbody&gt;&#xD;
&lt;/table&gt;&#xD;
&lt;p&gt;置灰这类全局滤镜，只能加在 html 上。&lt;/p&gt;&#xD;
&lt;h2 id="-offsetparent-"&gt;五、排查：用 offsetParent 一行找到“截胡”的祖先&lt;/h2&gt;&#xD;
&lt;p&gt;CSSOM View 规范对 fixed 元素的 &lt;code&gt;offsetParent&lt;/code&gt; 有专门的定义：没有祖先为它建立包含块时返回 null，否则返回那个祖先。&lt;/p&gt;&#xD;
&lt;p&gt;前面每一个“会截胡”的场景，&lt;code&gt;offsetParent&lt;/code&gt; 都指向了父元素，body 置灰的场景指向 body；所有“不会截胡”的场景都是 null。&lt;/p&gt;&#xD;
&lt;p&gt;所以排查时，在开发者工具的 Elements 面板里选中出问题的 fixed 元素，然后在 Console 里输入：&lt;/p&gt;&#xD;
&lt;pre&gt;&lt;code class="lang-js"&gt;&lt;span class="hljs-variable"&gt;$0&lt;/span&gt;.offsetParent&#xD;
&lt;/code&gt;&lt;/pre&gt;&#xD;
&lt;p&gt;返回 null，说明它确实相对视口，问题出在别处，比如 z-index 或者被别的元素遮住；返回某个元素，那个元素就是“截胡”的祖先。&lt;/p&gt;&#xD;
&lt;p&gt;再进一步，可以把这个祖先身上几个可疑属性的非默认值列出来：&lt;/p&gt;&#xD;
&lt;pre&gt;&lt;code class="lang-js"&gt;&lt;span class="hljs-function"&gt;&lt;span class="hljs-keyword"&gt;function&lt;/span&gt; &lt;span class="hljs-title"&gt;fixedTrap&lt;/span&gt;(&lt;span class="hljs-params"&gt;el&lt;/span&gt;) &lt;/span&gt;{&#xD;
    &lt;span class="hljs-keyword"&gt;const&lt;/span&gt; cb = el.offsetParent;   &lt;span class="hljs-comment"&gt;// fixed 元素：相对视口时是 null，被截胡时就是截胡它的祖先&lt;/span&gt;&#xD;
    &lt;span class="hljs-keyword"&gt;if&lt;/span&gt; (!cb) &lt;span class="hljs-keyword"&gt;return&lt;/span&gt; &lt;span class="hljs-string"&gt;'相对视口，没有被截胡'&lt;/span&gt;;&#xD;
    &lt;span class="hljs-keyword"&gt;const&lt;/span&gt; cs = getComputedStyle(cb);&#xD;
    &lt;span class="hljs-keyword"&gt;const&lt;/span&gt; defaults = {&#xD;
        &lt;span class="hljs-attr"&gt;transform&lt;/span&gt;: &lt;span class="hljs-string"&gt;'none'&lt;/span&gt;, &lt;span class="hljs-attr"&gt;translate&lt;/span&gt;: &lt;span class="hljs-string"&gt;'none'&lt;/span&gt;, &lt;span class="hljs-attr"&gt;rotate&lt;/span&gt;: &lt;span class="hljs-string"&gt;'none'&lt;/span&gt;, &lt;span class="hljs-attr"&gt;scale&lt;/span&gt;: &lt;span class="hljs-string"&gt;'none'&lt;/span&gt;, &lt;span class="hljs-attr"&gt;perspective&lt;/span&gt;: &lt;span class="hljs-string"&gt;'none'&lt;/span&gt;,&#xD;
        &lt;span class="hljs-attr"&gt;filter&lt;/span&gt;: &lt;span class="hljs-string"&gt;'none'&lt;/span&gt;, &lt;span class="hljs-attr"&gt;backdropFilter&lt;/span&gt;: &lt;span class="hljs-string"&gt;'none'&lt;/span&gt;, &lt;span class="hljs-attr"&gt;willChange&lt;/span&gt;: &lt;span class="hljs-string"&gt;'auto'&lt;/span&gt;, &lt;span class="hljs-attr"&gt;contain&lt;/span&gt;: &lt;span class="hljs-string"&gt;'none'&lt;/span&gt;, &lt;span class="hljs-attr"&gt;contentVisibility&lt;/span&gt;: &lt;span class="hljs-string"&gt;'visible'&lt;/span&gt;,&#xD;
    };&#xD;
    &lt;span class="hljs-keyword"&gt;const&lt;/span&gt; suspects = {};&#xD;
    &lt;span class="hljs-keyword"&gt;for&lt;/span&gt; (&lt;span class="hljs-keyword"&gt;const&lt;/span&gt; [prop, initial] &lt;span class="hljs-keyword"&gt;of&lt;/span&gt; &lt;span class="hljs-built_in"&gt;Object&lt;/span&gt;.entries(defaults)) {&#xD;
        &lt;span class="hljs-keyword"&gt;if&lt;/span&gt; (cs[prop] &amp;amp;&amp;amp; cs[prop] !== initial) suspects[prop] = cs[prop];&#xD;
    }&#xD;
    &lt;span class="hljs-keyword"&gt;return&lt;/span&gt; { &lt;span class="hljs-attr"&gt;captor&lt;/span&gt;: cb, suspects };&#xD;
}&#xD;
&#xD;
fixedTrap($&lt;span class="hljs-number"&gt;0&lt;/span&gt;);&#xD;
&lt;/code&gt;&lt;/pre&gt;&#xD;
&lt;p&gt;在前面的测试页里跑这段代码，输出分别是：&lt;/p&gt;&#xD;
&lt;table&gt;&#xD;
&lt;thead&gt;&#xD;
&lt;tr&gt;&#xD;
&lt;th style="text-align:center"&gt;场景&lt;/th&gt;&#xD;
&lt;th style="text-align:center"&gt;suspects 的输出&lt;/th&gt;&#xD;
&lt;/tr&gt;&#xD;
&lt;/thead&gt;&#xD;
&lt;tbody&gt;&#xD;
&lt;tr&gt;&#xD;
&lt;td style="text-align:center"&gt;backdrop-filter 截胡&lt;/td&gt;&#xD;
&lt;td style="text-align:center"&gt;&lt;code&gt;{ backdropFilter: &amp;#39;blur(8px)&amp;#39; }&lt;/code&gt;&lt;/td&gt;&#xD;
&lt;/tr&gt;&#xD;
&lt;tr&gt;&#xD;
&lt;td style="text-align:center"&gt;translate: 0px 截胡&lt;/td&gt;&#xD;
&lt;td style="text-align:center"&gt;&lt;code&gt;{ translate: &amp;#39;0px&amp;#39; }&lt;/code&gt;&lt;/td&gt;&#xD;
&lt;/tr&gt;&#xD;
&lt;tr&gt;&#xD;
&lt;td style="text-align:center"&gt;content-visibility 截胡&lt;/td&gt;&#xD;
&lt;td style="text-align:center"&gt;&lt;code&gt;{ contentVisibility: &amp;#39;auto&amp;#39; }&lt;/code&gt;&lt;/td&gt;&#xD;
&lt;/tr&gt;&#xD;
&lt;tr&gt;&#xD;
&lt;td style="text-align:center"&gt;body 置灰截胡&lt;/td&gt;&#xD;
&lt;td style="text-align:center"&gt;&lt;code&gt;{ filter: &amp;#39;grayscale(1)&amp;#39; }&lt;/code&gt;&lt;/td&gt;&#xD;
&lt;/tr&gt;&#xD;
&lt;tr&gt;&#xD;
&lt;td style="text-align:center"&gt;will-change: opacity&lt;/td&gt;&#xD;
&lt;td style="text-align:center"&gt;相对视口，没有被截胡&lt;/td&gt;&#xD;
&lt;/tr&gt;&#xD;
&lt;/tbody&gt;&#xD;
&lt;/table&gt;&#xD;
&lt;h2 id="-"&gt;六、三种修法&lt;/h2&gt;&#xD;
&lt;h3 id="1-fixed-"&gt;1. 把 fixed 元素挪到截胡者外面&lt;/h3&gt;&#xD;
&lt;p&gt;这次的抽屉就是这么修的：抽屉不再写在顶栏里，而是作为顶栏的兄弟节点放在它后面。&lt;/p&gt;&#xD;
&lt;pre&gt;&lt;code class="lang-html"&gt;&amp;lt;header &lt;span class="hljs-keyword"&gt;class&lt;/span&gt;=&lt;span class="hljs-string"&gt;"ff-nav"&lt;/span&gt;&amp;gt;…&amp;lt;/header&amp;gt;&#xD;
&amp;lt;aside &lt;span class="hljs-keyword"&gt;class&lt;/span&gt;=&lt;span class="hljs-string"&gt;"ff-drawer"&lt;/span&gt; id=&lt;span class="hljs-string"&gt;"ff-menu-panel"&lt;/span&gt;&amp;gt;…&amp;lt;/aside&amp;gt;&#xD;
&lt;/code&gt;&lt;/pre&gt;&#xD;
&lt;p&gt;挪出来之后，抽屉在 390 × 844 的视口里是 273 × 844，&lt;code&gt;offsetParent&lt;/code&gt; 是 null，顶栏收起时它也不会被带走。&lt;/p&gt;&#xD;
&lt;p&gt;挪出来之后还有两件事要一起处理：&lt;/p&gt;&#xD;
&lt;ul&gt;&#xD;
&lt;li&gt;&lt;strong&gt;层级&lt;/strong&gt;：抽屉和顶栏不在同一个层叠上下文里了，要重新排 z-index。现在是顶栏 1000、遮罩 1090、抽屉 1100，遮罩能盖住顶栏，抽屉又在遮罩上面；&lt;/li&gt;&#xD;
&lt;li&gt;&lt;strong&gt;焦点&lt;/strong&gt;：原来“焦点离开顶栏就收起菜单”的逻辑要改成“焦点离开顶栏和抽屉才收起”，否则用键盘从菜单按钮 Tab 进抽屉的那一刻，菜单就被关掉了。&lt;/li&gt;&#xD;
&lt;/ul&gt;&#xD;
&lt;p&gt;在组件化框架里，这个思路就是 Portal：React 的 &lt;code&gt;createPortal&lt;/code&gt;、Vue 的 &lt;code&gt;Teleport&lt;/code&gt;，都可以把弹层渲染到指定的节点（通常是 body）下面，避开中间祖先上的这些属性。&lt;/p&gt;&#xD;
&lt;h3 id="2-"&gt;2. 把截胡的属性挪到伪元素上&lt;/h3&gt;&#xD;
&lt;p&gt;如果只是 backdrop-filter 这类“视觉效果”的属性在截胡，也可以不动 DOM，把效果挪到伪元素上：&lt;/p&gt;&#xD;
&lt;pre&gt;&lt;code class="lang-css"&gt;&lt;span class="hljs-selector-class"&gt;.ff-nav&lt;/span&gt; {&#xD;
    &lt;span class="hljs-attribute"&gt;position&lt;/span&gt;: fixed;   &lt;span class="hljs-comment"&gt;/* 顶栏本身不再加 backdrop-filter */&lt;/span&gt;&#xD;
}&#xD;
&lt;span class="hljs-selector-class"&gt;.ff-nav&lt;/span&gt;&lt;span class="hljs-selector-pseudo"&gt;::before&lt;/span&gt; {&#xD;
    &lt;span class="hljs-attribute"&gt;content&lt;/span&gt;: &lt;span class="hljs-string"&gt;""&lt;/span&gt;;&#xD;
    &lt;span class="hljs-attribute"&gt;position&lt;/span&gt;: absolute; &lt;span class="hljs-attribute"&gt;inset&lt;/span&gt;: &lt;span class="hljs-number"&gt;0&lt;/span&gt;; &lt;span class="hljs-attribute"&gt;z-index&lt;/span&gt;: -&lt;span class="hljs-number"&gt;1&lt;/span&gt;;&#xD;
    &lt;span class="hljs-attribute"&gt;backdrop-filter&lt;/span&gt;: &lt;span class="hljs-built_in"&gt;blur&lt;/span&gt;(20px) &lt;span class="hljs-built_in"&gt;saturate&lt;/span&gt;(1.6);&#xD;
    &lt;span class="hljs-attribute"&gt;background&lt;/span&gt;: &lt;span class="hljs-built_in"&gt;rgba&lt;/span&gt;(255, 255, 255, .6);&#xD;
}&#xD;
&lt;/code&gt;&lt;/pre&gt;&#xD;
&lt;p&gt;伪元素是顶栏的子节点，不是抽屉的祖先，它的 backdrop-filter 管不到抽屉。实测这种写法下，fixed 子元素恢复为 600px 高、滚动时不动。&lt;/p&gt;&#xD;
&lt;p&gt;顶栏本身是 &lt;code&gt;position: fixed&lt;/code&gt;，会建立层叠上下文，所以 &lt;code&gt;z-index: -1&lt;/code&gt; 的伪元素只会沉到顶栏内容的下面，不会掉到页面后面去。&lt;/p&gt;&#xD;
&lt;p&gt;但这个办法有局限。流萤的顶栏还用 &lt;code&gt;transform: translateY(-100%)&lt;/code&gt; 来做“往下滚时收起”，transform 同样会截胡，抽屉放在顶栏里仍然会被带走。所以这次最终用的是第一种修法。&lt;/p&gt;&#xD;
&lt;h3 id="3-will-change-transform"&gt;3. 别随手加 will-change: transform&lt;/h3&gt;&#xD;
&lt;p&gt;&lt;code&gt;will-change: transform&lt;/code&gt; 和 &lt;code&gt;transform: translateZ(0)&lt;/code&gt; 常被当作性能优化随手加上，但实测它们都会截胡 fixed 后代。&lt;/p&gt;&#xD;
&lt;p&gt;只在确实需要的元素上加，加之前看一眼它里面有没有弹窗、提示条、抽屉这类 fixed 元素。&lt;/p&gt;&#xD;
&lt;h2 id="-fixed-"&gt;七、总结：fixed 是相对视口，前提是没有祖先截胡&lt;/h2&gt;&#xD;
&lt;p&gt;&lt;code&gt;position: fixed&lt;/code&gt; 的元素位置不对、尺寸不对、跟着页面滚动，往往不是它自己的样式写错了，而是某个祖先建立了包含块。&lt;/p&gt;&#xD;
&lt;table&gt;&#xD;
&lt;thead&gt;&#xD;
&lt;tr&gt;&#xD;
&lt;th style="text-align:center"&gt;要点&lt;/th&gt;&#xD;
&lt;th style="text-align:center"&gt;结论&lt;/th&gt;&#xD;
&lt;/tr&gt;&#xD;
&lt;/thead&gt;&#xD;
&lt;tbody&gt;&#xD;
&lt;tr&gt;&#xD;
&lt;td style="text-align:center"&gt;会截胡的属性&lt;/td&gt;&#xD;
&lt;td style="text-align:center"&gt;transform 系列、perspective、filter、backdrop-filter、will-change: transform、contain、content-visibility&lt;/td&gt;&#xD;
&lt;/tr&gt;&#xD;
&lt;tr&gt;&#xD;
&lt;td style="text-align:center"&gt;判断标准&lt;/td&gt;&#xD;
&lt;td style="text-align:center"&gt;值不是 none 就算，blur(0)、translate: 0px 也算&lt;/td&gt;&#xD;
&lt;/tr&gt;&#xD;
&lt;tr&gt;&#xD;
&lt;td style="text-align:center"&gt;不会截胡的属性&lt;/td&gt;&#xD;
&lt;td style="text-align:center"&gt;opacity、isolation、mix-blend-mode、z-index、container-type，它们只影响层叠&lt;/td&gt;&#xD;
&lt;/tr&gt;&#xD;
&lt;tr&gt;&#xD;
&lt;td style="text-align:center"&gt;全局滤镜&lt;/td&gt;&#xD;
&lt;td style="text-align:center"&gt;加在 html 上安全，加在 body 上会让 fixed 元素失效&lt;/td&gt;&#xD;
&lt;/tr&gt;&#xD;
&lt;tr&gt;&#xD;
&lt;td style="text-align:center"&gt;排查方法&lt;/td&gt;&#xD;
&lt;td style="text-align:center"&gt;fixed 元素的 offsetParent 不为 null，指向的就是截胡者&lt;/td&gt;&#xD;
&lt;/tr&gt;&#xD;
&lt;tr&gt;&#xD;
&lt;td style="text-align:center"&gt;修复方法&lt;/td&gt;&#xD;
&lt;td style="text-align:center"&gt;把 fixed 元素挪出去（Portal），或者把截胡属性挪到伪元素上&lt;/td&gt;&#xD;
&lt;/tr&gt;&#xD;
&lt;/tbody&gt;&#xD;
&lt;/table&gt;&#xD;
&lt;p&gt;&lt;strong&gt;fixed 元素相对视口，前提是一路往上没有祖先截胡；位置不对时，先看它的 offsetParent。&lt;/strong&gt;&lt;/p&gt;</description>
      <pubDate>Thu, 01 Oct 2026 06:40:51 GMT</pubDate>
    </item>
    <item>
      <title>博客六套主题的实现与踩坑</title>
      <link>https://www.hqxiaozou.top/post/A1bAfiVencB</link>
      <description>&lt;p&gt;博客原来只有一套样式，现在叫它“云笺”：蓝紫渐变、玻璃卡片、顶部导航加右侧小组件。九月底的六天里，我给它加了五套主题——纸墨、画报、格致、星河、流萤。&lt;/p&gt;&#xD;
&lt;p&gt;六套主题读的是同一个数据库，跑的是同一个后端，用的是同一批交互脚本，但页面结构完全不同：有单栏阅读的编辑风，有左侧固定侧栏的杂志风，有便当格拼图，也有通栏大图加侧栏的博客风。后台“外观 → 主题”里点一下就能整站切换，现在站点用的是流萤。&lt;/p&gt;&#xD;
&lt;p&gt;主题本身画出来不难，难的是“改了一处，别处跟着变”。本文按时间顺序，记录方案是怎么定下来的，以及一路踩到的坑。&lt;/p&gt;&#xD;
&lt;h2 id="-1528-"&gt;一、第一版：一张 1528 行的覆盖样式表&lt;/h2&gt;&#xD;
&lt;p&gt;最早的想法是最省事的：模板只有一套，主题就是叠在原样式上面的一层覆盖样式。云笺不加载任何覆盖，纸墨多加载一张 &lt;code&gt;skin-paper.css&lt;/code&gt;。&lt;/p&gt;&#xD;
&lt;p&gt;好处很明显：所有交互脚本、所有契约测试都不用动，两套主题只在观感上分岔。这一版在 9 月 25 日晚上上线，覆盖样式写了 1528 行，后台另有 372 行。&lt;/p&gt;&#xD;
&lt;p&gt;上线后自己看了一圈，结论是失败的。颜色和字体都换了，但页面还是原来那个页面：导航在同一个位置，卡片是同样的网格，侧栏里是同样的小组件。&lt;strong&gt;换肤只能换“皮”，换不了“骨架”。&lt;/strong&gt;&lt;/p&gt;&#xD;
&lt;p&gt;更麻烦的是，原样式里有大量高优先级的规则：字体用 &lt;code&gt;html body … !important&lt;/code&gt; 钉在标题、按钮和输入框上，评论区沿用 Semantic UI 的选择器。覆盖样式要一条条去跟它们比优先级，写得越多越难维护。&lt;/p&gt;&#xD;
&lt;p&gt;第二天一早推倒重来，这次提交改了 64 个文件，新增 13341 行。&lt;/p&gt;&#xD;
&lt;h2 id="-"&gt;二、第二版：骨架归主题，组件归公共&lt;/h2&gt;&#xD;
&lt;p&gt;新方案把“页面骨架”和“功能组件”拆开。&lt;/p&gt;&#xD;
&lt;p&gt;&lt;strong&gt;骨架归主题&lt;/strong&gt;：每套主题在 &lt;code&gt;templates/themes/&amp;lt;主题&amp;gt;/&lt;/code&gt; 下有自己的 8 个页面模板（页头、页脚、首页、文章、留言板、归档、分类、独立页），在 &lt;code&gt;static/css/themes/&amp;lt;主题&amp;gt;/&lt;/code&gt; 下有自己的前台和后台样式，再加一个主题脚本。&lt;/p&gt;&#xD;
&lt;p&gt;&lt;strong&gt;组件归公共&lt;/strong&gt;：评论区、表情与表情包、AI 文章助手、分享海报、目录、弹幕墙、视频卡片、天气、每日一句……这些带交互的东西，抽成 &lt;code&gt;templates/components/&lt;/code&gt; 下的 6 个共享片段文件，所有主题引用同一份。&lt;/p&gt;&#xD;
&lt;table&gt;&#xD;
&lt;thead&gt;&#xD;
&lt;tr&gt;&#xD;
&lt;th style="text-align:center"&gt;主题&lt;/th&gt;&#xD;
&lt;th style="text-align:center"&gt;风格&lt;/th&gt;&#xD;
&lt;th style="text-align:center"&gt;8 个页面模板&lt;/th&gt;&#xD;
&lt;th style="text-align:center"&gt;前台样式&lt;/th&gt;&#xD;
&lt;th style="text-align:center"&gt;主题脚本&lt;/th&gt;&#xD;
&lt;/tr&gt;&#xD;
&lt;/thead&gt;&#xD;
&lt;tbody&gt;&#xD;
&lt;tr&gt;&#xD;
&lt;td style="text-align:center"&gt;云笺&lt;/td&gt;&#xD;
&lt;td style="text-align:center"&gt;原样式&lt;/td&gt;&#xD;
&lt;td style="text-align:center"&gt;2252 行&lt;/td&gt;&#xD;
&lt;td style="text-align:center"&gt;原样式&lt;/td&gt;&#xD;
&lt;td style="text-align:center"&gt;原脚本&lt;/td&gt;&#xD;
&lt;/tr&gt;&#xD;
&lt;tr&gt;&#xD;
&lt;td style="text-align:center"&gt;纸墨&lt;/td&gt;&#xD;
&lt;td style="text-align:center"&gt;单栏编辑风&lt;/td&gt;&#xD;
&lt;td style="text-align:center"&gt;402 行&lt;/td&gt;&#xD;
&lt;td style="text-align:center"&gt;1044 行&lt;/td&gt;&#xD;
&lt;td style="text-align:center"&gt;87 行&lt;/td&gt;&#xD;
&lt;/tr&gt;&#xD;
&lt;tr&gt;&#xD;
&lt;td style="text-align:center"&gt;画报&lt;/td&gt;&#xD;
&lt;td style="text-align:center"&gt;左侧固定侧栏&lt;/td&gt;&#xD;
&lt;td style="text-align:center"&gt;392 行&lt;/td&gt;&#xD;
&lt;td style="text-align:center"&gt;952 行&lt;/td&gt;&#xD;
&lt;td style="text-align:center"&gt;130 行&lt;/td&gt;&#xD;
&lt;/tr&gt;&#xD;
&lt;tr&gt;&#xD;
&lt;td style="text-align:center"&gt;格致&lt;/td&gt;&#xD;
&lt;td style="text-align:center"&gt;便当格拼图&lt;/td&gt;&#xD;
&lt;td style="text-align:center"&gt;398 行&lt;/td&gt;&#xD;
&lt;td style="text-align:center"&gt;938 行&lt;/td&gt;&#xD;
&lt;td style="text-align:center"&gt;52 行&lt;/td&gt;&#xD;
&lt;/tr&gt;&#xD;
&lt;tr&gt;&#xD;
&lt;td style="text-align:center"&gt;星河&lt;/td&gt;&#xD;
&lt;td style="text-align:center"&gt;星空首屏&lt;/td&gt;&#xD;
&lt;td style="text-align:center"&gt;377 行&lt;/td&gt;&#xD;
&lt;td style="text-align:center"&gt;1099 行&lt;/td&gt;&#xD;
&lt;td style="text-align:center"&gt;162 行&lt;/td&gt;&#xD;
&lt;/tr&gt;&#xD;
&lt;tr&gt;&#xD;
&lt;td style="text-align:center"&gt;流萤&lt;/td&gt;&#xD;
&lt;td style="text-align:center"&gt;通栏大图两栏&lt;/td&gt;&#xD;
&lt;td style="text-align:center"&gt;465 行&lt;/td&gt;&#xD;
&lt;td style="text-align:center"&gt;1273 行&lt;/td&gt;&#xD;
&lt;td style="text-align:center"&gt;209 行&lt;/td&gt;&#xD;
&lt;/tr&gt;&#xD;
&lt;/tbody&gt;&#xD;
&lt;/table&gt;&#xD;
&lt;p&gt;新主题 8 个页面加起来只有 380 到 470 行，是云笺同样 8 个页面的五分之一左右，因为评论区这类又长又复杂的结构都挪进了共享片段（6 个文件共 881 行）。组件的底座样式从云笺里抽出来，单独放在 &lt;code&gt;themes/shared/components.css&lt;/code&gt;。星河参考了 ThriveX 的首屏和文章卡片，流萤参考了 Astro 主题 Firefly，内容都换成了本站真实的数据。&lt;/p&gt;&#xD;
&lt;p&gt;一次请求用哪套主题、哪个模板，是这样决定的：&lt;/p&gt;&#xD;
&lt;pre&gt;&lt;code class="lang-mermaid"&gt;&lt;span class="hljs-attribute"&gt;graph&lt;/span&gt; LR&#xD;
    A[&lt;span class="hljs-string"&gt;"请求"&lt;/span&gt;] --&amp;gt; B[&lt;span class="hljs-string"&gt;"解析主题"&lt;/span&gt;]&#xD;
    B --&amp;gt; C[&lt;span class="hljs-string"&gt;"找模板"&lt;/span&gt;]&#xD;
    C --&amp;gt; D[&lt;span class="hljs-string"&gt;"主题自己的模板"&lt;/span&gt;]&#xD;
    C -. &lt;span class="hljs-string"&gt;"没有这个页面"&lt;/span&gt; .-&amp;gt; E[&lt;span class="hljs-string"&gt;"云笺的模板"&lt;/span&gt;]&#xD;
&lt;/code&gt;&lt;/pre&gt;&#xD;
&lt;p&gt;解析主题的顺序是：URL 参数优先，其次是预览 Cookie，最后才是后台设置。&lt;/p&gt;&#xD;
&lt;p&gt;对应的代码很短（节选）：&lt;/p&gt;&#xD;
&lt;pre&gt;&lt;code class="lang-java"&gt;public &lt;span class="hljs-keyword"&gt;static&lt;/span&gt; &lt;span class="hljs-built_in"&gt;String&lt;/span&gt; resolve(HttpServletRequest request, HttpServletResponse response) {&#xD;
    &lt;span class="hljs-built_in"&gt;String&lt;/span&gt; requested = request.getParameter(&lt;span class="hljs-string"&gt;"theme"&lt;/span&gt;);&#xD;
    &lt;span class="hljs-keyword"&gt;if&lt;/span&gt; (requested != &lt;span class="hljs-literal"&gt;null&lt;/span&gt;) {&#xD;
        &lt;span class="hljs-keyword"&gt;if&lt;/span&gt; (isValid(requested)) {&#xD;
            writePreviewCookie(response, requested, &lt;span class="hljs-number"&gt;-1&lt;/span&gt;);   &lt;span class="hljs-comment"&gt;// 会话级，只影响当前浏览器&lt;/span&gt;&#xD;
            &lt;span class="hljs-keyword"&gt;return&lt;/span&gt; requested;&#xD;
        }&#xD;
        &lt;span class="hljs-keyword"&gt;if&lt;/span&gt; (&lt;span class="hljs-string"&gt;"off"&lt;/span&gt;.equals(requested) || !StringUtils.hasText(requested)) {&#xD;
            writePreviewCookie(response, &lt;span class="hljs-string"&gt;""&lt;/span&gt;, &lt;span class="hljs-number"&gt;0&lt;/span&gt;);           &lt;span class="hljs-comment"&gt;// ?theme=off 清掉预览&lt;/span&gt;&#xD;
            &lt;span class="hljs-keyword"&gt;return&lt;/span&gt; configured();&#xD;
        }&#xD;
    }&#xD;
    &lt;span class="hljs-comment"&gt;// 其次看预览 Cookie，最后才是后台设置&lt;/span&gt;&#xD;
    ...&#xD;
}&#xD;
&#xD;
public &lt;span class="hljs-keyword"&gt;static&lt;/span&gt; &lt;span class="hljs-built_in"&gt;String&lt;/span&gt; templateFor(&lt;span class="hljs-built_in"&gt;String&lt;/span&gt; theme, &lt;span class="hljs-built_in"&gt;String&lt;/span&gt; pageName) {&#xD;
    &lt;span class="hljs-built_in"&gt;String&lt;/span&gt; id = isValid(theme) ? theme : DEFAULT;&#xD;
    &lt;span class="hljs-keyword"&gt;if&lt;/span&gt; (!PINGHSU.equals(id)) {&#xD;
        &lt;span class="hljs-built_in"&gt;String&lt;/span&gt; candidate = &lt;span class="hljs-string"&gt;"themes/"&lt;/span&gt; + id + &lt;span class="hljs-string"&gt;"/"&lt;/span&gt; + pageName;&#xD;
        &lt;span class="hljs-keyword"&gt;if&lt;/span&gt; (TEMPLATE_PRESENT.computeIfAbsent(candidate, &lt;span class="hljs-attr"&gt;BlogTheme&lt;/span&gt;::templateExists)) {&#xD;
            &lt;span class="hljs-keyword"&gt;return&lt;/span&gt; candidate;&#xD;
        }&#xD;
    }&#xD;
    &lt;span class="hljs-keyword"&gt;return&lt;/span&gt; &lt;span class="hljs-string"&gt;"themes/"&lt;/span&gt; + PINGHSU + &lt;span class="hljs-string"&gt;"/"&lt;/span&gt; + pageName;&#xD;
}&#xD;
&lt;/code&gt;&lt;/pre&gt;&#xD;
&lt;p&gt;这里有两个设计点：&lt;/p&gt;&#xD;
&lt;p&gt;&lt;strong&gt;预览不改站点设置。&lt;/strong&gt;任意页面带上 &lt;code&gt;?theme=galaxy&lt;/code&gt;，只会写一个会话级的 HttpOnly Cookie，之后在这个浏览器里一直按星河渲染，别人看到的仍然是后台设置的主题。上线新主题前，我都是先这样在线上预览。&lt;/p&gt;&#xD;
&lt;p&gt;&lt;strong&gt;没做的页面自动回退。&lt;/strong&gt;烟花、纪念日这类独立页面，新主题都没有做，&lt;code&gt;templateFor&lt;/code&gt; 查一次 classpath 就缓存结果，找不到就回退到云笺的模板。&lt;/p&gt;&#xD;
&lt;p&gt;&lt;strong&gt;局部刷新也按主题渲染。&lt;/strong&gt;天气卡片首屏先放占位，页面加载后再请求 &lt;code&gt;/weather/fragment&lt;/code&gt; 换成真实天气。流萤的天气卡长得和别的主题不一样，这个接口同样走 &lt;code&gt;templateFor&lt;/code&gt;：流萤有自己的 &lt;code&gt;weather.html&lt;/code&gt;，其余主题回退到云笺那份。接口允许浏览器缓存 10 分钟，所以请求地址要带上当前主题（&lt;code&gt;?t=firefly&lt;/code&gt;），不然预览时切了主题，拿到的还是上一套主题渲染的片段。&lt;/p&gt;&#xD;
&lt;h2 id="-dom-"&gt;三、DOM 契约：脚本一行不改的前提&lt;/h2&gt;&#xD;
&lt;p&gt;组件能共用，是因为脚本只认 id 和 class。提交评论的脚本找 &lt;code&gt;#comment-btn&lt;/code&gt;、&lt;code&gt;#comment-container&lt;/code&gt;；展开回复的脚本找 &lt;code&gt;.show-replies&lt;/code&gt;、&lt;code&gt;.replies-container&lt;/code&gt; 和 &lt;code&gt;data-commentid&lt;/code&gt;；表情面板找 &lt;code&gt;#emoji-button&lt;/code&gt;、&lt;code&gt;#emoji-panel&lt;/code&gt;。共享片段的开头把这些依赖逐条列了出来，并注明“只能加类，不能改 id”。&lt;/p&gt;&#xD;
&lt;p&gt;契约靠测试守着，主题相关的契约测试检查四件事：&lt;/p&gt;&#xD;
&lt;table&gt;&#xD;
&lt;thead&gt;&#xD;
&lt;tr&gt;&#xD;
&lt;th style="text-align:center"&gt;检查项&lt;/th&gt;&#xD;
&lt;th style="text-align:center"&gt;防止的问题&lt;/th&gt;&#xD;
&lt;/tr&gt;&#xD;
&lt;/thead&gt;&#xD;
&lt;tbody&gt;&#xD;
&lt;tr&gt;&#xD;
&lt;td style="text-align:center"&gt;页面与样式齐全&lt;/td&gt;&#xD;
&lt;td style="text-align:center"&gt;漏页面时悄悄回退&lt;/td&gt;&#xD;
&lt;/tr&gt;&#xD;
&lt;tr&gt;&#xD;
&lt;td style="text-align:center"&gt;片段引用可解析&lt;/td&gt;&#xD;
&lt;td style="text-align:center"&gt;改名后引用断掉&lt;/td&gt;&#xD;
&lt;/tr&gt;&#xD;
&lt;tr&gt;&#xD;
&lt;td style="text-align:center"&gt;脚本钩子都在&lt;/td&gt;&#xD;
&lt;td style="text-align:center"&gt;误删 id 或 class&lt;/td&gt;&#xD;
&lt;/tr&gt;&#xD;
&lt;tr&gt;&#xD;
&lt;td style="text-align:center"&gt;后台列出全部主题&lt;/td&gt;&#xD;
&lt;td style="text-align:center"&gt;上线了却选不到&lt;/td&gt;&#xD;
&lt;/tr&gt;&#xD;
&lt;/tbody&gt;&#xD;
&lt;/table&gt;&#xD;
&lt;p&gt;但测试只能守住写下来的东西。星河和流萤的文章标题没有套 &lt;code&gt;&amp;lt;a&amp;gt;&lt;/code&gt; 链接，而分享海报脚本取标题用的是 &lt;code&gt;.post-title a&lt;/code&gt;，取不到就退回占位文字。结果这两套主题生成的海报，标题一直是“文章标题”四个字，页面上没有任何报错。&lt;/p&gt;&#xD;
&lt;p&gt;这类&lt;strong&gt;隐式的结构假设&lt;/strong&gt;不在契约清单里，只能靠把每个功能实际点一遍。修法是让脚本先找链接，找不到再取标题本身：&lt;/p&gt;&#xD;
&lt;pre&gt;&lt;code class="lang-javascript"&gt;&lt;span class="hljs-keyword"&gt;var&lt;/span&gt; articleTitle = &lt;span class="hljs-built_in"&gt;document&lt;/span&gt;.querySelector(&lt;span class="hljs-string"&gt;'.post-title a'&lt;/span&gt;) || &lt;span class="hljs-built_in"&gt;document&lt;/span&gt;.querySelector(&lt;span class="hljs-string"&gt;'.post-title'&lt;/span&gt;);&#xD;
&lt;/code&gt;&lt;/pre&gt;&#xD;
&lt;h2 id="-thymeleaf-"&gt;四、Thymeleaf 片段选择器的一个坑&lt;/h2&gt;&#xD;
&lt;p&gt;做流萤时，窄屏顶栏里多出一行淡淡的文字，看着像虚影。查下来，是分类条被复制进了顶栏。&lt;/p&gt;&#xD;
&lt;p&gt;页头文件里定义了好几个片段。导航片段叫 &lt;code&gt;nav&lt;/code&gt;，分类条是另一个片段，它的根元素恰好也写成了 &lt;code&gt;&amp;lt;nav&amp;gt;&lt;/code&gt;（简化后）：&lt;/p&gt;&#xD;
&lt;pre&gt;&lt;code class="lang-html"&gt;&lt;span class="hljs-tag"&gt;&amp;lt;&lt;span class="hljs-name"&gt;th:block&lt;/span&gt; &lt;span class="hljs-attr"&gt;th:fragment&lt;/span&gt;=&lt;span class="hljs-string"&gt;"nav"&lt;/span&gt;&amp;gt;&lt;/span&gt;&#xD;
    … 顶栏 …&#xD;
&lt;span class="hljs-tag"&gt;&amp;lt;/&lt;span class="hljs-name"&gt;th:block&lt;/span&gt;&amp;gt;&lt;/span&gt;&#xD;
&#xD;
&lt;span class="hljs-tag"&gt;&amp;lt;&lt;span class="hljs-name"&gt;th:block&lt;/span&gt; &lt;span class="hljs-attr"&gt;th:fragment&lt;/span&gt;=&lt;span class="hljs-string"&gt;"catBar"&lt;/span&gt;&amp;gt;&lt;/span&gt;&#xD;
    &lt;span class="hljs-tag"&gt;&amp;lt;&lt;span class="hljs-name"&gt;nav&lt;/span&gt; &lt;span class="hljs-attr"&gt;class&lt;/span&gt;=&lt;span class="hljs-string"&gt;"ff-catbar"&lt;/span&gt;&amp;gt;&lt;/span&gt; … 分类条 … &lt;span class="hljs-tag"&gt;&amp;lt;/&lt;span class="hljs-name"&gt;nav&lt;/span&gt;&amp;gt;&lt;/span&gt;&#xD;
&lt;span class="hljs-tag"&gt;&amp;lt;/&lt;span class="hljs-name"&gt;th:block&lt;/span&gt;&amp;gt;&lt;/span&gt;&#xD;
&lt;/code&gt;&lt;/pre&gt;&#xD;
&lt;p&gt;每个页面用 &lt;code&gt;~{themes/firefly/header :: nav}&lt;/code&gt; 引入顶栏。问题在于，Thymeleaf 的片段选择器本质上是&lt;strong&gt;标记选择器&lt;/strong&gt;：&lt;code&gt;nav&lt;/code&gt; 既匹配 &lt;code&gt;th:fragment=&amp;quot;nav&amp;quot;&lt;/code&gt;，也匹配文件里所有名为 &lt;code&gt;nav&lt;/code&gt; 的元素。于是分类条跟着顶栏，被插进了每一页。&lt;/p&gt;&#xD;
&lt;p&gt;修法很简单：页头文件里除了导航片段，其他片段一律不用 &lt;code&gt;&amp;lt;nav&amp;gt;&lt;/code&gt; 标签，改成 &lt;code&gt;&amp;lt;div role=&amp;quot;navigation&amp;quot;&amp;gt;&lt;/code&gt;，无障碍语义不丢。&lt;/p&gt;&#xD;
&lt;h2 id="-"&gt;五、层叠：让主题的样式真正生效&lt;/h2&gt;&#xD;
&lt;p&gt;新主题最费劲的不是写样式，而是让样式生效。底座里有三类“硬”规则：&lt;/p&gt;&#xD;
&lt;ul&gt;&#xD;
&lt;li&gt;字体样式用 &lt;code&gt;html body … !important&lt;/code&gt; 钉在标题、按钮、输入框、时间上；&lt;/li&gt;&#xD;
&lt;li&gt;Semantic UI 的评论区规则，比如 &lt;code&gt;.ui.comments&lt;/code&gt; 限宽 650px、头像 &lt;code&gt;float&lt;/code&gt; 加 &lt;code&gt;height: 100%&lt;/code&gt;；&lt;/li&gt;&#xD;
&lt;li&gt;从云笺抽出来的组件底座，不少规则带 &lt;code&gt;!important&lt;/code&gt;，黑夜模式还挂在 &lt;code&gt;html[data-blog-theme=&amp;quot;dark&amp;quot;]&lt;/code&gt; 上。&lt;/li&gt;&#xD;
&lt;/ul&gt;&#xD;
&lt;p&gt;最后固定下来三条规矩。&lt;/p&gt;&#xD;
&lt;p&gt;&lt;strong&gt;能改变量就改变量。&lt;/strong&gt;字体只改 &lt;code&gt;--blog-font-title&lt;/code&gt;、&lt;code&gt;--blog-font-body&lt;/code&gt; 这类变量，颜色、代码块配色也都走变量：底座读变量，主题只负责给变量赋值。&lt;/p&gt;&#xD;
&lt;p&gt;&lt;strong&gt;要压规则就统一抬一级。&lt;/strong&gt;主题里写 &lt;code&gt;html .gx-page .ui.comments …&lt;/code&gt;，比底座多一个元素选择器和一个类，优先级稳定地高一截，不用每条单独琢磨。&lt;/p&gt;&#xD;
&lt;p&gt;&lt;strong&gt;后台只换令牌。&lt;/strong&gt;最早，后台的主题样式放在 &lt;code&gt;@layer theme-admin { … }&lt;/code&gt; 里，并加上 &lt;code&gt;!important&lt;/code&gt;。层叠层有个反直觉的规则：普通声明是“不在层里的赢”，&lt;code&gt;!important&lt;/code&gt; 声明却反过来，“在层里的赢”。所以层里的 &lt;code&gt;!important&lt;/code&gt; 能压过后台原样式里所有的 &lt;code&gt;!important&lt;/code&gt;，又不用去拼选择器的长度。&lt;/p&gt;&#xD;
&lt;p&gt;但它压得太彻底了：10 月 7 日后台排版重做时，新写的侧栏、表格、按钮全被这一层 &lt;code&gt;!important&lt;/code&gt; 盖回旧样子。现在后台的界面结构统一放在 &lt;code&gt;admin-ui.css&lt;/code&gt;，每套主题的后台样式只剩一个 &lt;code&gt;:root&lt;/code&gt; 块，只改底色、文字、边框、主色、圆角和字体这些令牌，不再碰具体组件。&lt;/p&gt;&#xD;
&lt;h3 id="-"&gt;搜索框被裁掉一半&lt;/h3&gt;&#xD;
&lt;p&gt;规矩定了，漏网之鱼还是有。今天发现，除了云笺，其余五套主题的搜索弹窗都不对：输入框贴着弹窗底边，下面那行“Enter 搜索 · Esc 关闭”看不见。原因是底座里的这条规则：&lt;/p&gt;&#xD;
&lt;pre&gt;&lt;code class="lang-css"&gt;&lt;span class="hljs-selector-class"&gt;.search-overlay&lt;/span&gt; &lt;span class="hljs-selector-class"&gt;.search-dialog&lt;/span&gt; {&#xD;
    &lt;span class="hljs-attribute"&gt;height&lt;/span&gt;: &lt;span class="hljs-number"&gt;64px&lt;/span&gt; &lt;span class="hljs-meta"&gt;!important&lt;/span&gt;;&#xD;
    &lt;span class="hljs-attribute"&gt;overflow&lt;/span&gt;: hidden &lt;span class="hljs-meta"&gt;!important&lt;/span&gt;;&#xD;
}&#xD;
&lt;/code&gt;&lt;/pre&gt;&#xD;
&lt;p&gt;这条是从云笺的样式里抽出来的，云笺的弹窗里只有一个输入框。新主题给弹窗加了 8px 内边距、约 60px 高的输入区，下面还有一行提示，实际需要 100 到 111px。弹窗被写死在 64px 并裁掉溢出，输入区就被挤到底边，提示行整行消失。纸墨的弹窗没有内边距，输入框看着正常，但提示行同样被裁掉了。&lt;/p&gt;&#xD;
&lt;p&gt;去掉写死的高度后，弹窗按内容撑开。&lt;/p&gt;&#xD;
&lt;h3 id="-"&gt;同一条规则被复制了五次&lt;/h3&gt;&#xD;
&lt;p&gt;回复弹窗底部有三个按钮：表情、表情包和照片。表情的图标比另外两个小一圈，因为五套新主题的样式里各有一条：&lt;/p&gt;&#xD;
&lt;pre&gt;&lt;code class="lang-css"&gt;&lt;span class="hljs-selector-tag"&gt;html&lt;/span&gt; &lt;span class="hljs-selector-class"&gt;.gx-page&lt;/span&gt; &lt;span class="hljs-selector-id"&gt;#reply-modal&lt;/span&gt; &lt;span class="hljs-selector-class"&gt;.emoji-trigger&lt;/span&gt; &lt;span class="hljs-selector-tag"&gt;i&lt;/span&gt; { &lt;span class="hljs-attribute"&gt;font-size&lt;/span&gt;: &lt;span class="hljs-number"&gt;17px&lt;/span&gt; &lt;span class="hljs-meta"&gt;!important&lt;/span&gt;; }&#xD;
&lt;/code&gt;&lt;/pre&gt;&#xD;
&lt;p&gt;另外两个图标是 19px。新主题大多是从前一套派生出来的，这条规则就这样一路被带了下来。&lt;/p&gt;&#xD;
&lt;p&gt;派生留下的尾巴还有更隐蔽的。星河从格致的样式派生时，我按行删掉了导航和标签栏，留下了一段没有选择器的声明块和一个多余的 &lt;code&gt;}&lt;/code&gt;。CSS 解析器遇到它，会把紧跟其后的整条规则当作错误丢掉。被丢掉的恰好是评论框的图标对齐，于是图标错位，而浏览器不会给出任何报错。&lt;strong&gt;派生主题之后，要跑一遍括号配平检查。&lt;/strong&gt;&lt;/p&gt;&#xD;
&lt;p&gt;另一个是 &lt;code&gt;display: grid&lt;/code&gt; 的容器。如果不写 &lt;code&gt;grid-template-columns: minmax(0, 1fr)&lt;/code&gt;，轨道的最小宽度默认取内容的最小宽度，文章里的表格和代码块会把整列撑宽，手机上的文章页一度被撑到 632px。&lt;/p&gt;&#xD;
&lt;h3 id="-"&gt;回复和父评论不是一个样子&lt;/h3&gt;&#xD;
&lt;p&gt;楼中楼回复原来整条铺了一块底色，内容气泡又是同一个颜色，看不出层次；属地还单独掉在第二行。这次改成和父评论同一套结构：整条回复不铺底色，只有内容气泡有背景，属地挪进时间那一行。&lt;/p&gt;&#xD;
&lt;p&gt;这需要改回复的渲染脚本，而云笺也在用这个脚本。改前改后各截一张图对比，确认云笺的显示没有变化。&lt;/p&gt;&#xD;
&lt;p&gt;改完又被优先级坑了一次。手机上我把回复头像缩到 32px，网格的第一列确实缩了，头像却还是 44px：主题里另有一条 &lt;code&gt;.gx-page .ui.comments .comment &amp;gt; .avatar&lt;/code&gt;，给头像写了带 &lt;code&gt;!important&lt;/code&gt; 的固定宽度，优先级是 (0,5,0)，比我写的 &lt;code&gt;html .gx-page .replies-container .comment &amp;gt; .avatar&lt;/code&gt;（(0,4,1)）高。两条都带 &lt;code&gt;!important&lt;/code&gt;，比的还是优先级。结果头像溢出到 10px 的间距里，纸墨只剩 2px，另外四套主题直接压住了昵称 2px。选择器里补上 &lt;code&gt;.ui.comments&lt;/code&gt; 之后才生效，间距也改成和父评论一样的 14px。&lt;/p&gt;&#xD;
&lt;h2 id="-120-"&gt;六、去掉点击后的焦点框：和 120 条规则打交道&lt;/h2&gt;&#xD;
&lt;p&gt;最后一个需求听起来最简单：点击之后，不要再出现蓝色的框。&lt;/p&gt;&#xD;
&lt;p&gt;先数了一下。前台加载的样式表里，写在 &lt;code&gt;:focus&lt;/code&gt;、&lt;code&gt;:focus-visible&lt;/code&gt;、&lt;code&gt;:focus-within&lt;/code&gt; 上、会改描边、阴影或边框的规则，一共 &lt;strong&gt;120 条，分布在 23 个文件里&lt;/strong&gt;，其中 45 条带 &lt;code&gt;!important&lt;/code&gt;，16 条的选择器里有 id。逐条改不现实，也保证不了以后不再冒出来。&lt;/p&gt;&#xD;
&lt;p&gt;第一个念头是只保留 &lt;code&gt;:focus-visible&lt;/code&gt;，因为浏览器只在键盘操作时才让它生效。但实测下来，&lt;strong&gt;按钮&lt;/strong&gt;确实如此，鼠标点击后不匹配；&lt;strong&gt;输入框&lt;/strong&gt;却不是：鼠标点进跳页输入框，主题里写在 &lt;code&gt;:focus-visible&lt;/code&gt; 上的 2px 描边照样出现了。按照规范建议的判断方式，能接受键盘输入的元素获得焦点时总是匹配 &lt;code&gt;:focus-visible&lt;/code&gt;，因为用户需要知道光标在哪。所以这条路去不掉输入框的框。&lt;/p&gt;&#xD;
&lt;p&gt;于是换个思路：新建一个 &lt;code&gt;focus-reset.css&lt;/code&gt;，前台所有页面都加载，统一去掉焦点时的描边和光圈。难点在于，它要压过那些带 id、带 &lt;code&gt;!important&lt;/code&gt; 的规则，比如：&lt;/p&gt;&#xD;
&lt;pre&gt;&lt;code class="lang-css"&gt;&lt;span class="hljs-selector-class"&gt;.reply-modal&lt;/span&gt; &lt;span class="hljs-selector-id"&gt;#reply-editable&lt;/span&gt;&lt;span class="hljs-selector-pseudo"&gt;:focus&lt;/span&gt; {&#xD;
    &lt;span class="hljs-attribute"&gt;border-color&lt;/span&gt;: &lt;span class="hljs-built_in"&gt;rgba&lt;/span&gt;(73, 104, 242, .45) &lt;span class="hljs-meta"&gt;!important&lt;/span&gt;;&#xD;
    &lt;span class="hljs-attribute"&gt;box-shadow&lt;/span&gt;: &lt;span class="hljs-number"&gt;0&lt;/span&gt; &lt;span class="hljs-number"&gt;0&lt;/span&gt; &lt;span class="hljs-number"&gt;0&lt;/span&gt; &lt;span class="hljs-number"&gt;4px&lt;/span&gt; &lt;span class="hljs-built_in"&gt;rgba&lt;/span&gt;(73, 104, 242, .10) &lt;span class="hljs-meta"&gt;!important&lt;/span&gt;;&#xD;
}&#xD;
&lt;/code&gt;&lt;/pre&gt;&#xD;
&lt;p&gt;这条的优先级是 (1,2,0)。重置规则既要能匹配任何元素，又要比它高，这里借了一下优先级：&lt;/p&gt;&#xD;
&lt;pre&gt;&lt;code class="lang-css"&gt;&lt;span class="hljs-selector-tag"&gt;html&lt;/span&gt; &lt;span class="hljs-selector-tag"&gt;body&lt;/span&gt; &lt;span class="hljs-selector-pseudo"&gt;:is(&lt;/span&gt;&lt;span class="hljs-selector-id"&gt;#focus-reset&lt;/span&gt;, *)&lt;span class="hljs-selector-pseudo"&gt;:not(&lt;/span&gt;&lt;span class="hljs-selector-id"&gt;#focus-reset&lt;/span&gt;)&lt;span class="hljs-selector-pseudo"&gt;:focus&lt;/span&gt;,&#xD;
&lt;span class="hljs-selector-tag"&gt;html&lt;/span&gt; &lt;span class="hljs-selector-tag"&gt;body&lt;/span&gt; &lt;span class="hljs-selector-pseudo"&gt;:is(&lt;/span&gt;&lt;span class="hljs-selector-id"&gt;#focus-reset&lt;/span&gt;, *)&lt;span class="hljs-selector-pseudo"&gt;:not(&lt;/span&gt;&lt;span class="hljs-selector-id"&gt;#focus-reset&lt;/span&gt;)&lt;span class="hljs-selector-pseudo"&gt;:focus-visible&lt;/span&gt;,&#xD;
&lt;span class="hljs-selector-tag"&gt;html&lt;/span&gt; &lt;span class="hljs-selector-tag"&gt;body&lt;/span&gt; &lt;span class="hljs-selector-pseudo"&gt;:is(&lt;/span&gt;&lt;span class="hljs-selector-id"&gt;#focus-reset&lt;/span&gt;, *)&lt;span class="hljs-selector-pseudo"&gt;:not(&lt;/span&gt;&lt;span class="hljs-selector-id"&gt;#focus-reset&lt;/span&gt;)&lt;span class="hljs-selector-pseudo"&gt;:focus-within&lt;/span&gt; {&#xD;
    &lt;span class="hljs-attribute"&gt;outline&lt;/span&gt;: none &lt;span class="hljs-meta"&gt;!important&lt;/span&gt;;&#xD;
}&#xD;
&#xD;
&lt;span class="hljs-selector-tag"&gt;html&lt;/span&gt; &lt;span class="hljs-selector-tag"&gt;body&lt;/span&gt; &lt;span class="hljs-selector-pseudo"&gt;:is(&lt;/span&gt;&lt;span class="hljs-selector-id"&gt;#focus-reset&lt;/span&gt;, &lt;span class="hljs-selector-tag"&gt;input&lt;/span&gt;)&lt;span class="hljs-selector-pseudo"&gt;:not(&lt;/span&gt;&lt;span class="hljs-selector-id"&gt;#focus-reset&lt;/span&gt;)&lt;span class="hljs-selector-pseudo"&gt;:focus&lt;/span&gt;&lt;span class="hljs-selector-pseudo"&gt;:not(&lt;/span&gt;&lt;span class="hljs-selector-pseudo"&gt;:-webkit-autofill)&lt;/span&gt;,&#xD;
&lt;span class="hljs-selector-tag"&gt;html&lt;/span&gt; &lt;span class="hljs-selector-tag"&gt;body&lt;/span&gt; &lt;span class="hljs-selector-pseudo"&gt;:is(&lt;/span&gt;&lt;span class="hljs-selector-id"&gt;#focus-reset&lt;/span&gt;, &lt;span class="hljs-selector-tag"&gt;textarea&lt;/span&gt;, &lt;span class="hljs-selector-tag"&gt;select&lt;/span&gt;, &lt;span class="hljs-selector-attr"&gt;[contenteditable]&lt;/span&gt;)&lt;span class="hljs-selector-pseudo"&gt;:not(&lt;/span&gt;&lt;span class="hljs-selector-id"&gt;#focus-reset&lt;/span&gt;)&lt;span class="hljs-selector-pseudo"&gt;:focus&lt;/span&gt; {&#xD;
    &lt;span class="hljs-attribute"&gt;box-shadow&lt;/span&gt;: none &lt;span class="hljs-meta"&gt;!important&lt;/span&gt;;&#xD;
}&#xD;
&lt;/code&gt;&lt;/pre&gt;&#xD;
&lt;p&gt;&lt;code&gt;:is()&lt;/code&gt; 的优先级取参数里最高的那个，哪怕实际匹配上的是 &lt;code&gt;*&lt;/code&gt;，也按 &lt;code&gt;#focus-reset&lt;/code&gt; 算作 (1,0,0)；&lt;code&gt;:not(#focus-reset)&lt;/code&gt; 再加一个 (1,0,0)。页面上并没有这个 id，所以选择器对任何元素都成立，优先级却是 (2,1,2)，稳稳压过上面那条。&lt;/p&gt;&#xD;
&lt;p&gt;还有两个细节：&lt;/p&gt;&#xD;
&lt;p&gt;&lt;strong&gt;自动填充要排除。&lt;/strong&gt;黑夜模式下，站点用一圈 1000px 的内阴影盖掉浏览器自动填充的浅色底。输入框的焦点光圈也是 &lt;code&gt;box-shadow&lt;/code&gt;，重置时如果不排除 &lt;code&gt;:-webkit-autofill&lt;/code&gt;，这层遮盖会被一起去掉。&lt;/p&gt;&#xD;
&lt;p&gt;&lt;strong&gt;变色的边框只能逐条改。&lt;/strong&gt;有些规则不是加描边或光圈，而是在聚焦时把边框换成主题色，比如黑夜模式的跳页输入框、AI 助手的输入框、表情包的搜索框。原来的边框是什么颜色，通用的重置规则无从知道，这几处只能回到原规则里把变色删掉，一共 13 条。&lt;/p&gt;&#xD;
&lt;p&gt;验证用的是 CDP 的 &lt;code&gt;Input.dispatchMouseEvent&lt;/code&gt;，真实地点击输入框、按钮和回复框，对比点击前后元素的描边、阴影和边框颜色。六套主题，白天和黑夜，点击后都不再出现描边和光圈，输入框的边框颜色也和点击前一致。&lt;/p&gt;&#xD;
&lt;p&gt;代价也要说清楚：焦点框对只用键盘的访客是有用的，去掉之后按 Tab 键切换，同样看不到焦点在哪。这是一次有意做出的取舍。&lt;/p&gt;&#xD;
&lt;h2 id="-"&gt;七、明暗切换：圆为什么在流萤上看不出来&lt;/h2&gt;&#xD;
&lt;p&gt;六套主题的明暗切换是同一个脚本：用 View Transitions 把切换前后的页面各拍一张，新画面从点击的位置以圆形扩散铺满，约半秒。各主题自己的明暗按钮（顶栏开关、抽屉里的按钮）都是转发给一个公共按钮的，真正被 &lt;code&gt;click()&lt;/code&gt; 的元素不在点击位置，所以脚本在捕获阶段记下最近一次 &lt;code&gt;pointerdown&lt;/code&gt; 的坐标，拿它当圆心。不支持 View Transitions 的浏览器退回颜色渐变，系统开了“减少动态效果”就直接切换。核心只有几行（简化后）：&lt;/p&gt;&#xD;
&lt;pre&gt;&lt;code class="lang-javascript"&gt;&lt;span class="hljs-keyword"&gt;const&lt;/span&gt; transition = &lt;span class="hljs-built_in"&gt;document&lt;/span&gt;.startViewTransition(&lt;span class="hljs-function"&gt;&lt;span class="hljs-params"&gt;()&lt;/span&gt; =&amp;gt;&lt;/span&gt; commitTheme(nextTheme));&#xD;
transition.ready.then(&lt;span class="hljs-function"&gt;&lt;span class="hljs-params"&gt;()&lt;/span&gt; =&amp;gt;&lt;/span&gt; {&#xD;
  &lt;span class="hljs-built_in"&gt;document&lt;/span&gt;.documentElement.animate({&#xD;
    &lt;span class="hljs-attr"&gt;clipPath&lt;/span&gt;: [&lt;span class="hljs-string"&gt;`circle(0px at &lt;span class="hljs-subst"&gt;${x}&lt;/span&gt;px &lt;span class="hljs-subst"&gt;${y}&lt;/span&gt;px)`&lt;/span&gt;, &lt;span class="hljs-string"&gt;`circle(&lt;span class="hljs-subst"&gt;${radius}&lt;/span&gt;px at &lt;span class="hljs-subst"&gt;${x}&lt;/span&gt;px &lt;span class="hljs-subst"&gt;${y}&lt;/span&gt;px)`&lt;/span&gt;]&#xD;
  }, { &lt;span class="hljs-attr"&gt;duration&lt;/span&gt;: &lt;span class="hljs-number"&gt;560&lt;/span&gt;, &lt;span class="hljs-attr"&gt;easing&lt;/span&gt;: &lt;span class="hljs-string"&gt;'cubic-bezier(.4, 0, .2, 1)'&lt;/span&gt;, &lt;span class="hljs-attr"&gt;pseudoElement&lt;/span&gt;: &lt;span class="hljs-string"&gt;'::view-transition-new(root)'&lt;/span&gt; });&#xD;
});&#xD;
&lt;/code&gt;&lt;/pre&gt;&#xD;
&lt;p&gt;云笺上这个效果很明显，流萤上却像没有动画。把动画放慢 10 倍逐帧截图，扩散其实在发生：流萤的首屏是一张铺满整屏的大图，明暗两套配色下它一模一样，圆扫过去，变色的只有顶栏和底部的波浪。修法是黑夜模式给大图多压一层暗色（遮罩从 18% 加到 46%），圆扫过首屏时能看出明暗交界，夜里看大图也不刺眼。&lt;/p&gt;&#xD;
&lt;p&gt;第二个问题在手机上。扩散的那半秒里，有四五帧都要 150 到 220ms（无头 Chrome，CPU 按 4 倍降速），圆是一顿一顿扩出来的。原因是 View Transitions 的新画面不是一张静态截图，而是实时渲染的。主题里很多元素自己带着 &lt;code&gt;transition: color .15s, background .2s&lt;/code&gt; 这类过渡，配色一换，这些过渡就在圆里逐帧播放，整页每帧都要重绘，露出来的颜色也是半路上的。扩散期间把它们全关掉：&lt;/p&gt;&#xD;
&lt;pre&gt;&lt;code class="lang-css"&gt;&lt;span class="hljs-selector-tag"&gt;html&lt;/span&gt;&lt;span class="hljs-selector-class"&gt;.blog-theme-revealing&lt;/span&gt; *,&#xD;
&lt;span class="hljs-selector-tag"&gt;html&lt;/span&gt;&lt;span class="hljs-selector-class"&gt;.blog-theme-revealing&lt;/span&gt; *&lt;span class="hljs-selector-pseudo"&gt;::before&lt;/span&gt;,&#xD;
&lt;span class="hljs-selector-tag"&gt;html&lt;/span&gt;&lt;span class="hljs-selector-class"&gt;.blog-theme-revealing&lt;/span&gt; *&lt;span class="hljs-selector-pseudo"&gt;::after&lt;/span&gt; {&#xD;
    &lt;span class="hljs-attribute"&gt;transition&lt;/span&gt;: none &lt;span class="hljs-meta"&gt;!important&lt;/span&gt;;&#xD;
}&#xD;
&lt;/code&gt;&lt;/pre&gt;&#xD;
&lt;p&gt;配色一次切到位，之后圆只管扩散。改完只剩切换配色的那一帧比较长（两三百毫秒，整页换配色本来就要重算一遍样式），其余每帧都是 17ms。&lt;/p&gt;&#xD;
&lt;p&gt;顺手改了两处：切换时为了让按钮里的日月图标重新播一遍入场动画，脚本先摘掉类、读一次 &lt;code&gt;offsetWidth&lt;/code&gt; 强制重排、再把类加回去，配色刚换就又整页重排一次，现在只有上一次的动画还没播完时才这么做；文章里的 Mermaid 图会按新配色重画，一张图在手机上要两百多毫秒，原来正好撞在扩散中间，现在等过渡播完再画。&lt;/p&gt;&#xD;
&lt;h2 id="-"&gt;八、流畅度：页面停着，主线程也在忙&lt;/h2&gt;&#xD;
&lt;p&gt;最后对流萤做了一轮流畅度测试。无头 Chrome 打开六个页面，桌面 1440 宽、手机 390 宽（CPU 按 4 倍降速，模拟手机），每个页面录三段：静止的 3 秒、一次 4000px 的滚动手势、页面加载。看的是 requestAnimationFrame 的帧间隔、能归因到具体脚本的长动画帧（Long Animation Frames），以及 trace 里样式重算、重排、重绘的次数；再打开 invalidation tracking，看每次样式失效是谁引起的。&lt;/p&gt;&#xD;
&lt;p&gt;&lt;strong&gt;首屏的波浪：页面不动，也在每帧重画。&lt;/strong&gt;流萤首屏底部是参考站同款的四层波浪，原来是一张 svg 里四个 &lt;code&gt;&amp;lt;use&amp;gt;&lt;/code&gt; 做横移动画。SVG 内部元素的 transform 动画走不了合成线程，结果页面停着不动，桌面上 3 秒里也有样式重算 203 次、共 483ms，重排 205 次，重绘 388 次。改法是每层单独一个 &lt;code&gt;&amp;lt;svg&amp;gt;&lt;/code&gt;，动画加在 &lt;code&gt;&amp;lt;svg&amp;gt;&lt;/code&gt; 元素自己身上：它是普通的 CSS 盒子，transform 可以交给合成线程。几何要换算一下，原来的位移是 -90 到 85 个用户单位，路径全长 352 个单位，换成自身宽度的百分比：&lt;/p&gt;&#xD;
&lt;pre&gt;&lt;code class="lang-css"&gt;&lt;span class="hljs-selector-class"&gt;.ff-wave&lt;/span&gt; {&#xD;
    &lt;span class="hljs-attribute"&gt;position&lt;/span&gt;: absolute; &lt;span class="hljs-attribute"&gt;left&lt;/span&gt;: -&lt;span class="hljs-number"&gt;74.6667%&lt;/span&gt;; &lt;span class="hljs-attribute"&gt;width&lt;/span&gt;: &lt;span class="hljs-number"&gt;234.6667%&lt;/span&gt;; &lt;span class="hljs-attribute"&gt;height&lt;/span&gt;: &lt;span class="hljs-number"&gt;100%&lt;/span&gt;;&#xD;
    &lt;span class="hljs-attribute"&gt;animation&lt;/span&gt;: ff-wave &lt;span class="hljs-number"&gt;25s&lt;/span&gt; &lt;span class="hljs-built_in"&gt;cubic-bezier&lt;/span&gt;(.55, .5, .45, .5) infinite;&#xD;
}&#xD;
@&lt;span class="hljs-keyword"&gt;keyframes&lt;/span&gt; ff-wave {&#xD;
    0% { &lt;span class="hljs-attribute"&gt;transform&lt;/span&gt;: &lt;span class="hljs-built_in"&gt;translate3d&lt;/span&gt;(-25.568%, 0, 0); }&#xD;
    100% { &lt;span class="hljs-attribute"&gt;transform&lt;/span&gt;: &lt;span class="hljs-built_in"&gt;translate3d&lt;/span&gt;(24.148%, 0, 0); }&#xD;
}&#xD;
&lt;/code&gt;&lt;/pre&gt;&#xD;
&lt;p&gt;把新旧两版的动画停在同一时刻截图，波浪的形状一模一样。改完同样的 3 秒，样式重算降到 86 次、143ms，重排 31 次，重绘 70 次。阅读进度条也是同一类问题：原来过渡的是 &lt;code&gt;width&lt;/code&gt;，改成 &lt;code&gt;transform: scaleX()&lt;/code&gt;。&lt;/p&gt;&#xD;
&lt;p&gt;&lt;strong&gt;留言板：两个盯着整页的观察者。&lt;/strong&gt;留言板在手机上滚动最卡，帧间隔的 p95 是 67ms，有 24 帧超过 50ms。归因下来，静止的 3 秒里有 417 次强制样式计算来自同一个函数，是留言“查看完整内容”的折叠。它在 &lt;code&gt;document.body&lt;/code&gt; 上挂了 MutationObserver 观察整棵子树，任何节点增删都把整页留言逐条 &lt;code&gt;getComputedStyle&lt;/code&gt;、量一遍高度；而这个页面上的节点一直在变，弹幕条每秒进出，“在一起”的计时每秒改一次字。返回顶部按钮的脚本也挂着一个同样的观察者，连 class 和 style 的变化都算，每次都去查一遍“有没有弹窗开着”；滚动时阅读进度条每帧都改 style，于是每帧都查。&lt;/p&gt;&#xD;
&lt;p&gt;修法是只认相关的变化：折叠只在新留言加进来时重算；返回顶部把滚动和 DOM 变化合并到每帧最多一次，弹窗状态只在弹窗元素自己变化时才重查。&lt;/p&gt;&#xD;
&lt;p&gt;&lt;strong&gt;屏幕外的动画也要算。&lt;/strong&gt;跑在合成线程上的动画并不是完全免费的：只要页面在出新帧，比如滚动时进度条在动，Chrome 每一帧都会在主线程上把正在播的动画的样式更新一遍，不管它在不在屏幕里。弹幕墙、首屏波浪、“在一起”的心跳和天气图标，现在滚出视口就用 IntersectionObserver 暂停。弹幕墙要多做一步：恢复时，各条轨道“什么时候空出来”的时间要顺延暂停的时长，不然新弹幕会叠到停在半路的旧弹幕上。&lt;/p&gt;&#xD;
&lt;p&gt;&lt;strong&gt;代码块美化：先全写，再全读。&lt;/strong&gt;文章页加载时，给代码块加标题栏、折叠长代码的脚本在手机上要 284ms。它每包好一个代码块就读一次 &lt;code&gt;offsetHeight&lt;/code&gt; 判断要不要折叠，下一个再插 DOM、再读，每次读都要把整页重排一遍。改成先把所有代码块包好，再统一量高度，最后统一折叠，降到 75ms 左右。&lt;/p&gt;&#xD;
&lt;table&gt;&#xD;
&lt;thead&gt;&#xD;
&lt;tr&gt;&#xD;
&lt;th style="text-align:center"&gt;手机端指标&lt;/th&gt;&#xD;
&lt;th style="text-align:center"&gt;改前&lt;/th&gt;&#xD;
&lt;th style="text-align:center"&gt;改后&lt;/th&gt;&#xD;
&lt;/tr&gt;&#xD;
&lt;/thead&gt;&#xD;
&lt;tbody&gt;&#xD;
&lt;tr&gt;&#xD;
&lt;td style="text-align:center"&gt;留言板滚动 p95&lt;/td&gt;&#xD;
&lt;td style="text-align:center"&gt;67ms&lt;/td&gt;&#xD;
&lt;td style="text-align:center"&gt;17ms&lt;/td&gt;&#xD;
&lt;/tr&gt;&#xD;
&lt;tr&gt;&#xD;
&lt;td style="text-align:center"&gt;超过 50ms 的帧&lt;/td&gt;&#xD;
&lt;td style="text-align:center"&gt;24 帧&lt;/td&gt;&#xD;
&lt;td style="text-align:center"&gt;0 帧&lt;/td&gt;&#xD;
&lt;/tr&gt;&#xD;
&lt;tr&gt;&#xD;
&lt;td style="text-align:center"&gt;留言板静止 3 秒&lt;/td&gt;&#xD;
&lt;td style="text-align:center"&gt;1131ms&lt;/td&gt;&#xD;
&lt;td style="text-align:center"&gt;96ms&lt;/td&gt;&#xD;
&lt;/tr&gt;&#xD;
&lt;tr&gt;&#xD;
&lt;td style="text-align:center"&gt;代码块美化&lt;/td&gt;&#xD;
&lt;td style="text-align:center"&gt;284ms&lt;/td&gt;&#xD;
&lt;td style="text-align:center"&gt;75ms&lt;/td&gt;&#xD;
&lt;/tr&gt;&#xD;
&lt;/tbody&gt;&#xD;
&lt;/table&gt;&#xD;
&lt;p&gt;表里第三行是静止 3 秒内样式计算的总耗时。这些数字来自无头 Chrome 的模拟，和真机的绝对值不同，但改前改后用的是同一套条件。&lt;/p&gt;&#xD;
&lt;h2 id="-"&gt;九、验证：不部署，也能在线上页面看改动&lt;/h2&gt;&#xD;
&lt;p&gt;主题的问题大多出在真实数据上：超长的无空格链接、带图的评论、只有一两篇文章的分类……本地造的数据很难覆盖全。所以我的验证基本都在线上页面上做，但不用先部署。&lt;/p&gt;&#xD;
&lt;p&gt;做法是用无头 Chrome 打开线上页面，通过 CDP 的 Fetch 域拦截请求。页面引用的样式和脚本都是带内容哈希的地址，形如 &lt;code&gt;/css/themes/galaxy/theme-&amp;lt;哈希&amp;gt;.css&lt;/code&gt;，拦下来后用本地文件回填：&lt;/p&gt;&#xD;
&lt;pre&gt;&lt;code class="lang-javascript"&gt;&lt;span class="hljs-comment"&gt;// 只拦样式和主题脚本，其余请求照常走线上&lt;/span&gt;&#xD;
&lt;span class="hljs-keyword"&gt;await&lt;/span&gt; send(&lt;span class="hljs-string"&gt;'Fetch.enable'&lt;/span&gt;, { &lt;span class="hljs-attr"&gt;patterns&lt;/span&gt;: [{ &lt;span class="hljs-attr"&gt;urlPattern&lt;/span&gt;: &lt;span class="hljs-string"&gt;'*/css/*'&lt;/span&gt; }, { &lt;span class="hljs-attr"&gt;urlPattern&lt;/span&gt;: &lt;span class="hljs-string"&gt;'*/js/themes/*'&lt;/span&gt; }] });&#xD;
&#xD;
&lt;span class="hljs-function"&gt;&lt;span class="hljs-keyword"&gt;function&lt;/span&gt; &lt;span class="hljs-title"&gt;onRequestPaused&lt;/span&gt;(&lt;span class="hljs-params"&gt;{ requestId, request }&lt;/span&gt;) &lt;/span&gt;{&#xD;
  &lt;span class="hljs-keyword"&gt;const&lt;/span&gt; m = request.url.match(&lt;span class="hljs-regexp"&gt;/\/css\/themes\/([a-z]+)\/theme-[0-9a-f]+\.css/&lt;/span&gt;);&#xD;
  &lt;span class="hljs-keyword"&gt;if&lt;/span&gt; (!m) &lt;span class="hljs-keyword"&gt;return&lt;/span&gt; send(&lt;span class="hljs-string"&gt;'Fetch.continueRequest'&lt;/span&gt;, { requestId });&#xD;
  &lt;span class="hljs-keyword"&gt;const&lt;/span&gt; body = fs.readFileSync(&lt;span class="hljs-string"&gt;`&lt;span class="hljs-subst"&gt;${LOCAL}&lt;/span&gt;/css/themes/&lt;span class="hljs-subst"&gt;${m[&lt;span class="hljs-number"&gt;1&lt;/span&gt;]}&lt;/span&gt;/theme.css`&lt;/span&gt;).toString(&lt;span class="hljs-string"&gt;'base64'&lt;/span&gt;);&#xD;
  &lt;span class="hljs-keyword"&gt;return&lt;/span&gt; send(&lt;span class="hljs-string"&gt;'Fetch.fulfillRequest'&lt;/span&gt;, {&#xD;
    requestId, &lt;span class="hljs-attr"&gt;responseCode&lt;/span&gt;: &lt;span class="hljs-number"&gt;200&lt;/span&gt;,&#xD;
    &lt;span class="hljs-attr"&gt;responseHeaders&lt;/span&gt;: [{ &lt;span class="hljs-attr"&gt;name&lt;/span&gt;: &lt;span class="hljs-string"&gt;'Content-Type'&lt;/span&gt;, &lt;span class="hljs-attr"&gt;value&lt;/span&gt;: &lt;span class="hljs-string"&gt;'text/css'&lt;/span&gt; }], body,&#xD;
  });&#xD;
}&#xD;
&lt;/code&gt;&lt;/pre&gt;&#xD;
&lt;p&gt;页面里的文章、评论、图片都是真的，只有样式和脚本是本地正在改的版本。线上还不存在的新文件，比如这次的 &lt;code&gt;focus-reset.css&lt;/code&gt;，用 &lt;code&gt;Page.addScriptToEvaluateOnNewDocument&lt;/code&gt; 在页面加载时注入。&lt;/p&gt;&#xD;
&lt;p&gt;截图之外，还会在页面里跑一段检查脚本：逐个文本节点往上找真实的底色、计算对比度，找黑夜模式里扎眼的大块亮色，查横向溢出和脚本报错。四套主题做黑夜模式优化时，它查出日期、阅读数这类弱化文字的对比度只有 3.2 到 3.8，主色偏亮的主题上白字只有 2.5 到 2.9，都低于 WCAG 对正文要求的 4.5:1。&lt;/p&gt;&#xD;
&lt;p&gt;这里我自己犯过一个错。横向溢出一开始用 &lt;code&gt;scrollWidth - innerWidth&lt;/code&gt; 判断，手机模拟下结果永远是 0。原因是内容超宽时，模拟视口的 &lt;code&gt;innerWidth&lt;/code&gt; 会跟着被撑大（那一次是 469px），拿它来比当然没有差值。改用 &lt;code&gt;document.documentElement.clientWidth&lt;/code&gt; 之后，才发现流萤的手机端首页被分页条撑宽了。&lt;/p&gt;&#xD;
&lt;p&gt;另一条经验是：&lt;strong&gt;整页长图缩小之后，什么都看不出来。&lt;/strong&gt;评论框图标错位、评论照片撑满整列、恋爱计时显示 0，都是一屏一屏按视口截图才发现的。&lt;/p&gt;&#xD;
&lt;p&gt;这次的几项修改上线前，六套主题、两种宽度（390 和 1440）、八种交互状态（搜索、表情、表情包、回复弹窗、展开回复、视频卡片、菜单、AI 助手），白天跑了 96 组，黑夜又跑了 60 组，没有横向溢出，也没有脚本报错。&lt;/p&gt;&#xD;
&lt;h2 id="-"&gt;总结&lt;/h2&gt;&#xD;
&lt;table&gt;&#xD;
&lt;thead&gt;&#xD;
&lt;tr&gt;&#xD;
&lt;th style="text-align:center"&gt;问题&lt;/th&gt;&#xD;
&lt;th style="text-align:center"&gt;根因&lt;/th&gt;&#xD;
&lt;th style="text-align:center"&gt;改法&lt;/th&gt;&#xD;
&lt;/tr&gt;&#xD;
&lt;/thead&gt;&#xD;
&lt;tbody&gt;&#xD;
&lt;tr&gt;&#xD;
&lt;td style="text-align:center"&gt;主题像换了配色&lt;/td&gt;&#xD;
&lt;td style="text-align:center"&gt;只叠覆盖样式&lt;/td&gt;&#xD;
&lt;td style="text-align:center"&gt;独立模板&lt;/td&gt;&#xD;
&lt;/tr&gt;&#xD;
&lt;tr&gt;&#xD;
&lt;td style="text-align:center"&gt;海报标题是占位字&lt;/td&gt;&#xD;
&lt;td style="text-align:center"&gt;假设标题带链接&lt;/td&gt;&#xD;
&lt;td style="text-align:center"&gt;回退取文字&lt;/td&gt;&#xD;
&lt;/tr&gt;&#xD;
&lt;tr&gt;&#xD;
&lt;td style="text-align:center"&gt;分类条出现两次&lt;/td&gt;&#xD;
&lt;td style="text-align:center"&gt;选择器匹配 nav 元素&lt;/td&gt;&#xD;
&lt;td style="text-align:center"&gt;改用 div&lt;/td&gt;&#xD;
&lt;/tr&gt;&#xD;
&lt;tr&gt;&#xD;
&lt;td style="text-align:center"&gt;搜索提示被裁&lt;/td&gt;&#xD;
&lt;td style="text-align:center"&gt;写死 64px 高&lt;/td&gt;&#xD;
&lt;td style="text-align:center"&gt;去掉固定高度&lt;/td&gt;&#xD;
&lt;/tr&gt;&#xD;
&lt;tr&gt;&#xD;
&lt;td style="text-align:center"&gt;表情图标偏小&lt;/td&gt;&#xD;
&lt;td style="text-align:center"&gt;派生时复制旧规则&lt;/td&gt;&#xD;
&lt;td style="text-align:center"&gt;统一 19px&lt;/td&gt;&#xD;
&lt;/tr&gt;&#xD;
&lt;tr&gt;&#xD;
&lt;td style="text-align:center"&gt;回复头像压住昵称&lt;/td&gt;&#xD;
&lt;td style="text-align:center"&gt;优先级不够&lt;/td&gt;&#xD;
&lt;td style="text-align:center"&gt;补上 .ui.comments&lt;/td&gt;&#xD;
&lt;/tr&gt;&#xD;
&lt;tr&gt;&#xD;
&lt;td style="text-align:center"&gt;点击后有蓝框&lt;/td&gt;&#xD;
&lt;td style="text-align:center"&gt;120 条焦点规则&lt;/td&gt;&#xD;
&lt;td style="text-align:center"&gt;统一重置&lt;/td&gt;&#xD;
&lt;/tr&gt;&#xD;
&lt;tr&gt;&#xD;
&lt;td style="text-align:center"&gt;溢出测不出&lt;/td&gt;&#xD;
&lt;td style="text-align:center"&gt;用了 innerWidth&lt;/td&gt;&#xD;
&lt;td style="text-align:center"&gt;改用 clientWidth&lt;/td&gt;&#xD;
&lt;/tr&gt;&#xD;
&lt;tr&gt;&#xD;
&lt;td style="text-align:center"&gt;切换动画看不出&lt;/td&gt;&#xD;
&lt;td style="text-align:center"&gt;首屏大图明暗一样&lt;/td&gt;&#xD;
&lt;td style="text-align:center"&gt;黑夜压暗大图&lt;/td&gt;&#xD;
&lt;/tr&gt;&#xD;
&lt;tr&gt;&#xD;
&lt;td style="text-align:center"&gt;扩散一顿一顿&lt;/td&gt;&#xD;
&lt;td style="text-align:center"&gt;圆里逐帧播过渡&lt;/td&gt;&#xD;
&lt;td style="text-align:center"&gt;扩散时关过渡&lt;/td&gt;&#xD;
&lt;/tr&gt;&#xD;
&lt;tr&gt;&#xD;
&lt;td style="text-align:center"&gt;静止时也在重绘&lt;/td&gt;&#xD;
&lt;td style="text-align:center"&gt;SVG 内部元素动画&lt;/td&gt;&#xD;
&lt;td style="text-align:center"&gt;每层一个 svg&lt;/td&gt;&#xD;
&lt;/tr&gt;&#xD;
&lt;tr&gt;&#xD;
&lt;td style="text-align:center"&gt;留言板滚动卡&lt;/td&gt;&#xD;
&lt;td style="text-align:center"&gt;整页观察者&lt;/td&gt;&#xD;
&lt;td style="text-align:center"&gt;只认相关变化&lt;/td&gt;&#xD;
&lt;/tr&gt;&#xD;
&lt;/tbody&gt;&#xD;
&lt;/table&gt;&#xD;
&lt;p&gt;回头看，六套主题真正的工作量不在“画”，而在“边界”：&lt;/p&gt;&#xD;
&lt;p&gt;&lt;strong&gt;骨架和组件分开。&lt;/strong&gt;结构差异交给模板，交互交给共享片段，脚本就不用跟着主题分叉。&lt;/p&gt;&#xD;
&lt;p&gt;&lt;strong&gt;契约要写下来，也要知道它守不住什么。&lt;/strong&gt;id 和 class 能写进测试，“标题里有个链接”这种隐式假设写不进去，只能靠把每个功能点一遍。&lt;/p&gt;&#xD;
&lt;p&gt;&lt;strong&gt;层叠要有规矩。&lt;/strong&gt;先改变量，要压规则就统一抬一级，实在需要全局兜底，再去借优先级。&lt;/p&gt;&#xD;
&lt;p&gt;&lt;strong&gt;动画先问它跑在哪个线程。&lt;/strong&gt;SVG 内部的元素、&lt;code&gt;width&lt;/code&gt; 这类属性动不了合成线程；屏幕外的动画、盯着整页的观察者，会在页面看起来什么都没做的时候占着主线程。&lt;/p&gt;&#xD;
&lt;p&gt;&lt;strong&gt;在真实数据上，一屏一屏地看。&lt;/strong&gt;&lt;/p&gt;</description>
      <pubDate>Wed, 30 Sep 2026 09:35:10 GMT</pubDate>
    </item>
    <item>
      <title>SpringBoot优雅停机实战</title>
      <link>https://www.hqxiaozou.top/post/YoXXU75atze</link>
      <description>&lt;h2 id="-502-"&gt;一、502不是代码抛出来的，是交接时漏掉的&lt;/h2&gt;&#xD;
&lt;p&gt;假设一个 Spring Boot 服务每次发版，监控上都会出现一小段尖刺：&lt;/p&gt;&#xD;
&lt;pre&gt;&lt;code&gt;upstream prematurely closed connection &lt;span class="hljs-keyword"&gt;while&lt;/span&gt; reading response header &lt;span class="hljs-keyword"&gt;from&lt;/span&gt; upstream&#xD;
connect() failed (&lt;span class="hljs-number"&gt;111&lt;/span&gt;: Connection refused) &lt;span class="hljs-keyword"&gt;while&lt;/span&gt; connecting to upstream&#xD;
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;网关返回 502，部分客户端报 &lt;code&gt;Connection reset by peer&lt;/code&gt;。&lt;/p&gt;&#xD;
&lt;p&gt;但应用日志里没有异常，发版结束之后一切正常，也没有任何接口能稳定复现。&lt;/p&gt;&#xD;
&lt;p&gt;很多人的第一反应是在配置里加上一行：&lt;/p&gt;&#xD;
&lt;pre&gt;&lt;code class="lang-yaml"&gt;&lt;span class="hljs-section"&gt;server:&lt;/span&gt;&#xD;
  shutdown: graceful&#xD;
&lt;/code&gt;&lt;/pre&gt;&#xD;
&lt;p&gt;然后下一次发版，502 依然存在。&lt;/p&gt;&#xD;
&lt;p&gt;原因在于，发版期间的错误并不只来自“正在处理的请求被中断”。一次停机至少要回答三个问题：&lt;/p&gt;&#xD;
&lt;p&gt;&lt;strong&gt;旧实例从什么时候开始不再收到新请求？已经进来的请求能不能做完？进程到底有没有收到“该停了”的信号？&lt;/strong&gt;&lt;/p&gt;&#xD;
&lt;p&gt;优雅停机只负责回答其中一个。&lt;/p&gt;&#xD;
&lt;h3 id="1-"&gt;1. 一次停机其实是一条时间线&lt;/h3&gt;&#xD;
&lt;p&gt;从外部看，“停掉一个实例”像一个动作；从内部看，它是一串有先后顺序的步骤：&lt;/p&gt;&#xD;
&lt;pre&gt;&lt;code class="lang-mermaid"&gt;&lt;span class="hljs-attribute"&gt;graph&lt;/span&gt; LR&#xD;
    A[&lt;span class="hljs-string"&gt;"发起停止"&lt;/span&gt;] --&amp;gt; B[&lt;span class="hljs-string"&gt;"摘除流量"&lt;/span&gt;]&#xD;
    A --&amp;gt; C[&lt;span class="hljs-string"&gt;"收到 SIGTERM"&lt;/span&gt;]&#xD;
    C --&amp;gt; D[&lt;span class="hljs-string"&gt;"拒绝新连接"&lt;/span&gt;]&#xD;
    D --&amp;gt; E[&lt;span class="hljs-string"&gt;"等待请求完成"&lt;/span&gt;]&#xD;
    E --&amp;gt; G[&lt;span class="hljs-string"&gt;"进程退出"&lt;/span&gt;]&#xD;
    E -.-&amp;gt;|&lt;span class="hljs-string"&gt;"超过宽限期"&lt;/span&gt;| H[&lt;span class="hljs-string"&gt;"SIGKILL"&lt;/span&gt;]&#xD;
    B -.-&amp;gt;|&lt;span class="hljs-string"&gt;"晚于拒绝新连接"&lt;/span&gt;| X[&lt;span class="hljs-string"&gt;"新请求 502"&lt;/span&gt;]&#xD;
&lt;/code&gt;&lt;/pre&gt;&#xD;
&lt;p&gt;注意“摘除流量”和“收到 SIGTERM”在很多平台上是&lt;strong&gt;并行&lt;/strong&gt;发生的。只要摘除流量这一步比应用关闭端口慢，哪怕应用本身停得再“优雅”，中间这段时间到达的请求也会失败。&lt;/p&gt;&#xD;
&lt;h3 id="2-"&gt;2. 发版期间的错误，可以分成四类窗口&lt;/h3&gt;&#xD;
&lt;table&gt;&#xD;
&lt;thead&gt;&#xD;
&lt;tr&gt;&#xD;
&lt;th style="text-align:center"&gt;窗口&lt;/th&gt;&#xD;
&lt;th style="text-align:center"&gt;典型现象&lt;/th&gt;&#xD;
&lt;th style="text-align:center"&gt;修复方向&lt;/th&gt;&#xD;
&lt;/tr&gt;&#xD;
&lt;/thead&gt;&#xD;
&lt;tbody&gt;&#xD;
&lt;tr&gt;&#xD;
&lt;td style="text-align:center"&gt;摘流量晚于关闭端口&lt;/td&gt;&#xD;
&lt;td style="text-align:center"&gt;502、连接被拒绝或重置&lt;/td&gt;&#xD;
&lt;td style="text-align:center"&gt;先摘流量，再发 SIGTERM&lt;/td&gt;&#xD;
&lt;/tr&gt;&#xD;
&lt;tr&gt;&#xD;
&lt;td style="text-align:center"&gt;请求处理到一半被中断&lt;/td&gt;&#xD;
&lt;td style="text-align:center"&gt;502、upstream 提前关闭连接&lt;/td&gt;&#xD;
&lt;td style="text-align:center"&gt;优雅停机，并对齐超时&lt;/td&gt;&#xD;
&lt;/tr&gt;&#xD;
&lt;tr&gt;&#xD;
&lt;td style="text-align:center"&gt;SIGTERM 没送到 JVM&lt;/td&gt;&#xD;
&lt;td style="text-align:center"&gt;超时后被强杀，日志无记录&lt;/td&gt;&#xD;
&lt;td style="text-align:center"&gt;启动命令使用 exec&lt;/td&gt;&#xD;
&lt;/tr&gt;&#xD;
&lt;tr&gt;&#xD;
&lt;td style="text-align:center"&gt;长连接迟迟不结束&lt;/td&gt;&#xD;
&lt;td style="text-align:center"&gt;每次停机都拖满超时时间&lt;/td&gt;&#xD;
&lt;td style="text-align:center"&gt;关闭时主动结束长连接&lt;/td&gt;&#xD;
&lt;/tr&gt;&#xD;
&lt;/tbody&gt;&#xD;
&lt;/table&gt;&#xD;
&lt;p&gt;排查时不要把它们混成一个“502 问题”。每一类窗口，对应的证据和修复位置都不同。&lt;/p&gt;&#xD;
&lt;h2 id="-sigterm-"&gt;二、先做实验：同样一次 SIGTERM，差别在哪里&lt;/h2&gt;&#xD;
&lt;p&gt;为了不凭印象下结论，下面用一个最小的 Spring Boot 应用做对照实验。&lt;/p&gt;&#xD;
&lt;p&gt;环境为 &lt;strong&gt;Spring Boot 2.7.18（内嵌 Tomcat 9.0.121）+ JDK 8&lt;/strong&gt;，本机运行。应用只有几个接口：&lt;/p&gt;&#xD;
&lt;ul&gt;&#xD;
&lt;li&gt;&lt;code&gt;/slow?ms=5000&lt;/code&gt;：睡眠指定毫秒后返回，模拟一个处理中的请求；&lt;/li&gt;&#xD;
&lt;li&gt;&lt;code&gt;/fast&lt;/code&gt;：立即返回；&lt;/li&gt;&#xD;
&lt;li&gt;&lt;code&gt;/ready&lt;/code&gt;：返回 Spring Boot 当前的就绪状态；&lt;/li&gt;&#xD;
&lt;li&gt;&lt;code&gt;/sse&lt;/code&gt;：一个不会自然结束的 SSE 连接。&lt;/li&gt;&#xD;
&lt;/ul&gt;&#xD;
&lt;p&gt;实验步骤统一为：先发起 &lt;code&gt;/slow&lt;/code&gt; 请求，1 秒后向 JVM 发送 &lt;code&gt;SIGTERM&lt;/code&gt;，再在 0.5 秒后发起新的请求。所有时间都以&lt;strong&gt;收到 SIGTERM 的时刻为 0&lt;/strong&gt;。&lt;/p&gt;&#xD;
&lt;h3 id="1-immediate-graceful-"&gt;1. immediate 与 graceful 的对照&lt;/h3&gt;&#xD;
&lt;table&gt;&#xD;
&lt;thead&gt;&#xD;
&lt;tr&gt;&#xD;
&lt;th style="text-align:center"&gt;观察项&lt;/th&gt;&#xD;
&lt;th style="text-align:center"&gt;&lt;code&gt;shutdown: immediate&lt;/code&gt;&lt;/th&gt;&#xD;
&lt;th style="text-align:center"&gt;&lt;code&gt;shutdown: graceful&lt;/code&gt;&lt;/th&gt;&#xD;
&lt;/tr&gt;&#xD;
&lt;/thead&gt;&#xD;
&lt;tbody&gt;&#xD;
&lt;tr&gt;&#xD;
&lt;td style="text-align:center"&gt;进行中的请求（还剩约 4 秒）&lt;/td&gt;&#xD;
&lt;td style="text-align:center"&gt;约 2.7 秒被断开，无响应&lt;/td&gt;&#xD;
&lt;td style="text-align:center"&gt;4.0 秒正常返回 200&lt;/td&gt;&#xD;
&lt;/tr&gt;&#xD;
&lt;tr&gt;&#xD;
&lt;td style="text-align:center"&gt;SIGTERM 后 0.5 秒的新连接&lt;/td&gt;&#xD;
&lt;td style="text-align:center"&gt;连接被重置&lt;/td&gt;&#xD;
&lt;td style="text-align:center"&gt;连接被重置&lt;/td&gt;&#xD;
&lt;/tr&gt;&#xD;
&lt;tr&gt;&#xD;
&lt;td style="text-align:center"&gt;SIGTERM 后 0.7 秒请求 &lt;code&gt;/ready&lt;/code&gt;&lt;/td&gt;&#xD;
&lt;td style="text-align:center"&gt;连接被拒绝&lt;/td&gt;&#xD;
&lt;td style="text-align:center"&gt;连接被拒绝&lt;/td&gt;&#xD;
&lt;/tr&gt;&#xD;
&lt;tr&gt;&#xD;
&lt;td style="text-align:center"&gt;复用空闲的 keep-alive 连接&lt;/td&gt;&#xD;
&lt;td style="text-align:center"&gt;—&lt;/td&gt;&#xD;
&lt;td style="text-align:center"&gt;0.6 秒时被重置&lt;/td&gt;&#xD;
&lt;/tr&gt;&#xD;
&lt;tr&gt;&#xD;
&lt;td style="text-align:center"&gt;进程退出&lt;/td&gt;&#xD;
&lt;td style="text-align:center"&gt;约 2.8～2.9 秒&lt;/td&gt;&#xD;
&lt;td style="text-align:center"&gt;约 4.1 秒&lt;/td&gt;&#xD;
&lt;/tr&gt;&#xD;
&lt;/tbody&gt;&#xD;
&lt;/table&gt;&#xD;
&lt;p&gt;把请求时长加到 20 秒再测一次：&lt;code&gt;graceful&lt;/code&gt; 模式下，这个请求在 19.0 秒时完整返回 200，进程随后退出；&lt;code&gt;immediate&lt;/code&gt; 模式下，请求依然在 2.7 秒左右被断开。&lt;/p&gt;&#xD;
&lt;p&gt;从这组结果可以得到一个很重要的结论：&lt;/p&gt;&#xD;
&lt;p&gt;&lt;strong&gt;优雅停机保护的是“已经进入应用的请求”，不保护“SIGTERM 之后才到达的请求”。&lt;/strong&gt;&lt;/p&gt;&#xD;
&lt;p&gt;实验中，Tomcat 进入优雅停机后立即停止接收新连接，已经建立但处于空闲状态的 keep-alive 连接，在下一次复用时也被重置。Spring Boot 官方文档说明，Jetty、Reactor Netty 与 Tomcat 都在网络层停止接收新请求；Undertow 则会继续接收连接，但立即返回 503。无论哪一种，&lt;strong&gt;新请求都拿不到正常结果&lt;/strong&gt;。&lt;/p&gt;&#xD;
&lt;p&gt;所以，只开优雅停机而不先摘流量，502 窗口仍然存在，只是从“处理中的请求失败”变成了“新请求失败”。&lt;/p&gt;&#xD;
&lt;h3 id="2-"&gt;2. 就绪状态变了，但探针读不到&lt;/h3&gt;&#xD;
&lt;p&gt;应用日志里能看到，收到 SIGTERM 后几乎同一毫秒，Spring Boot 就发布了就绪状态变化：&lt;/p&gt;&#xD;
&lt;pre&gt;&lt;code&gt;availability -&amp;gt; REFUSING_TRAFFIC&#xD;
ContextClosedEvent&#xD;
Commencing graceful shutdown. Waiting &lt;span class="hljs-keyword"&gt;for&lt;/span&gt; active requests to complete&#xD;
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;但实验中 0.7 秒时请求 &lt;code&gt;/ready&lt;/code&gt;，得到的是连接被拒绝，而不是一个 &lt;code&gt;REFUSING_TRAFFIC&lt;/code&gt; 的响应——端口已经不再接收新连接了。&lt;/p&gt;&#xD;
&lt;p&gt;这说明：&lt;/p&gt;&#xD;
&lt;blockquote&gt;&#xD;
&lt;p&gt;停机时的就绪状态变化，更多是给应用内部和同进程的组件看的；指望外部探针在 SIGTERM 之后“读到拒绝”再去摘流量，时间上来不及。&lt;/p&gt;&#xD;
&lt;/blockquote&gt;&#xD;
&lt;p&gt;摘流量必须发生在应用关闭端口&lt;strong&gt;之前&lt;/strong&gt;，而不是依赖应用在关闭过程中“通知”外部。&lt;/p&gt;&#xD;
&lt;h2 id="-sigterm-jvm"&gt;三、第一步：确认 SIGTERM 真的送到了 JVM&lt;/h2&gt;&#xD;
&lt;h3 id="1-exec"&gt;1. 启动脚本少了一个 exec&lt;/h3&gt;&#xD;
&lt;p&gt;很多服务用脚本启动，比如：&lt;/p&gt;&#xD;
&lt;pre&gt;&lt;code class="lang-bash"&gt;&lt;span class="hljs-meta"&gt;#!/bin/bash&lt;/span&gt;&#xD;
JAVA_OPTS=&lt;span class="hljs-string"&gt;"-Xms512m -Xmx512m"&lt;/span&gt;&#xD;
java &lt;span class="hljs-variable"&gt;$JAVA_OPTS&lt;/span&gt; -jar /opt/app/app.jar&#xD;
&lt;/code&gt;&lt;/pre&gt;&#xD;
&lt;p&gt;这时 shell 是父进程，JVM 是它的子进程。&lt;/p&gt;&#xD;
&lt;p&gt;在实验中，把同一个应用改成用这样的脚本启动，然后只向&lt;strong&gt;脚本进程&lt;/strong&gt;发送 &lt;code&gt;SIGTERM&lt;/code&gt;：&lt;/p&gt;&#xD;
&lt;ul&gt;&#xD;
&lt;li&gt;脚本进程立即以退出码 &lt;code&gt;-15&lt;/code&gt; 结束；&lt;/li&gt;&#xD;
&lt;li&gt;JVM 完全没有收到信号，进行中的请求照常返回；&lt;/li&gt;&#xD;
&lt;li&gt;40 秒之后，JVM 依然在运行，成了一个没人管理的孤儿进程。&lt;/li&gt;&#xD;
&lt;/ul&gt;&#xD;
&lt;p&gt;在容器里，这个脚本通常就是 PID 1。停止容器时，信号发给了脚本，脚本没有转发给 JVM，于是 JVM 一直等到宽限期结束被 &lt;code&gt;SIGKILL&lt;/code&gt;——所有正在处理的请求一起中断，而且应用日志里&lt;strong&gt;不会留下任何关闭记录&lt;/strong&gt;。&lt;/p&gt;&#xD;
&lt;p&gt;修复方式是让脚本最后一步用 &lt;code&gt;exec&lt;/code&gt; 替换自身：&lt;/p&gt;&#xD;
&lt;pre&gt;&lt;code class="lang-bash"&gt;&lt;span class="hljs-meta"&gt;#!/bin/bash&lt;/span&gt;&#xD;
&lt;span class="hljs-built_in"&gt;set&lt;/span&gt; -euo pipefail&#xD;
&#xD;
JAVA_OPTS=&lt;span class="hljs-string"&gt;"&lt;span class="hljs-variable"&gt;${JAVA_OPTS:--Xms512m -Xmx512m}&lt;/span&gt;"&lt;/span&gt;&#xD;
&#xD;
&lt;span class="hljs-comment"&gt;# exec 之后，JVM 接管当前进程号，停止信号会直接送到 JVM&lt;/span&gt;&#xD;
&lt;span class="hljs-built_in"&gt;exec&lt;/span&gt; java &lt;span class="hljs-variable"&gt;$JAVA_OPTS&lt;/span&gt; -jar /opt/app/app.jar&#xD;
&lt;/code&gt;&lt;/pre&gt;&#xD;
&lt;h3 id="2-exec-entrypoint"&gt;2. 容器：使用 exec 形式的 ENTRYPOINT&lt;/h3&gt;&#xD;
&lt;p&gt;Dockerfile 里的 shell 形式：&lt;/p&gt;&#xD;
&lt;pre&gt;&lt;code class="lang-dockerfile"&gt;&lt;span class="hljs-attribute"&gt;ENTRYPOINT&lt;/span&gt; java -jar /opt/app/app.jar&#xD;
&lt;/code&gt;&lt;/pre&gt;&#xD;
&lt;p&gt;实际执行的是 &lt;code&gt;/bin/sh -c &amp;quot;java -jar ...&amp;quot;&lt;/code&gt;，与上面的包装脚本是同一个问题。应改为 exec 形式：&lt;/p&gt;&#xD;
&lt;pre&gt;&lt;code class="lang-dockerfile"&gt;&lt;span class="hljs-attribute"&gt;ENTRYPOINT&lt;/span&gt; [&lt;span class="hljs-string"&gt;"java"&lt;/span&gt;, &lt;span class="hljs-string"&gt;"-jar"&lt;/span&gt;, &lt;span class="hljs-string"&gt;"/opt/app/app.jar"&lt;/span&gt;]&#xD;
&lt;/code&gt;&lt;/pre&gt;&#xD;
&lt;p&gt;如果确实需要启动脚本，就在脚本最后使用 &lt;code&gt;exec&lt;/code&gt;，并用 exec 形式调用这个脚本。&lt;/p&gt;&#xD;
&lt;p&gt;另外要记住默认宽限期：&lt;code&gt;docker stop&lt;/code&gt; 默认只等 10 秒，就会发送 &lt;code&gt;SIGKILL&lt;/code&gt;；Kubernetes 的 &lt;code&gt;terminationGracePeriodSeconds&lt;/code&gt; 默认是 30 秒。这两个数字，都要和后面计算的停机预算对齐。&lt;/p&gt;&#xD;
&lt;h3 id="3-systemd-143-"&gt;3. systemd：确认超时，并把 143 视为正常退出&lt;/h3&gt;&#xD;
&lt;p&gt;systemd 默认使用 &lt;code&gt;KillMode=control-group&lt;/code&gt;，停止服务时会向服务所在 cgroup 里的所有进程发送 &lt;code&gt;SIGTERM&lt;/code&gt;，所以包装脚本的问题在 systemd 下没有容器里那么致命。但仍建议使用 &lt;code&gt;exec&lt;/code&gt;，让 &lt;code&gt;MainPID&lt;/code&gt; 就是 JVM。&lt;/p&gt;&#xD;
&lt;p&gt;一个典型的 unit：&lt;/p&gt;&#xD;
&lt;pre&gt;&lt;code class="lang-ini"&gt;&lt;span class="hljs-section"&gt;[Service]&lt;/span&gt;&#xD;
&lt;span class="hljs-attr"&gt;ExecStart&lt;/span&gt;=/opt/app/startup.sh&#xD;
&lt;span class="hljs-comment"&gt;# 停机总预算：摘流量等待 + 应用停机超时 + 余量&lt;/span&gt;&#xD;
&lt;span class="hljs-attr"&gt;TimeoutStopSec&lt;/span&gt;=&lt;span class="hljs-number"&gt;60&lt;/span&gt;&#xD;
&lt;span class="hljs-comment"&gt;# JVM 收到 SIGTERM 正常关闭后，退出码为 128 + 15 = 143&lt;/span&gt;&#xD;
&lt;span class="hljs-attr"&gt;SuccessExitStatus&lt;/span&gt;=&lt;span class="hljs-number"&gt;143&lt;/span&gt;&#xD;
&lt;/code&gt;&lt;/pre&gt;&#xD;
&lt;p&gt;实验中 JVM 在 &lt;code&gt;SIGTERM&lt;/code&gt; 后完成优雅停机，退出码确实是 &lt;code&gt;143&lt;/code&gt;。如果不声明 &lt;code&gt;SuccessExitStatus=143&lt;/code&gt;，每次正常停止，systemd 都会把服务标记为 failed，干扰告警判断。&lt;/p&gt;&#xD;
&lt;p&gt;验证方式不是看配置文件，而是看实际状态：&lt;/p&gt;&#xD;
&lt;pre&gt;&lt;code class="lang-bash"&gt;systemctl show myapp.service -p MainPID -p KillMode -p TimeoutStopUSec&#xD;
&#xD;
&lt;span class="hljs-comment"&gt;# MainPID 应该就是 java 进程&lt;/span&gt;&#xD;
ps -o pid,ppid,comm -p &lt;span class="hljs-string"&gt;"&lt;span class="hljs-variable"&gt;$(systemctl show -p MainPID --value myapp.service)&lt;/span&gt;"&lt;/span&gt;&#xD;
&lt;/code&gt;&lt;/pre&gt;&#xD;
&lt;h2 id="-"&gt;四、第二步：开启优雅停机，并算清楚超时&lt;/h2&gt;&#xD;
&lt;h3 id="1-"&gt;1. 配置本身很简单&lt;/h3&gt;&#xD;
&lt;pre&gt;&lt;code class="lang-yaml"&gt;&lt;span class="hljs-section"&gt;server:&lt;/span&gt;&#xD;
  shutdown: graceful&#xD;
&#xD;
&lt;span class="hljs-section"&gt;spring:&lt;/span&gt;&#xD;
  lifecycle:&#xD;
    &lt;span class="hljs-comment"&gt;# 每个关闭阶段最多等待多久，默认 30 秒&lt;/span&gt;&#xD;
    timeout-per-shutdown-phase: 30s&#xD;
&lt;/code&gt;&lt;/pre&gt;&#xD;
&lt;p&gt;&lt;code&gt;server.shutdown=graceful&lt;/code&gt; 从 Spring Boot 2.3 开始提供；Spring Boot 3.4 起，内嵌 Web 服务器默认就启用优雅停机。即便使用的是新版本，也建议在配置中显式写出来，让停机行为不依赖默认值。&lt;/p&gt;&#xD;
&lt;h3 id="2-"&gt;2. 超时不是“到点砍断”&lt;/h3&gt;&#xD;
&lt;p&gt;&lt;code&gt;timeout-per-shutdown-phase&lt;/code&gt; 很容易被理解成“请求最多再处理 30 秒”。实验结果并非如此。&lt;/p&gt;&#xD;
&lt;p&gt;把它设为 &lt;code&gt;2s&lt;/code&gt;：&lt;/p&gt;&#xD;
&lt;ul&gt;&#xD;
&lt;li&gt;还剩约 4 秒的请求：Spring 在 2 秒时打印 &lt;code&gt;Graceful shutdown aborted with one or more requests still active&lt;/code&gt;，但这个请求&lt;strong&gt;仍然在 4.0 秒时正常返回了 200&lt;/strong&gt;；&lt;/li&gt;&#xD;
&lt;li&gt;还剩约 19 秒的请求：同样在 2 秒时放弃等待，随后 Tomcat 停止容器时又等待了一小段时间，请求在 4.2 秒时被断开。&lt;/li&gt;&#xD;
&lt;/ul&gt;&#xD;
&lt;p&gt;也就是说，这个配置控制的是 &lt;strong&gt;Spring 生命周期为这一阶段等待多久&lt;/strong&gt;，放弃等待之后，Web 服务器自身的停止流程还会有额外的时间。它不是请求处理时间的硬上限。&lt;/p&gt;&#xD;
&lt;p&gt;真正的硬上限来自外部：Kubernetes 的 &lt;code&gt;terminationGracePeriodSeconds&lt;/code&gt;、systemd 的 &lt;code&gt;TimeoutStopSec&lt;/code&gt;、&lt;code&gt;docker stop -t&lt;/code&gt;。时间一到，就是 &lt;code&gt;SIGKILL&lt;/code&gt;。&lt;/p&gt;&#xD;
&lt;h3 id="3-"&gt;3. 用预算公式对齐所有超时&lt;/h3&gt;&#xD;
&lt;p&gt;可以按下面的关系设置：&lt;/p&gt;&#xD;
&lt;p&gt;&lt;strong&gt;外部宽限期 ≥ 摘流量等待 + 应用停机超时 + 其余关闭步骤 + 余量&lt;/strong&gt;&lt;/p&gt;&#xD;
&lt;p&gt;例如：摘流量等待 10 秒，Spring 停机超时 30 秒，线程池与连接关闭预留 5 秒，那么 Kubernetes 的 &lt;code&gt;terminationGracePeriodSeconds&lt;/code&gt; 不应少于 45 秒。&lt;/p&gt;&#xD;
&lt;p&gt;反过来也成立：如果业务上最长的正常请求需要 60 秒，只把 Spring 超时改成 60 秒而不改外部宽限期，请求依然会在 30 秒时被 &lt;code&gt;SIGKILL&lt;/code&gt; 截断。&lt;/p&gt;&#xD;
&lt;p&gt;&lt;strong&gt;超时要成组调整，不能只改其中一个。&lt;/strong&gt;&lt;/p&gt;&#xD;
&lt;h2 id="-"&gt;五、第三步：先摘流量，再停进程&lt;/h2&gt;&#xD;
&lt;h3 id="1-kubernetes-prestop-"&gt;1. Kubernetes：用 preStop 等待端点摘除生效&lt;/h3&gt;&#xD;
&lt;p&gt;删除 Pod 时，kubelet 会先执行 &lt;code&gt;preStop&lt;/code&gt;，再向容器发送 &lt;code&gt;SIGTERM&lt;/code&gt;。与此同时，Pod 被标记为 Terminating，从 Service 的可用端点中移除。&lt;/p&gt;&#xD;
&lt;p&gt;问题在于，端点的变化需要时间才能传播到 kube-proxy、Ingress 控制器和外部负载均衡。如果应用一收到 &lt;code&gt;SIGTERM&lt;/code&gt; 就关闭端口，而网关还没来得及更新，就会出现前面实验里“新连接被拒绝”的窗口。&lt;/p&gt;&#xD;
&lt;p&gt;Spring Boot 官方文档给出的做法，是在 &lt;code&gt;preStop&lt;/code&gt; 中短暂等待，让摘除先生效：&lt;/p&gt;&#xD;
&lt;pre&gt;&lt;code class="lang-yaml"&gt;spec:&#xD;
  terminationGracePeriodSeconds: 45&#xD;
  containers:&#xD;
    - name: app&#xD;
      image: registry.example.com/app:1.0.0&#xD;
      lifecycle:&#xD;
        preStop:&#xD;
          &lt;span class="hljs-built_in"&gt;exec&lt;/span&gt;:&#xD;
            &lt;span class="hljs-built_in"&gt;command&lt;/span&gt;: [&lt;span class="hljs-string"&gt;"sh"&lt;/span&gt;, &lt;span class="hljs-string"&gt;"-c"&lt;/span&gt;, &lt;span class="hljs-string"&gt;"sleep 10"&lt;/span&gt;]&#xD;
      readinessProbe:&#xD;
        httpGet:&#xD;
          path: /actuator/health/readiness&#xD;
          port: 8080&#xD;
        periodSeconds: 5&#xD;
&lt;/code&gt;&lt;/pre&gt;&#xD;
&lt;p&gt;几点需要注意：&lt;/p&gt;&#xD;
&lt;ul&gt;&#xD;
&lt;li&gt;&lt;code&gt;terminationGracePeriodSeconds&lt;/code&gt; 包含了 &lt;code&gt;preStop&lt;/code&gt; 的执行时间，所以要按上一节的公式加上这 10 秒；&lt;/li&gt;&#xD;
&lt;li&gt;&lt;code&gt;exec&lt;/code&gt; 方式要求镜像中存在 &lt;code&gt;sh&lt;/code&gt; 和 &lt;code&gt;sleep&lt;/code&gt;，精简镜像可能没有，较新的 Kubernetes 版本也提供了原生的 sleep 动作，可按集群版本选择；&lt;/li&gt;&#xD;
&lt;li&gt;10 秒只是示例，应根据实际网关的更新延迟确定。&lt;/li&gt;&#xD;
&lt;/ul&gt;&#xD;
&lt;h3 id="2-nginx-"&gt;2. Nginx + 多实例：重试能兜住什么&lt;/h3&gt;&#xD;
&lt;p&gt;对于 Nginx 反向代理多个实例的部署，很多人会依赖 &lt;code&gt;proxy_next_upstream&lt;/code&gt;：一个实例失败了，自动换下一个。&lt;/p&gt;&#xD;
&lt;p&gt;为了确认它的边界，在一台服务器上用独立的 Nginx 1.20.1 实例和两个后端做了验证，配置为：&lt;/p&gt;&#xD;
&lt;pre&gt;&lt;code class="lang-nginx"&gt;&lt;span class="hljs-attribute"&gt;upstream&lt;/span&gt; app {&#xD;
    &lt;span class="hljs-attribute"&gt;server&lt;/span&gt; &lt;span class="hljs-number"&gt;127.0.0.1:18481&lt;/span&gt; max_fails=&lt;span class="hljs-number"&gt;0&lt;/span&gt;;&#xD;
    &lt;span class="hljs-attribute"&gt;server&lt;/span&gt; &lt;span class="hljs-number"&gt;127.0.0.1:18482&lt;/span&gt; max_fails=&lt;span class="hljs-number"&gt;0&lt;/span&gt;;&#xD;
}&#xD;
&#xD;
&lt;span class="hljs-section"&gt;server&lt;/span&gt; {&#xD;
    &lt;span class="hljs-attribute"&gt;listen&lt;/span&gt; &lt;span class="hljs-number"&gt;127.0.0.1:18480&lt;/span&gt;;&#xD;
&#xD;
    &lt;span class="hljs-attribute"&gt;location&lt;/span&gt; / {&#xD;
        &lt;span class="hljs-attribute"&gt;proxy_pass&lt;/span&gt; http://app;&#xD;
        &lt;span class="hljs-attribute"&gt;proxy_next_upstream&lt;/span&gt; &lt;span class="hljs-literal"&gt;error&lt;/span&gt; timeout;&#xD;
        &lt;span class="hljs-attribute"&gt;proxy_connect_timeout&lt;/span&gt; &lt;span class="hljs-number"&gt;1s&lt;/span&gt;;&#xD;
    }&#xD;
}&#xD;
&lt;/code&gt;&lt;/pre&gt;&#xD;
&lt;p&gt;每种情况各发 10 次请求：&lt;/p&gt;&#xD;
&lt;table&gt;&#xD;
&lt;thead&gt;&#xD;
&lt;tr&gt;&#xD;
&lt;th style="text-align:center"&gt;后端 A 的状态&lt;/th&gt;&#xD;
&lt;th style="text-align:center"&gt;GET&lt;/th&gt;&#xD;
&lt;th style="text-align:center"&gt;POST&lt;/th&gt;&#xD;
&lt;/tr&gt;&#xD;
&lt;/thead&gt;&#xD;
&lt;tbody&gt;&#xD;
&lt;tr&gt;&#xD;
&lt;td style="text-align:center"&gt;端口已关闭（连接被拒绝）&lt;/td&gt;&#xD;
&lt;td style="text-align:center"&gt;10 次全部 200&lt;/td&gt;&#xD;
&lt;td style="text-align:center"&gt;10 次全部 200&lt;/td&gt;&#xD;
&lt;/tr&gt;&#xD;
&lt;tr&gt;&#xD;
&lt;td style="text-align:center"&gt;收到完整请求后，不回响应直接断开&lt;/td&gt;&#xD;
&lt;td style="text-align:center"&gt;10 次全部 200&lt;/td&gt;&#xD;
&lt;td style="text-align:center"&gt;502 与 200 交替出现，5 次失败&lt;/td&gt;&#xD;
&lt;/tr&gt;&#xD;
&lt;/tbody&gt;&#xD;
&lt;/table&gt;&#xD;
&lt;p&gt;原因在于：连接被拒绝时，请求还没有发出去，Nginx 可以安全地换一个实例；而请求已经发给后端之后连接断开，对于 POST 这类非幂等方法，Nginx 默认&lt;strong&gt;不会&lt;/strong&gt;重试——它无法判断后端是否已经执行了一半。这正是 &lt;code&gt;proxy_next_upstream&lt;/code&gt; 的 &lt;code&gt;non_idempotent&lt;/code&gt; 参数默认关闭的原因。&lt;/p&gt;&#xD;
&lt;p&gt;所以，重试只能兜住“还没发出去”的请求。滚动发布时，更稳妥的顺序是：&lt;/p&gt;&#xD;
&lt;ol&gt;&#xD;
&lt;li&gt;把即将停止的实例从 &lt;code&gt;upstream&lt;/code&gt; 中摘除，执行 &lt;code&gt;nginx -s reload&lt;/code&gt;（旧 worker 会处理完已有连接后退出）；&lt;/li&gt;&#xD;
&lt;li&gt;等待已有请求处理完毕；&lt;/li&gt;&#xD;
&lt;li&gt;停止实例，启动新版本；&lt;/li&gt;&#xD;
&lt;li&gt;确认新实例健康后，再加回 &lt;code&gt;upstream&lt;/code&gt;。&lt;/li&gt;&#xD;
&lt;/ol&gt;&#xD;
&lt;p&gt;&lt;strong&gt;不要让“重试”承担“摘流量”的职责，尤其是写接口。&lt;/strong&gt;&lt;/p&gt;&#xD;
&lt;h3 id="3-"&gt;3. 单实例：没有无损，只能让失败变得可理解&lt;/h3&gt;&#xD;
&lt;p&gt;只有一个实例时，重启期间一定存在不可用窗口，任何配置都不能消除它。&lt;/p&gt;&#xD;
&lt;p&gt;这时能做的是：缩短窗口（加快启动、减少启动时的同步初始化），并让失败“体面”一些——例如在 Nginx 中把重启期间的 502 替换为一个说明页：&lt;/p&gt;&#xD;
&lt;pre&gt;&lt;code class="lang-nginx"&gt;&lt;span class="hljs-attribute"&gt;error_page&lt;/span&gt; &lt;span class="hljs-number"&gt;502&lt;/span&gt; = &lt;span class="hljs-variable"&gt;@restarting&lt;/span&gt;;&#xD;
&#xD;
&lt;span class="hljs-attribute"&gt;location&lt;/span&gt; &lt;span class="hljs-variable"&gt;@restarting&lt;/span&gt; {&#xD;
    &lt;span class="hljs-attribute"&gt;default_type&lt;/span&gt; &lt;span class="hljs-string"&gt;"text/plain; charset=utf-8"&lt;/span&gt;;&#xD;
    &lt;span class="hljs-attribute"&gt;add_header&lt;/span&gt; Retry-After &lt;span class="hljs-number"&gt;30&lt;/span&gt; always;&#xD;
    &lt;span class="hljs-attribute"&gt;return&lt;/span&gt; &lt;span class="hljs-number"&gt;503&lt;/span&gt; &lt;span class="hljs-string"&gt;"服务正在更新，请稍后刷新"&lt;/span&gt;;&#xD;
}&#xD;
&lt;/code&gt;&lt;/pre&gt;&#xD;
&lt;p&gt;&lt;code&gt;503 + Retry-After&lt;/code&gt; 比一个裸的 502 更准确地描述了状态，也方便客户端和爬虫决定何时重试。&lt;/p&gt;&#xD;
&lt;h2 id="-"&gt;六、第四步：别让长连接和后台任务拖住停机&lt;/h2&gt;&#xD;
&lt;h3 id="1-sse-"&gt;1. SSE 会让优雅停机等满超时&lt;/h3&gt;&#xD;
&lt;p&gt;优雅停机会等待“所有进行中的请求”完成，而 SSE、长轮询这类连接，本身就被设计成不会主动结束。&lt;/p&gt;&#xD;
&lt;p&gt;实验中，保持一个 SSE 连接后发送 &lt;code&gt;SIGTERM&lt;/code&gt;：&lt;/p&gt;&#xD;
&lt;ul&gt;&#xD;
&lt;li&gt;默认情况下，停机一直等满 30 秒超时，打印 &lt;code&gt;Graceful shutdown aborted&lt;/code&gt;，之后连接才被断开，进程在约 32 秒时退出；&lt;/li&gt;&#xD;
&lt;li&gt;在收到 &lt;code&gt;ContextClosedEvent&lt;/code&gt; 时主动结束所有 SSE 连接后，进程在 &lt;strong&gt;0.74 秒&lt;/strong&gt;内退出。&lt;/li&gt;&#xD;
&lt;/ul&gt;&#xD;
&lt;p&gt;前者看上去也“停下来了”，但每次发版都白白多等 30 秒；如果外部宽限期更短，还会直接变成 &lt;code&gt;SIGKILL&lt;/code&gt;。&lt;/p&gt;&#xD;
&lt;p&gt;可以把 SSE 连接集中登记，并在应用关闭时统一结束：&lt;/p&gt;&#xD;
&lt;pre&gt;&lt;code class="lang-java"&gt;&lt;span class="hljs-keyword"&gt;import&lt;/span&gt; org.springframework.context.event.ContextClosedEvent;&#xD;
&lt;span class="hljs-keyword"&gt;import&lt;/span&gt; org.springframework.context.event.EventListener;&#xD;
&lt;span class="hljs-keyword"&gt;import&lt;/span&gt; org.springframework.stereotype.Component;&#xD;
&lt;span class="hljs-keyword"&gt;import&lt;/span&gt; org.springframework.web.servlet.mvc.method.annotation.SseEmitter;&#xD;
&#xD;
&lt;span class="hljs-keyword"&gt;import&lt;/span&gt; java.util.Set;&#xD;
&lt;span class="hljs-keyword"&gt;import&lt;/span&gt; java.util.concurrent.ConcurrentHashMap;&#xD;
&#xD;
&lt;span class="hljs-meta"&gt;@Component&lt;/span&gt;&#xD;
&lt;span class="hljs-keyword"&gt;public&lt;/span&gt; &lt;span class="hljs-class"&gt;&lt;span class="hljs-keyword"&gt;class&lt;/span&gt; &lt;span class="hljs-title"&gt;SseRegistry&lt;/span&gt; &lt;/span&gt;{&#xD;
&#xD;
    &lt;span class="hljs-keyword"&gt;private&lt;/span&gt; &lt;span class="hljs-keyword"&gt;final&lt;/span&gt; Set&amp;lt;SseEmitter&amp;gt; emitters = ConcurrentHashMap.newKeySet();&#xD;
&#xD;
    &lt;span class="hljs-function"&gt;&lt;span class="hljs-keyword"&gt;public&lt;/span&gt; SseEmitter &lt;span class="hljs-title"&gt;open&lt;/span&gt;&lt;span class="hljs-params"&gt;()&lt;/span&gt; &lt;/span&gt;{&#xD;
        SseEmitter emitter = &lt;span class="hljs-keyword"&gt;new&lt;/span&gt; SseEmitter(&lt;span class="hljs-number"&gt;0L&lt;/span&gt;);&#xD;
        emitters.add(emitter);&#xD;
        emitter.onCompletion(() -&amp;gt; emitters.remove(emitter));&#xD;
        emitter.onTimeout(() -&amp;gt; emitters.remove(emitter));&#xD;
        emitter.onError(ex -&amp;gt; emitters.remove(emitter));&#xD;
        &lt;span class="hljs-keyword"&gt;return&lt;/span&gt; emitter;&#xD;
    }&#xD;
&#xD;
    &lt;span class="hljs-comment"&gt;/**&#xD;
     * 应用开始关闭时主动结束所有 SSE 连接，&#xD;
     * 否则优雅停机会一直等到超时。&#xD;
     */&lt;/span&gt;&#xD;
    &lt;span class="hljs-meta"&gt;@EventListener&lt;/span&gt;(ContextClosedEvent.class)&#xD;
    &lt;span class="hljs-function"&gt;&lt;span class="hljs-keyword"&gt;public&lt;/span&gt; &lt;span class="hljs-keyword"&gt;void&lt;/span&gt; &lt;span class="hljs-title"&gt;closeAll&lt;/span&gt;&lt;span class="hljs-params"&gt;()&lt;/span&gt; &lt;/span&gt;{&#xD;
        &lt;span class="hljs-keyword"&gt;for&lt;/span&gt; (SseEmitter emitter : emitters) {&#xD;
            emitter.complete();&#xD;
        }&#xD;
        emitters.clear();&#xD;
    }&#xD;
}&#xD;
&lt;/code&gt;&lt;/pre&gt;&#xD;
&lt;p&gt;控制器只需要调用 &lt;code&gt;sseRegistry.open()&lt;/code&gt; 返回连接。浏览器端的 &lt;code&gt;EventSource&lt;/code&gt; 在连接结束后会自动重连，届时请求会被负载均衡分配到新的实例上。&lt;/p&gt;&#xD;
&lt;p&gt;WebSocket 同理：在关闭阶段以明确的关闭码结束会话，由客户端负责重连，而不是让服务端被动等到超时。&lt;/p&gt;&#xD;
&lt;h3 id="2-"&gt;2. 线程池与异步任务&lt;/h3&gt;&#xD;
&lt;p&gt;Web 服务器的优雅停机只覆盖 HTTP 请求。由 &lt;code&gt;@Async&lt;/code&gt;、自定义线程池、消息消费者处理的任务，需要单独安排收尾。&lt;/p&gt;&#xD;
&lt;p&gt;以 Spring 的 &lt;code&gt;ThreadPoolTaskExecutor&lt;/code&gt; 为例：&lt;/p&gt;&#xD;
&lt;pre&gt;&lt;code class="lang-java"&gt;&lt;span class="hljs-meta"&gt;@Bean&lt;/span&gt;&#xD;
&lt;span class="hljs-function"&gt;&lt;span class="hljs-keyword"&gt;public&lt;/span&gt; ThreadPoolTaskExecutor &lt;span class="hljs-title"&gt;reportExecutor&lt;/span&gt;&lt;span class="hljs-params"&gt;()&lt;/span&gt; &lt;/span&gt;{&#xD;
    ThreadPoolTaskExecutor executor = &lt;span class="hljs-keyword"&gt;new&lt;/span&gt; ThreadPoolTaskExecutor();&#xD;
    executor.setCorePoolSize(&lt;span class="hljs-number"&gt;4&lt;/span&gt;);&#xD;
    executor.setMaxPoolSize(&lt;span class="hljs-number"&gt;8&lt;/span&gt;);&#xD;
    executor.setQueueCapacity(&lt;span class="hljs-number"&gt;200&lt;/span&gt;);&#xD;
    executor.setThreadNamePrefix(&lt;span class="hljs-string"&gt;"report-"&lt;/span&gt;);&#xD;
    &lt;span class="hljs-comment"&gt;// 关闭时等待已提交的任务执行完，而不是直接中断&lt;/span&gt;&#xD;
    executor.setWaitForTasksToCompleteOnShutdown(&lt;span class="hljs-keyword"&gt;true&lt;/span&gt;);&#xD;
    &lt;span class="hljs-comment"&gt;// 最多等待 20 秒，这段时间同样要计入外部宽限期&lt;/span&gt;&#xD;
    executor.setAwaitTerminationSeconds(&lt;span class="hljs-number"&gt;20&lt;/span&gt;);&#xD;
    &lt;span class="hljs-keyword"&gt;return&lt;/span&gt; executor;&#xD;
}&#xD;
&lt;/code&gt;&lt;/pre&gt;&#xD;
&lt;p&gt;需要注意两点：&lt;/p&gt;&#xD;
&lt;ul&gt;&#xD;
&lt;li&gt;等待时间会叠加到停机总时长上，必须纳入前面的预算公式；&lt;/li&gt;&#xD;
&lt;li&gt;如果任务来自消息队列，应先停止拉取新消息，再等待处理中的消息完成，否则关闭过程中还会不断有新任务进入。&lt;/li&gt;&#xD;
&lt;/ul&gt;&#xD;
&lt;h3 id="3-"&gt;3. 客户端要能处理连接被重置&lt;/h3&gt;&#xD;
&lt;p&gt;实验中，已经建立的 keep-alive 空闲连接在停机开始时被关闭，客户端复用时得到的是 &lt;code&gt;Connection reset&lt;/code&gt;。&lt;/p&gt;&#xD;
&lt;p&gt;即使服务端已经做好了摘流量和优雅停机，调用方的连接池里仍可能有指向旧实例的连接。对于幂等请求，调用方应当具备有限次数的重试；对于非幂等请求，则应依靠幂等键等机制保证重试安全，而不是简单地关掉重试。&lt;/p&gt;&#xD;
&lt;h2 id="-"&gt;七、验证：把“发版不报错”变成可重复的检查&lt;/h2&gt;&#xD;
&lt;p&gt;优雅停机很难靠阅读配置确认，最可靠的是在发版过程中持续打流量，统计失败。&lt;/p&gt;&#xD;
&lt;p&gt;下面的脚本持续请求指定地址，按秒输出成功数和各类失败数，适合在一次滚动发布期间运行：&lt;/p&gt;&#xD;
&lt;pre&gt;&lt;code class="lang-python"&gt;&lt;span class="hljs-keyword"&gt;import&lt;/span&gt; collections&#xD;
&lt;span class="hljs-keyword"&gt;import&lt;/span&gt; http.client&#xD;
&lt;span class="hljs-keyword"&gt;import&lt;/span&gt; sys&#xD;
&lt;span class="hljs-keyword"&gt;import&lt;/span&gt; time&#xD;
&lt;span class="hljs-keyword"&gt;import&lt;/span&gt; urllib.parse&#xD;
&#xD;
url = urllib.parse.urlparse(sys.argv[&lt;span class="hljs-number"&gt;1&lt;/span&gt;])&#xD;
duration = int(sys.argv[&lt;span class="hljs-number"&gt;2&lt;/span&gt;]) &lt;span class="hljs-keyword"&gt;if&lt;/span&gt; len(sys.argv) &amp;gt; &lt;span class="hljs-number"&gt;2&lt;/span&gt; &lt;span class="hljs-keyword"&gt;else&lt;/span&gt; &lt;span class="hljs-number"&gt;120&lt;/span&gt;&#xD;
&#xD;
end = time.time() + duration&#xD;
&lt;span class="hljs-keyword"&gt;while&lt;/span&gt; time.time() &amp;lt; end:&#xD;
    second = int(time.time())&#xD;
    stats = collections.Counter()&#xD;
    &lt;span class="hljs-keyword"&gt;while&lt;/span&gt; int(time.time()) == second:&#xD;
        &lt;span class="hljs-keyword"&gt;try&lt;/span&gt;:&#xD;
            conn = http.client.HTTPConnection(url.hostname, url.port &lt;span class="hljs-keyword"&gt;or&lt;/span&gt; &lt;span class="hljs-number"&gt;80&lt;/span&gt;, timeout=&lt;span class="hljs-number"&gt;5&lt;/span&gt;)&#xD;
            conn.request(&lt;span class="hljs-string"&gt;"GET"&lt;/span&gt;, url.path &lt;span class="hljs-keyword"&gt;or&lt;/span&gt; &lt;span class="hljs-string"&gt;"/"&lt;/span&gt;)&#xD;
            status = conn.getresponse().status&#xD;
            conn.close()&#xD;
            stats[&lt;span class="hljs-string"&gt;"ok"&lt;/span&gt; &lt;span class="hljs-keyword"&gt;if&lt;/span&gt; status &amp;lt; &lt;span class="hljs-number"&gt;500&lt;/span&gt; &lt;span class="hljs-keyword"&gt;else&lt;/span&gt; f&lt;span class="hljs-string"&gt;"http_{status}"&lt;/span&gt;] += &lt;span class="hljs-number"&gt;1&lt;/span&gt;&#xD;
        &lt;span class="hljs-keyword"&gt;except&lt;/span&gt; Exception &lt;span class="hljs-keyword"&gt;as&lt;/span&gt; exc:&#xD;
            stats[type(exc).__name__] += &lt;span class="hljs-number"&gt;1&lt;/span&gt;&#xD;
        &lt;span class="hljs-comment"&gt;# 限制请求频率，避免验证脚本本身给网关制造压力&lt;/span&gt;&#xD;
        time.sleep(&lt;span class="hljs-number"&gt;0.05&lt;/span&gt;)&#xD;
    print(time.strftime(&lt;span class="hljs-string"&gt;"%H:%M:%S"&lt;/span&gt;, time.localtime(second)), dict(stats), flush=&lt;span class="hljs-keyword"&gt;True&lt;/span&gt;)&#xD;
&lt;/code&gt;&lt;/pre&gt;&#xD;
&lt;p&gt;执行：&lt;/p&gt;&#xD;
&lt;pre&gt;&lt;code class="lang-bash"&gt;&lt;span class="hljs-attribute"&gt;python3&lt;/span&gt; deploy_probe.py http://gateway.example.com/api/ping &lt;span class="hljs-number"&gt;180&lt;/span&gt;&#xD;
&lt;/code&gt;&lt;/pre&gt;&#xD;
&lt;p&gt;然后在另一个终端触发发版。建议至少覆盖下面几类场景：&lt;/p&gt;&#xD;
&lt;table&gt;&#xD;
&lt;thead&gt;&#xD;
&lt;tr&gt;&#xD;
&lt;th style="text-align:center"&gt;验证场景&lt;/th&gt;&#xD;
&lt;th style="text-align:center"&gt;期望结果&lt;/th&gt;&#xD;
&lt;/tr&gt;&#xD;
&lt;/thead&gt;&#xD;
&lt;tbody&gt;&#xD;
&lt;tr&gt;&#xD;
&lt;td style="text-align:center"&gt;发版期间持续请求幂等接口&lt;/td&gt;&#xD;
&lt;td style="text-align:center"&gt;没有 502，没有连接被拒绝或重置&lt;/td&gt;&#xD;
&lt;/tr&gt;&#xD;
&lt;tr&gt;&#xD;
&lt;td style="text-align:center"&gt;发版开始时存在一个长耗时请求&lt;/td&gt;&#xD;
&lt;td style="text-align:center"&gt;请求完整返回，而不是被中途断开&lt;/td&gt;&#xD;
&lt;/tr&gt;&#xD;
&lt;tr&gt;&#xD;
&lt;td style="text-align:center"&gt;发版开始时存在 SSE 连接&lt;/td&gt;&#xD;
&lt;td style="text-align:center"&gt;连接被主动结束，进程在远小于超时的时间内退出&lt;/td&gt;&#xD;
&lt;/tr&gt;&#xD;
&lt;tr&gt;&#xD;
&lt;td style="text-align:center"&gt;查看旧进程的退出过程&lt;/td&gt;&#xD;
&lt;td style="text-align:center"&gt;日志中有完整的关闭记录，退出码为 143&lt;/td&gt;&#xD;
&lt;/tr&gt;&#xD;
&lt;tr&gt;&#xD;
&lt;td style="text-align:center"&gt;统计单个实例的停机耗时&lt;/td&gt;&#xD;
&lt;td style="text-align:center"&gt;明显小于外部宽限期，不触发 SIGKILL&lt;/td&gt;&#xD;
&lt;/tr&gt;&#xD;
&lt;/tbody&gt;&#xD;
&lt;/table&gt;&#xD;
&lt;p&gt;如果只看“发版之后服务正常”，是验证不出上面这些问题的。&lt;/p&gt;&#xD;
&lt;h2 id="-"&gt;八、总结：优雅停机是一条链，不是一个开关&lt;/h2&gt;&#xD;
&lt;p&gt;发版时的 502 很少来自某一行代码，而是来自停机链条上某个环节没有接上。&lt;/p&gt;&#xD;
&lt;p&gt;回到最开始的三个问题：&lt;/p&gt;&#xD;
&lt;p&gt;&lt;strong&gt;旧实例什么时候不再收到新请求？&lt;/strong&gt;——取决于摘流量是否早于应用关闭端口。优雅停机不会延迟端口关闭，实验中 SIGTERM 后 0.5 秒的新连接就已经失败。&lt;/p&gt;&#xD;
&lt;p&gt;&lt;strong&gt;已经进来的请求能不能做完？&lt;/strong&gt;——取决于 &lt;code&gt;server.shutdown=graceful&lt;/code&gt;，以及 Spring 超时、线程池等待和外部宽限期是否成组对齐。Spring 的超时不是硬上限，外部的 &lt;code&gt;SIGKILL&lt;/code&gt; 才是。&lt;/p&gt;&#xD;
&lt;p&gt;&lt;strong&gt;进程有没有收到信号？&lt;/strong&gt;——取决于启动方式。少一个 &lt;code&gt;exec&lt;/code&gt;，JVM 可能根本不知道自己要停机，最后被强制结束。&lt;/p&gt;&#xD;
&lt;p&gt;把这三件事分别确认清楚，再加上对 SSE、线程池和客户端重试的收尾，发版才能从“每次都有一小段 502”变成可预期、可验证的过程。&lt;/p&gt;&#xD;
&lt;p&gt;&lt;strong&gt;优雅停机不是在配置文件里多写一行，而是让流量、信号和超时在同一条时间线上对齐。&lt;/strong&gt;&lt;/p&gt;</description>
      <pubDate>Tue, 29 Sep 2026 10:08:14 GMT</pubDate>
    </item>
  </channel>
</rss>

