今天三轮是 JVM、前端、数据库网络。前两轮各有一处没答到位,第三轮全过。前端那套题我一开始没认出来,问了才看明白是 Pointer Events、容器查询和安全区。
STW 到底是什么
STW 是 GC 在某些阶段暂停所有应用线程,等它做完再恢复。必须停的原因不复杂:应用线程一边跑一边改引用,GC 边遍历边被改,就会漏标或者错标,最简单可靠的办法就是先摁住。
线上更怕 Full GC 那次停顿。Minor GC 只扫新生代,活下来的少,通常几毫秒到几十毫秒;Full GC 扫整个堆,对象多、引用关系复杂,时间长,用户能明显感觉到卡。
安全点与安全区域
JVM 不是随时能把线程摁住,得等线程自己走到一个可以安全停的位置,这个位置叫安全点,一般插在方法调用返回、循环回边、可能抛异常这些地方。
这题我答对了一半:卡在 sleep 或者等锁上的线程不执行字节码,本来就能停,但我没说出这种情形在术语上叫安全区域(Safe Region)。
停顿时间为什么比「标几个对象」长很多,拆开看是几段:等所有线程跑到安全点,有的线程还在长循环里,这段等待也算进去;安全点轮询本身的协调开销;GC 真正的标记、复制、整理。
为什么收集器有这么多代
串行只省实现复杂度和 GC 线程开销,不在乎停多久。并行拿多核换更短的总 GC 时间。并发让 GC 和应用线程一起跑,换的是停顿时间。
吞吐量是应用线程运行时间占总时间的比例,GC 占得越少吞吐越高。停顿时间是每次 STW 持续多久。这两个一般没法两头都到顶:并行追求吞吐但单次停顿可能长,并发追求停顿但并发阶段要抢 CPU,总吞吐会降。
并发的对象是业务线程。代价有几样:并发标记期间应用还在产生新垃圾、还在改引用,这次收不干净,留下浮动垃圾(Floating Garbage)等下次;GC 线程和应用抢 CPU,核少的时候更明显;为了标记正确要靠写屏障、读屏障,有额外开销;并发失败(Concurrent Mode Failure)时退化成 Full GC,停顿反而更长。所以小堆、客户端、核少又追求极致吞吐的场景不适合上并发收集器。
永久代到元空间
JDK8 之前方法区由永久代实现,位置在 JVM 的堆内存里,最经典的报错是 java.lang.OutOfMemoryError: PermGen space。撞上它的典型情况是加载的类特别多、框架动态生成类,或者永久代参数设小了。这里要补一句:字符串常量池在 JDK7 就挪到堆了,所以「intern 字符串撑爆永久代」只属于 JDK6 及更早。
JDK8 换成元空间,挪到本地内存,不再受堆大小限制,默认只受机器物理内存约束。类加载器卸载之后,对应的元空间内存也能回收。但它不是不会爆,物理内存到底还是上限,所以生产要给 -XX:MaxMetaspaceSize 设一个合理值;-XX:MetaspaceSize 是首次触发元空间 GC 的阈值。
一套事件管住手、鼠标和笔
老路是 touchstart/move/end 加 mousedown/move/up 两套,还得自己防「触摸之后浏览器又补发一遍鼠标事件」。Pointer Events 把输入设备统一了:pointerdown、pointermove、pointerup、pointercancel,pointerdown 差不多是 mousedown 加 touchstart 的合体,补发鼠标事件那套坑基本不用自己处理。pointerType 会告诉你是 mouse、touch 还是 pen,常用来按设备调交互、区分点击和拖拽。
手指拖出元素之后,后续 pointermove 会落到指针下的新元素上,或者干脆丢了,滑块就断。解决办法是在 pointerdown 里调 el.setPointerCapture(e.pointerId),把这次指针的所有后续移动都收归自己。
pointer-events: none 是让元素不成为指针事件的命中目标,点击、悬停、pointer 事件都会穿到下层去,不影响键盘焦点,子元素可以单独设成 auto 恢复。常用在遮罩层不抢点击、装饰图标不挡按钮、loading 蒙层挡交互这些地方。
组件自己有多宽就自己决定长什么样
媒体查询量的是视口宽度,不看组件实际能用多宽。同一个卡片放 300px 宽的侧栏里和放 900px 的主区域里,屏幕宽度一样,断点表现也一样,结果就是挤或者错。
容器查询换了个量法。容器那边先声明 container-type: inline-size,可以再起个名字;查询那边写 @container card (min-width: 500px) 加规则。窄了竖排、宽了横排,判断的是容器自己的宽度,跟视口无关。container-type 非声明不可,不声明的话 @container 找不到可查询的容器,规则根本不生效;inline-size 只让宽度参与查询,避免高度和内容互相撑出循环依赖。
这题我漏了一环:容器尺寸变了靠什么感知。答案是 ResizeObserver,跟 IntersectionObserver 同族的另一个观察者,浏览器用它盯容器宽高的变化再重算样式。
全面屏底下那条横条
100vh 不含安全区。底部有手势横条、顶部有挖孔的机型,贴着 100vh 底部放东西会被压住或者不好点。安全区的实际高度用 env(safe-area-inset-top/right/bottom/left) 读。
只写 env() 还不够,viewport-fit 必须是 cover。默认的 auto 会让页面不延伸到安全区外,浏览器把内容限制在里面,那几个 env() 变量全是 0;cover 才让页面铺满整屏,变量才有实际值。
底部固定栏就 bottom: 0 配 padding-bottom: calc(12px + env(safe-area-inset-bottom)),背景和边框可以延伸到横条区域,内容留在横条上方。iOS 滚到底的弹性回弹、安卓的过度滚动光晕,用 overscroll-behavior: none 挂在 html 和 body 上关掉。
半同步那次等,到底等了多久
上次我只答出 Rpl_semi_sync_master_status 的 ON/OFF 和 yes_tx/no_tx 两个计数,没对上等待耗时的变量。这次点出来了:Rpl_semi_sync_master_net_wait_time 是主库等从库回复的累计总时间,Rpl_semi_sync_master_net_avg_wait_time 是平均每次等待,单位都是微秒。
为什么要单独看耗时:no_tx 只告诉你没等到,不告诉你为什么等这么久。单个事务被卡住,后面的事务跟着排队,堆多了会把库压垮。no_tx 占比很低但平均等待一路涨,先怀疑从库 ACK 变慢,从库侧重放大事务积压、锁等待、磁盘 I/O 饱和;网络侧看 RTT 抖动、丢包、带宽;主库侧看是不是有单条写卡着锁和 undo。
把 rpl_semi_sync_master_timeout 调大不算解决,只是提高主库对慢 ACK 的容忍时间,ACK 本身该多慢还是多慢。
压缩不是免费的
gzip 有一到九级,br 也有自己的质量档。线上一般取中间档,gzip 5 到 6、br 4 到 5,原因是再往上体积掉得很少,CPU 和首字节时间却一直涨。
同一个文件 br 一般比 gzip 小 15% 到 25%,代价是压缩和解压都更吃 CPU,另外在明文 HTTP 上支持很差。
静态资源用 brotli_static / gzip_static 直接发预压文件,是因为请求一来就现压,烧的是 Web 服务器的 CPU,高并发下这部分开销直接吃掉吞吐。预压等于把这份开销挪到构建时,运行时只管发。Nginx 命中 .br 或 .gz 之后会按客户端的 Accept-Encoding 选择,并设置对应的 Content-Encoding。Vary: Accept-Encoding 仍然要加,gzip_vary on 一般同时覆盖 gzip 和 brotli,这样缓存和 CDN 才会把两种版本当成不同条目,不会把 br 发给只认 gzip 的客户端。
刚写完就查不到
读写分离之后,这条链路是这样断的:写请求走主库、提交成功,用户立刻刷新或跳详情,那条读被路由到从库,从库的 SQL 线程还没重放完这次写,于是读到旧数据或者干脆查不到。
救法有三种。写操作之后一段时间内,这个会话的读全部强制走主库。或者按业务语义标记,像刚下单跳详情、刚改完昵称刷新,这些明确要读自己写的场景直接指定主库。更精细的是把主库位点(GTID 或 binlog 位点)记在会话或 Cookie 里,读的时候跟从库已执行的位点比,没覆盖就走主库,覆盖了就放回从库。
列表类查询一般不用这么麻烦,几秒的延迟用户没感觉,直接读从库就行。