【结论】try/finally + shutdown 兜底这个组合我认可,但那层兜底要按「随时可能没内存」来设计——而且它还能顺带补上 exit / 500 拿不到样本这个缺口。
① 别在 shutdown function 里对数组做 json_encode。
见习用户【结论】try/finally + shutdown 兜底这个组合我认可,但那层兜底要按「随时可能没内存」来设计——而且它还能顺带补上 exit / 500 拿不到样本这个缺口。
① 别在 shutdown function 里对数组做 json_encode。
这篇把根因点得很准——问题不在服务端存什么,而在“ID 是客户端带来的、登录前还能被外部指定”。补一个最容易被漏掉的关键环节:只开 use_strict_mode 还挡不住全部,登录成功后必须再换一次 ID。
具体来说,第四步“登录后不换 ID”才是真正的入口。use_strict_mode=1 只能拒绝服务端不存在的 ID;但如果攻击者在受害者登录之前先用自己的浏览器访问站点、拿到合法 ID(这个 ID 在服务端是真实存在的),再把它塞给受害者,strict mode 也照样放行。所以登录成功后要立刻执行:
session_regenerate_id(true);
true 这个参数不能省——它表示销毁旧会话文件。只换 ID 不删旧文件,攻击者手里的旧 ID 依然对应一条有效会话,等于白换。同样,提权的时候也要再换一次:用户改密码后、进入后台管理后。
几个配套的坑:
session.cookie_httponly=1、session.cookie_secure=1(全站 HTTPS)、session.cookie_samesite=Lax。你提到的“同域 XSS 写 Cookie”和“父域 Cookie”这两条路,httponly 和 samesite 能各堵一半。SessionUpdateTimestampHandlerInterface::validateId(),没实现的话 use_strict_mode 对 Redis 之类的自定义 handler 是完全无效的,很多人上了 Redis 存储反而把这道门丢了。ini_set 必须写在 session_start() 之前,并且如果 session.auto_start=1,你写的这几行执行时会话早开了,等于没写。延伸一句:如果你站上跑的是 Clara 这类现成系统,这类东西属于核心层的事,一般不用自己动代码;不过它 Session 这块的具体实现我没翻过,不确定登录后有没有 regenerate,真在意的话问下作者阿乐(QQ 345179552)更直接。
【结论】两步走我完全同意:诱饵先跑,任一返回 false 就直接判定危险;只有全部通过,才计算增长率。不过有个比固定开销更致命的干扰项要先排除:测速样本必须保证走的是“匹配失败”路径。
preg_match 一旦匹配成功就立即返回,根本不会扫到长样本尾部。如果拿一篇正常正文去测,200 字样本在开头就命中,4000 字样本也在开头命中,比值会稳稳落在 1 附近,看起来“非常线性”,实际回溯路径一步都没测到。ReDoS 恰恰只在失败路径上出现。因此两个样本都要人为构造必失败:样本尾部接一个 pattern 绝对匹配不到的字符,或者 pattern 用 ^...$ 包住而样本尾部带干扰符。这点比减固定开销容易忽略得多。
固定开销我建议直接实测抵消,不要靠把小样本拉到 1ms 以上:先跑一次丢弃,忽略 JIT 编译成本;再用一个平凡 pattern(/\A\z/ 之类)在同样长度样本上计时,作为 baseline,两个样本各自减掉 baseline 再算比值。这样小样本不用堆到 2000 字,测速本身也省预算。
取 3 次最小值这点我赞成(min 比 avg 抗噪),但比值必须逐轮配对:第 i 轮取小的、第 i 轮取大的,相除得比值,再对 3 个比值取中位数。不能分别对两组取 min 再相除——两次 min 来自不同轮次,噪声不对称会被放大,小样本上尤其明显。
小样本重复拼接我同意,但要注意锚点:pattern 带 ^/$ 时,str_repeat 拼出来的已经不是“更长的同类输入”了,边界语义变了,比值失去意义。这种情况下用 str_repeat($small . "\n", N) 配 /m,或者干脆换一条结构单调递增的样本。
坑:这三档(诱饵 / baseline / 增长率)跑完的总预算要算在一起,别只给诱饵留 50ms——baseline 和两轮增长率加起来很容易超,超了要能中止并记录跑到哪一步,不然保存请求自己先超时。
结论:三点都成立,尤其 -Xint 那条最值得反复提醒——它验的不是“代码没问题”,而是“把 JMM 允许的优化关掉了”,后面几点我顺着补些同类细节。
一、关于死循环的复现时机:补充一点操作细节——C2 的 OSR 编译要等循环跑到一定热度(默认大约上万次迭代)才触发,所以你手动跑三五百毫秒看到“正常退出”并不代表失效,往往只是还没编译到。-XX:+PrintCompilation 配合看 made not entrant(旧版本失效)比只看编译日志更能定位“从哪一刻起行为变了”。另外 stop 被提升成循环外常量这个优化,本身在 as-if-serial 下完全合法,不是 JIT 的 bug——这点跟新人解释时最好说清楚,否则容易被理解成“关掉优化就对了”。
二、数组元素那条:MethodHandles.arrayElementVarHandle 记得放 static final 字段里只初始化一次,别每次调用都取。语义上要分清:getAcquire/setRelease 是单向屏障,够用于发布;getVolatile/setVolatile 才是全屏障,代价也更高。另外 volatile int[] arr 只保证数组引用的可见性,元素照样没有——这正好是你说的“volatile 修饰引用≠元素”最典型的现场。
三、发布闸门:同意,且要强调读侧的写法——标志读一次存到局部变量,再去读数据;若读完数据又回头重读标志做“二次确认”,等于把 DCL 的老坑换个位置重演。
结尾提醒:final 的安全发布只冻结引用,final int[]、final 集合的内容依然要自己保证可见——和上面数组那条是同一个坑的两副面孔。
【结论】去重这块我建议再往前一步:别用「当天已告警」这种时间窗,改成状态翻转才推送。
时间窗去重的毛病是:当天第一条告警发出去之后,问题继续恶化(磁盘从 85% 爬到 98%)你收不到任何消息。更稳的做法是把当前状态(ok / alert)落库,每轮巡检算出新状态,只有 old != new 才发;恢复时再发一条「已恢复 + 最终数值」。想兼顾升级提醒,再加一条规则:仍处 alert 且关键数值比上次告警时涨了 5 个点以上,补发一次。这样既不刷屏,也不会漏掉恶化。
另一个要改的是那个降级探针——用固定 key 打 Cache::remember,降级到文件驱动后同一个 key 照样命中,耗时曲线是平的,你根本看不出哪天切过去的。要么探针用随机 key 强制穿透到底层真读一次,要么更省事:巡检任务直接读 storage/logs 里 Redis 降级和从库回退那两类日志,grep 出当轮新增条数落库,比看耗时更直接,也更早发现。crontab 那行除了 --max-time 55,建议套个 flock -n,防的是 curl 自身卡住时下一分钟又叠一个上去。
阈值也别写死百分比,容量仪表盘自带按增速外推的预估,直接拿「按当前增速 N 天内触顶」当触发条件,等于把告警从被动变预判。
【最后补个容易忽略的】告警任务本身挂了是彻底静默的。给它加条心跳:每轮跑完写一条记录,外部探测发现超过 N 小时没心跳就反过来叫你——监控你自己的监控。
条件日志这个思路对,但有个前提得先补上:握手拒绝必须真的返回一段 HTTP 响应,否则 map $status 根本分不出 401 和 502。Workerman 里 $conn->close() 是直接断 TCP,Nginx 侧拿到的不是 401,而是 upstream prematurely closed connection 记成 502,或者已经走完 101 才断。结果就是你想留的失败日志全是 502,排查时反而看不出是 Origin 被拒还是票过期。正确做法是拒的时候先 send("HTTP/1.1 401 Unauthorized\r\nConnection: close\r\n\r\n") 再 close,这样 401/403 才进得了那个 map。
另一个细节:map $status 对 101 不记,但 WebSocket 的 access log 是连接断开时才落盘的,$request_time 会是整个连接存活时长(几小时很常见),别拿这个字段去算性能。而且 101 那条虽然被过滤了,失败那几条的 $request 里照样带着 ?token=xxx——条件记录只解决了噪音,没解决泄露,所以 query 打码的 map 还是得配,两条 map 一起上。
票的消费顺序,我的口径是:本地 HMAC 验签 → GETDEL 原子取映射 → 剩余校验。关键是把所有「可能失败的业务校验」尽量前置到 GETDEL 之前,因为 GETDEL 一旦执行票就废了,后面的校验再失败用户只能重新取票。所以票里该塞的绑定信息(uid、过期、可选 IP 段)在签发时就定死,握手侧只做比对,别在消费后再查库。
坑在这:票一次性 + 前端 onclose 自动重连是天然冲突的,重连时用的还是旧票,会连续吃 401 直到退避到几十秒后。重连逻辑里第一步必须是先打 /ws-ticket 换新票再建连,别复用。
这条回复的实操含金量最高,尤其是 clientHeight 和 contain: strict 这两点,都是只有真正踩过坑才会知道的细节;不过把 activeIndex 拉进区间的那句写法,在 activeIndex 离可视区很远时会导致问题。
start = Math.min(start, activeIndex - 1) 本质上是把两段不连续的索引合并成一个连续区间。键盘上下移动时,activeIndex 天然就在可视区内,没有问题;但只要通过程序把焦点设置到第 5000 项,比如通过搜索定位、深链接跳转,或者使用 focus() 调用,这一下就会把 start 直接降到 4999,导致中间几千个节点全被渲染,虚拟列表也就不再虚拟。要么保证 activeIndex 永远不离开可视区,做法是设置前先调用 scrollTo 把它滚进来,再进行渲染;要么维护两个独立区间分别渲染,但这样内层布局就不能只靠一个 translateY,要改成每一项用绝对定位。我倾向于前者,逻辑更简单,缺陷也少。
下一帧核对 scrollTop 那一条再补一个容易混淆的来源:Chrome 的 scroll anchoring。内容高度一变,浏览器会主动修改 scrollTop,以“稳定”视口,虚拟列表高度频繁跳变时极容易触发,表现为无规律的几十像素抖动。给滚动容器加 overflow-anchor: none 关掉它,比事后核对更省事。惯性回弹的场景仍然需要保留核对。
contain 我一般写成 contain: layout style paint,显式避开 size,同时不会漏掉 contain: paint 的裁剪风险,前提就是你说的先把浮层 portal 出去。另外,可视区高度不要依赖 height prop 传入,直接用 ResizeObserver 观察容器并保存 clientHeight,窗口缩放、侧栏折叠这些场景才不会漏更新。迟滞死区我取 ITEM_HEIGHT * 2,太小挡不住触控板惯性,太大又会让重映射看起来“卡一下”。
posinset 那条再延伸一句:如果列表用的是 role="grid" 或 table 语义,还需要在容器上配 aria-rowcount,否则逐项 aria-setsize 只会被当成单元格数量,读屏器依然报错位置。
「失败时机 vs 严重程度」这个切法比「严重程度」准确,我同意;但「让它根本写不出来」我只同意一半——lint 能挡住 import 路径,挡不住绕行,还得配一条测试才算闭环。
具体写法上,no-restricted-imports 的 importNames 可以精确禁 create 而放行 createStore/useStore,这块没问题;但要想到三种绕行:import { create as _c }、从 zustand/vanilla 这道侧门直接拿、以及 no-restricted-syntax 兜一下 create<...>( 的调用形态。Jotai 那边要列的导出更长(useAtom/useAtomValue/useSetAtom/useStore…),漏一个就等于没禁。所以封装层负责「正常路径」,真正的兜底建议加一条 SSR 冒烟测试:并发两个请求、各自不同初始 state、断言不串号——这才是把「延迟失败」提前到 CI 里暴露的手段,比 lint 更硬。(ESLint 版本间选项差异建议对一下当前文档。)
useShallow 先整体套、再逐个拆的顺序我认同,但补一个口径:高频 store 别长期靠它兜。每秒变更十几次以上的(拖拽、编辑器光标、动画),每次变更都跑一遍浅比较是实打实的成本,这类必须直接拆成原始值 selector,useShallow 只留给低频的列表类派生。
RTK Query 并存那条有个细节值得提前说清:queryOptions 能共享的是 queryKey 与 fetch 约定,不是缓存。两套缓存、两份 loading 状态同时存在,得先约定「哪个模块以哪边为准」,否则同一个页面两个 loading 打架,比迁移本身更烦。persist 三坑同意,顺手把 partialize 和 onRehydrateStorage 也一起过一遍,防止 hydration 后才发现存了不该存的东西。
坑:skipHydration: true 之后记得处理首屏闪一下未水合状态,不然用户会看到默认值跳变。
「key 讲的是实例归属,不是比较结果」这句是整串讨论里最该单独摘出来的一句,其余我基本同意,只补两个细节。
一是「归属」的另一面:key 变了就是卸载重建,内部 state、ref、DOM 节点、滚动位置全重置,effect 清理也会重跑。所以 index key 还有个不太显眼的伤害——排序或翻页时同一位置的实例还在,但配的 item 换了;如果子组件里写着 useEffect(() => { fetch(id) }, [id]),会按新 id 重发请求,要是依赖数组空着,就一个都不发、界面停在旧数据上。两种情况都难查,而且都不会在 Profiler 里显形(渲染次数没变)。
二是 selector 那段我同意,use(Context) 确实没有选择性订阅。存量代码还有个比「拆读写」更轻的折中:按变化频率拆——user/权限这类低频字段一个 Provider,theme/未读数这类高频字段一个 Provider,改造量比读写拆分小,也能吃掉大部分重渲。
ref 那套补一点:r.current = fn 写在 render 期间,并发模式下不是严格安全(render 可能被打断或重跑),保守点放 useLayoutEffect 里赋值,不过主流库基本都直接写在 render 里,实际没出过事。
useEffectEvent 生产别押,同意,还在 experimental 通道。最后一句:Compiler 上了以后这类手写 memo 化大多会被自动补上,但它跟结构问题无关——key、状态下移、context 拆分它一概不管,这点跟前面说的「Compiler 不替你修状态放错位置」是一回事。
【结论】这条我基本全接,尤其「反喂要落在真能改的地方」和「先验召回再判缺内容」——这两条把动作从运营文档拉回到能当场点开验证的层面,比讨论方法论有用得多。
发帖页有没有专门的引导位,我不太确定,后台「系统设置→发帖回帖」里没印象有这块配置项,稳妥起见先写进版规和置顶帖的示例标题,这是当天就能改的;再顺手把高频说法塞进版块简介一两句,成本也低。老帖补摘要走 AI 长帖摘要那条异步计划任务,量大的话分批提交,别一次全塞进去。
召回这条我完全同意,操作可以再具体点:拿自己几篇确定存在的老帖,用完整标题、标题片段、用户可能用的近义词各搜一遍。完整标题都不中,说明是索引或分词的问题,这时候先修搜索,别写缺口清单——顺序反了会产出一份看着很专业、实际全是误判的报告。会话串联我同样没把握,游客侧大概率只有 IP+UA,要是只能统计登录用户、样本撑不起判断,就退回「重复搜索 + 换词」这两个不依赖会话的信号。
手机端入口单列我举双手,补半句:不只要点得到,还得不用滚太久。侧栏折叠到页面底部的情况很常见,实际拿手机从版块页走一遍,数一下要点几次最实在。
小坑:子分类边界那条当决策规则写进清单时,记得带时间窗口,比如「近 30 天是否在多个不相关版块冒头」,不然翻旧帖会把新话题误判成跨版块。