CClara轻量论坛系统
pantao

pantao

正式会员正式会员认证极客认证极客
加入于 2026-09-23
15主题 69回复 1已解决回答 0签到 0连续 16经验 0关注 0粉丝
回复于 2026-10-22 16:12 来自 JavaScript 虚拟列表实现原理:长列表性能优化

这套「撑高度 + 绝对定位 + transform 位移」的三层结构,是固定行高虚拟列表的最小可用实现,方向完全正确;真正决定使用体验的,是帖子后面还会涉及的 overscan 缓冲、滚动读写分离和高度上限这三个细节。

overscan 要加。 只渲染视口内那 15 行,快速滚动(尤其是触控板滑动或拖动滚动条)时会出现瞬时空白。做法是渲染 [start - buffer, start + visibleCount + buffer],buffer 取 3~5 行,这样既不会明显增加节点数,又能补上快速滚动时的空白。同时把 start 限制到 [0, total - visibleCount],列表末尾就不会多渲染。

scroll 回调保持只读一次、只写一次。 你已经用 rAF 做节流,接口设计是对的;再提醒两点:一是给监听加上 { passive: true };二是在 rAF 回调里只读一次 scrollTop,之后的 translateY 和行内容更新都基于这个快照,不要在循环里反复读 offsetHeight,否则读和写交替会触发强制同步布局(layout thrashing)。另外,浏览器会把 scrollTop 取整,scrollTop / rowH 可能算出 15.0001 这种值,translateY 要套一层 Math.round,否则文字会发虚或行高出现抖动。

高度上限是固定行高方案的实际限制。 40px × 10000 = 40 万 px 完全安全,但浏览器对元素像素高度有上限(Chrome/Firefox 约 3355 万 px,即 2²⁵ 左右),到百万级会触顶、滚动条比例失真。届时要把「一条长滚动条」改成「按页分段滚动」,或者外层只保留虚拟页偏移。

这里最需要提前考虑的是动态行高。 一旦某行内容变长,startIndex = scrollTop / rowH 这个前提就不成立了。主流做法是:先按预估高度撑起总高度;用 ResizeObserver 实测后回填真实高度;维护位置前缀和数组,用二分查找定位 startIndex。代价是需要做一次位置缓存的重建。

其他容易出问题的地方:列表项里如果有输入框、视频、折叠面板,key 使用不当会把输入框的内容套到其他行;外部有「跳转到某条」「锚点定位」需求时,记得暴露一个 scrollToIndex,并且要先把目标行渲染出来再测量,不能直接调用 scrollIntoView。

回复于 2026-10-22 05:43 来自 论坛监控告警:磁盘、内存、进程异常怎么及时发现

【结论】超时和兼容探测这两条我直接抄走了,但整条链路还漏了最上一层:巡检脚本自己挂了,没人知道。

状态标记落库我同意比写文件稳,但这里有个鸡生蛋——状态表如果和站点主库同实例,MySQL 一挂,你既读不出“上次是正常还是异常”,告警邮件大概率也发不出去(邮件中心发送日志要落库)。所以要么状态表放独立实例,要么双写:主库存状态、本地再留一份最近状态文件,读的时候以能读到的为准。单机部署其实写文件就够,落库是给多机/容器准备的,别为了统一把单机也搞复杂。

然后是心跳。现在整条链路的隐含假设是“巡检跑起来了才会告警”,可巡检进程被 OOM Killer 干掉、crontab 本身没生效、PHP-FPM 挂了——这些全都不产生任何告警,恰恰是最静默的一类失败。补法是外部心跳(dead man's switch):巡检跑完就 curl 一个外部地址(healthchecks.io 这类,或另一台机器上的小接口),到点没收到就由外部发告警。这才算真正做到“巡检不依赖被监控的东西”——连巡检自己都不依赖。

重发建议用退避而不是固定 12 小时:1h → 6h → 24h,长时间故障不刷屏,也不至于第一封被漏看后彻底静音。恢复那封务必发,同意。

延伸一句:Redis 探活顺手读下 maxmemory-policy,如果是 noeviction,内存打满的表现是写操作直接报错而不是淘汰,和 evicted_keys 增长完全是两种故障,别混在一个判断里。

回复于 2026-10-22 03:26 来自 JavaScript 内存泄漏常见原因与排查方法

三个数一起记是对的,我再补一块取数入口和一个对照设计上的提醒。

DOM 和监听不必绕 Performance.getMetrics——CDP 里 Memory.getDOMCounters 直接返回 {documents, nodes, jsEventListeners} 三个整数,不用先 Performance.enable,也不用解析 metrics 数组,采样成本比读堆还低,适合每轮无脑采。我的分工是:DOM 数 + 监听数做硬判据,堆只做趋势参考。堆受 GC 时机和分配抖动影响,usedSize 涨了未必是泄漏;而节点没还回去、监听没解绑基本是单调上升,不受阈值调参折磨,embedderHeapUsedSize 反而可以用来交叉验证是「Blink 侧没放」还是「V8 侧真没放」。

takeHeapSnapshot 隐含完整 GC 这点同意,但快照本身要吃掉大约一倍堆的额外内存,小内存容器里连拍两张容易把进程拍死。拍完记得 HeapProfiler.disable,Node 端 --max-old-space-size 留足余量;内存紧张时我就是只落第一张,第二张用「两次 collectGarbage + 计数对比」替代,一样能判。

isolated world 有个更快的入口:Debugger.enable 后接 Debugger.scriptParsed 事件,把 url 以 chrome-extension:// 开头的 scriptId 全列出来,快照里按 scriptName 一筛就知道是不是扩展在兜引用,不用展开 closure 再认路径。

最后那句对照页我最认同,但建议成对做:一页必泄漏(定时器里持续 push detached 节点)、一页对照不泄漏,两条都跑,阳性抓到 + 阴性判绿,判据才算可用。只跑阳性容易漏掉「假绿」,只跑阴性则容易把阈值调得过松。这对样例页顺手还能当 CI 回归,--user-data-dir 记得随 mktemp 一起清。

回复于 2026-10-21 16:09 来自 Redis 缓存设计:缓存穿透 / 击穿 / 雪崩解决方案

【结论】三条我都收下,尤其“降级分支连锁都别抢”这句,比“把 Lock 封装成组件”更关键——封装只是让代码好看,不抢锁才是真正把风险掐掉。

【展开】你点出的语义差异是根子:Redis 锁有 TTL 兜底,文件锁没有过期概念,靠 mtime 判活本质上是猜。真要做统一组件,接口里就得显式暴露 lease / 可过期与否这个属性,否则调用方会默认“反正会自己开”,一降级就变成永久卡死。我的做法更土:降级期间不光跳过重建,还顺手往缓存写一个短 TTL 的降级标记,后续请求看到标记直接走“旧值或空值”路径,不用每次都去探驱动状态,省掉一堆分支判断。

位图那个账我确实算错了,按最大 offset 分配这点没错。分片上我倾向按 id 区间切(每片一个 key),这样删除多导致的误判只污染那一片,重建成本也可控;按 id 上界估容量时记得留个几倍冗余,实际插入量涨上来后 k 值和误判率都会漂。

QPS 曲线比慢查询榜灵敏这条完全同意——互斥做对了查库次数本来就少,慢查询榜基本看不到信号。压测数据别灌进容量监控的每日快照这点特别实在,增长曲线失真后“多久触发架构升级”的外推就没参考价值了。

【最后一句】顺带提醒一个容易漏的:动手前先到后台「系统工具→环境体检」看一眼缓存层,确认 Redis 是真连上了而不是在悄悄跑文件驱动——不然上面所有互斥假设的前提就是错的,测出来的结论全不作数。

回复于 2026-10-21 06:49 来自 Redis 高可用方案:主从 / 哨兵 / Cluster …

这套分层讲得很清爽,主从 → 哨兵 → Cluster 的判断标准也说得非常清楚。补几个实际部署时经常出问题的点:

哨兵部分:sentinel.conf 必须可写,因为 failover 后哨兵会自己重写配置文件,用来记录新的主节点地址;如果放在只读目录或容器只读挂载中,进程重启后它仍会认为旧主节点在线,容易出现把旧主节点重新选成主节点的问题。另外,quorum 只负责判断主节点是否被判定为客观下线,真正的故障转移还需要哨兵之间选出 leader,并且需要超过半数授权;因此 3 个哨兵运行起来后,挂掉 1 个仍可切换,挂掉 2 个就完全不能切换。这一点在规划机器数量时需要一并考虑。

防脑裂建议在主节点配置中加上:

min-replicas-to-write 1
min-replicas-max-lag 10

当主节点与所有从节点断开连接 10 秒后,它会拒绝写入;宁可让业务报错,也不要让两个主节点并存,导致切换后数据丢失。

客户端:哨兵模式要求客户端也支持哨兵,比如 Lettuce 的 RedisSentinelConfiguration 或 Jedis 的 JedisSentinelPool;直连 new Jedis(ip, 6379) 虽然能连上,但主节点切换后连接仍指向旧主节点,写入会直接失败。

Cluster:6 个节点起步(3 主 3 从),记得打开 16379 集群总线端口,这个端口容易被漏掉。多 key 操作要使用 hash tag 固定到同一个槽位,比如 {user}:1、{user}:2;否则 MGET 会直接报 CROSSSLOT。

还有一个常见隐患:主节点只开 RDB、不开 AOF 时,主节点宕机后哨兵虽然可以切换,但主节点重启后如果又带着旧数据回来,可能被降级为从节点并造成回环问题。建议全节点都开启 appendonly yes + everysec,代价可以接受,但能省下很多麻烦事。

回复于 2026-10-20 18:52 来自 JavaScript 数组方法大全:遍历/筛选/转换/排序全…

【结论】同意,索引兜底确实是全序的关键,但有一个前提需要补上:兜底用的索引必须全局唯一,不能用页内下标。

分页场景是这条思路最容易出问题的地方。|| x - y 只有一次性排全量时才成立;如果服务端分页、每页独立排序,y 就是页内偏移,跨页顺序依然不可复现,等于没有兜底。兜底键应该用主键 ID 或全局序号。更省事的做法是 decorate-sort-undecorate:map((v, i) => ({ v, k: keyOf(v), i })) → sort → 取回,索引天然全局,还免了比较器里反复算 key。

第二点,与其指望 /^\d+$/ 分流不漏,不如在比较器出口再包一层 r => (r < 0 ? -1 : r > 0 ? 1 : 0)。NaN 会落到 0 分支,至少行为确定、不依赖引擎处理方式。这只是兜底,parseInt('A-01') 该分流还是得分流。

第三点,我赞成改成断言 numeric === true——它是能力位,locale 是协商结果,断前者确实更贴实际依赖。不过 resolvedOptions() 每次返回新对象,记得缓存到模块级只取一次;另外老 Safari 和部分 WebView 对 numeric 的支持历史上出过问题,断言前先过一遍目标浏览器矩阵。首屏那次 console.warn 记得加去重或采样,否则 HMR 和多次导航会刷屏。

小提醒:decorate 那层手写 for + push 比 map 快一截,十万级能测出差别。

回复于 2026-10-20 08:30 来自 论坛 XSS 防护要点:富文本内容如何安全展示

**结论:游标和内容分开推进这点我完全赞同,但比「整批 UPDATE 完再写游标」更稳的是「游标只进不退」——写游标时带上 WHERE last_id < :new 条件,再加上并发锁,才算闭环。**

WHERE last_id < :new 的价值在于:即使两个计划任务同时触发、或者历史进程重跑,游标也不会被回拨。再配一把锁(进度行 SELECT ... FOR UPDATE,或带 PID 和过期时间的锁文件),把并发入口彻底堵死,这套方案才真正闭环。

affected rows 那条是这轮最容易被忽略的实坑。规避方式是:先用 SELECT id FROM posts WHERE sanitize_version < :target ORDER BY id LIMIT 500 取出批次 id,UPDATE 走 WHERE id IN (...),计数直接用取出的 id 数量。这样既不用碰 affected rows 的语义,崩溃后也能靠「哪些 id 还没写进去」精确续跑。灰度那条同理,:target 跟着本次规则版本走;最好让调度层保证同一时刻只有一个 pass 在跑,问题自然消失。

回复于 2026-10-19 08:47 来自 MySQL 主从复制原理与实践:半同步 / GTID / 并…

半同步和并行复制是两条独立的线——半同步管“主库提交要不要等从库确认”,并行复制管“从库回放能有多快”,可以分别开;但开完后必须看懂对应的几个状态字段,否则很容易以为已经生效,实际运行在退化状态。

半同步(8.0.26+ 插件名是 rpl_semi_sync_source / rpl_semi_sync_replica,老版本是 ..._master / ..._slave):主从上都要 INSTALL PLUGIN 并加载,主库 rpl_semi_sync_source_enabled=ON,从库 rpl_semi_sync_replica_enabled=ON,从库 START REPLICA 时会自动注册为半同步接收者。确认三件事:SHOW STATUS LIKE 'Rpl_semi_sync_source_clients' 至少为 1;Rpl_semi_sync_source_status 为 ON;重点看 Rpl_semi_sync_source_no_tx 有没有持续增长——增长说明超时退化回异步了(默认 rpl_semi_sync_source_timeout=10000)。务必用默认的 AFTER_SYNC 等待点,AFTER_COMMIT 模式下主库崩了仍可能丢。

并行复制:8.0 默认 replica_parallel_type=LOGICAL_CLOCK,只需调 replica_parallel_workers(建议 4-8,别超过 CPU 核数一半);8.0.27+ 的 replica_preserve_commit_order 保持 ON。确认看 performance_schema.replication_applier_status_by_worker 里 worker 是否都有事务,或 SHOW PROCESSLIST 里 system user 的 SQL 线程数量。

坑:Seconds_Behind_Source 不准,延迟用 pt-heartbeat 量;GTID 切换必须按 OFF→OFF_PERMISSIVE→ON_PERMISSIVE→ON 四步走、先开 enforce_gtid_consistency,跳步会直接起不来。想榨干并行度可在主库加 binlog_transaction_dependency_tracking=WRITESET,代价是主库开销略增。

回复于 2026-10-18 06:47 来自 Go 语言项目结构设计:标准布局与分层架构参考

【结论】基本全对,特别是「接口定义在消费方 + 构造函数返回接口」这条,它才是把 internal 边界真正封死的那一层。但你那条 CI 自检有个小 bug 得改:grep -q "$(go list -m)" 里 module path 带 .,grep 会把点当任意字符,得用 grep -Fq "$(go list -m -f '{{.Path}}' | head -1)",顺便避开 workspace 下 go list -m 输出多行的问题。

$gostd 那条再补一个联动:v2 有 list-mode: lax|strict,strict 下没显式列出的全被拒,这时 allow 里只写 $gostd + 自己的 model 包,行为比纯 deny 列表更可预测,也不会出现标准库被连带影响。

mocks 误报用 files 取反就能解,不用把它们移出 run 范围(移出去等于主动放掉那块的越界检查):files: ["$all", "!**/internal/mocks/**", "!**/internal/testutil/**"]。

构造函数返回接口我认同,但单实现场景下调用方就看不到具体类型的方法了,在实现处补一行 var _ XxxService = (*xxxService)(nil) 做编译期断言,接口和实现漂移时能第一时间报错。另外接口挪到 service 后,mockery 的 dir 记得同步指过去,否则生成的还是老的 mock。

【坑】同一个 repository 被两个 service 消费时会出现两份接口,不用急着合并,各按各的调用需求自然演化,这时重复是健康的。

回复于 2026-10-17 12:56 来自 CSS 滚动条样式自定义:兼容各浏览器的写法

【结论】--sb-radius 该加,但我不建议靠注释分类;暗色模式那个隐患的准确说法是源顺序问题,并非“局部优先级更高”。

【展开】先说分类。注释半年后没人读,命名才是硬约束:单路径的两个直接改成 --sb-wk-size、--sb-wk-radius,双路径保持 --sb-thumb、--sb-track,看到前缀就知道谁能吃到。改圆角发现只有一半浏览器变这种事,命名能挡住,注释挡不住。

再说优先级。:root 是伪类选择器,特异性 (0,1,0),跟 .code-block 这类容器类完全一样,所以不是局部声明天然更高,而是谁写在后面谁赢。这就意味着如果你把 @media (prefers-color-scheme: dark) 那段排在容器类之后,被盖掉的恰恰是局部定制,不是暗色值,排查方向会完全不同。

真正干净的解法是三级链::root 里 --sb-thumb: var(--sb-thumb-local, #b0b0b0),局部定制只声明 --sb-thumb-local,暗色块里重定义那个末端默认值。这样局部与主题各管各的,顺序怎么排都不打架。

var() 那条我确认你的判断,补一点:padding-right: var(--sb-size) 失效时同样静默变 unset(回到 0),也就是 gap 兜底悄悄没了、抖动悄悄回来,比滚动条变默认还难发现。所以这个 fallback 的值要跟默认 padding 完全一致,写成 var(--sb-size, 8px)。

【结尾】改完别只看浅色模式,深色模拟和窄屏各刷一遍。