三轮技术练习:join 与阻塞队列、for...in 与移动端适配、窗口函数与主从复制

三轮技术练习:join 与阻塞队列、for...in 与移动端适配、窗口函数与主从复制

这一批练了三轮:Java 的线程等待(join、死锁条件、阻塞队列、ThreadLocal),前端的对象遍历与移动端适配,数据库的窗口函数、CDN 调度和主从复制。前两处是上一批丢掉的机制,这一轮补齐了,后面的都是新面。

join 之后是谁把主线程叫醒

主线程调用 t.join(),停的是主线程,它进入 WAITING,底层是在 t 这个 Thread 对象上做 wait()。把它叫醒的是 JVM:t 的 run() 结束、线程真正终止的那一刻,JVM 会在这个 Thread 对象上执行一次 notifyAll()。主线程被唤醒后重新竞争这把锁,拿到锁才从 wait() 返回,再回头看一眼 isAlive(),确认为 false 才真正结束 join。

join 内部用 while (isAlive()) 包着 wait(),因为 wait 返回不等于线程已经结束:可能是虚假唤醒,也可能被唤醒之后又有人先拿到了锁。醒来重新判一次条件,是这套等待机制的通例。

死锁的四个条件

互斥、持有并等待、不可剥夺、循环等待,四个同时成立才会死锁。工程上最容易打破的是循环等待:给所有锁定一个全局顺序,所有线程都按这个顺序申请,A 先 B 后的规则在哪个线程里都一样,环就形不成。

线上真出事了,先用 jstack <pid> 抓各线程的锁情况,检测到环时输出里会有 Found one Java-level deadlock,下面列着两个线程各持有什么、在等什么。

用 wait/notify 搭一个阻塞队列

固定长度数组当队列,put 在队满时等,take 在队空时等。醒来之后必须重新判一次条件,三个原因:可能虚假唤醒;被 notifyAll 唤醒后要重新竞争锁,拿到锁时条件可能已经被别的线程改掉;被唤醒的可能还是同类线程,比如生产者唤醒了另一个生产者,队列仍然是满的。所以是 while (!条件) wait();,不是 if。

一个生产者两个消费者时用 notifyAll()。只调 notify() 会随机挑一个等待线程:队列空时两个消费者都在等,生产者放入一个元素后唤醒的可能只是其中一个,另一个还在睡;如果之后再没有新元素,这个消费者就等不到了。生产者和消费者混合时更糟,notify 可能唤醒同类线程,被唤醒的那个发现条件不满足又去 wait,真正该干活的线程没被叫醒,大家一起挂住。

wait 和 notify 必须挂在同一把锁上。队列对象的同步块里,wait 就只能调这个队列对象。

ThreadLocal 用隔离代替互斥

加锁解决的是「多线程共享同一份数据」,靠互斥让同一时刻只有一个线程能碰它。ThreadLocal 走另一条路:每个线程各存一份副本,互不干扰,靠隔离避免竞争。两者解决的是同一类问题,思路相反。

典型用途有两类。数据库连接和事务管理,每个线程绑定自己的连接,避免多线程共用一个 Connection;以及 SimpleDateFormat 这种本身就是非线程安全的工具类,每个线程一份就不会互相踩。Web 项目里把用户信息放进 ThreadLocal,整条调用链随处可取,不用一层层传参,也是同一个思路。

存进去的东西在线程真正终止时会随 Thread 对象一起回收,所以「起一个线程用完就死」的场景问题不大。线程池不一样,线程不死,ThreadLocalMap 一直挂在上面:上一个任务残留的值会被下一个任务读到,造成数据串用;同时 ThreadLocalMap 的 entry 继承自弱引用,key 是弱引用、value 是强引用,key 被回收后 value 还挂在 entry 上,看起来就像泄漏。所以用完要在 finally 里 remove()。

for...in 到底枚举了什么

它枚举对象上所有可枚举的字符串键,并且沿原型链一路往上找,只要属性的 enumerable 为 true 就会列出来。想只要对象自己的属性,用 Object.hasOwn(obj, key) 过滤,或者直接用 Object.keys。

拿它遍历数组有几个老坑:拿到的是字符串索引 "0"、"1",不是数字;数组上手工挂的可枚举属性(比如 arr.foo = 1)也会被遍历到;原型被污染时,污染上去的属性同样会露出来;顺序在算上原型链之后没有保证。

for...of 和自定义迭代器

for...of 走的是迭代协议:调用对象的 Symbol.iterator 拿到迭代器,反复 next() 取值,直到 done 为 true,跟可枚举属性没有关系。普通对象没有这个方法,所以不能直接遍历;想让它能遍历,就在它身上实现一个:

const iterable = {
  a: 1,
  b: 2,
  [Symbol.iterator]() {
    const values = Object.values(this)
    let i = 0
    return {
      next: () => i < values.length
        ? { value: values[i++], done: false }
        : { value: undefined, done: true }
    }
  }
}

1px 的线换算成 vw 之后

按 375 的设计稿,1px 约等于 0.267vw。到了真机上,这个值换算出来的 CSS 像素是小数,比如 0.98px、1.03px,浏览器再把它映射到物理像素上。物理像素只能是整数,dpr=1 时 0.98 个像素落在取整与抗锯齿之间,渲染出来是一条发灰、发虚的线;dpr=3 时同一个值又是另一个结果,不同设备的粗细就对不齐。

所以细线不靠 vw。dpr 大于等于 2 的屏幕上,0.5px 正好对应 1 个物理像素,写法最简单;更稳的是伪元素加 transform:先写 1px 的高度,再用 scaleY(0.5) 压成半个 CSS 像素,dpr=3 时改 scaleY(1/3)。缩放走的是合成层,兼容性比直接写小数 px 好。

100vh 和 dvh、svh、lvh

手机浏览器顶部地址栏会收起和展开,可视区域的高度一直在变。移动端的 vh 取的是大视口高度,也就是地址栏收起时的那份高度,所以地址栏可见时 height: 100vh 的内容底部会被挡住或者出现滚动。

现在有三个对应的单位:svh 是地址栏展开时的小视口高度,lvh 是地址栏收起时的大视口高度,dvh 随地址栏动态变化,始终等于当前可视高度。做「一屏刚好放下」用 100dvh,代价是地址栏收放时高度跟着变,可能引起布局抖动。

高清屏的图怎么给

一张 300px 宽的图硬拉到 2x、3x 的屏幕上,等于把 300 个像素铺到 600、900 个物理像素上,每个像素被放大,边缘就糊了。srcset 里的 1x、2x 是在告诉浏览器这几张候选图各自适合多少的像素密度,由浏览器根据当前屏幕和网络状况自己挑。

图片宽度跟着容器走时,光有 srcset 不够,因为浏览器不知道它最终会显示多宽。sizes 就是补这件事:描述在不同媒体条件下这张图占多宽,浏览器解析 HTML 阶段就能选图并开始下载,不必等 CSS 加载和布局计算完。它的默认值是 100vw,不写就等于告诉浏览器图占满视口,很多 srcset 不生效就是这个原因。

<picture> 配 <source media="..."> 除了按密度换图,还能做艺术指导:不同断点给不同裁切的图,而不只是缩放。用 <img> 还有 alt 和语义,也不依赖 CSS 是否加载成功。CSS 里写的背景图,媒体查询不命中的一般不会下载,但元素只要渲染在 DOM 里(哪怕 display: none)背景图就会被抓,而且背景图进不了预加载扫描。

loading="lazy" 是原生懒加载,告诉浏览器这张图不在视口附近就先别下。首屏那张大图不该加,它通常是 LCP 元素,延迟加载会让它更晚被发现和下载,直接拖慢首屏指标。

窗口函数和 GROUP BY

GROUP BY 把一组行折叠成一行;窗口函数不折叠,它在保留原行的基础上,给每行旁边附加一个聚合或排名的结果。这是两者最本质的差别。

OVER (PARTITION BY user_id ORDER BY created_at DESC) 里,PARTITION BY 决定按什么分组,窗口计算在每个用户内部独立进行;ORDER BY 决定分区内部的顺序,既决定排名和行号的先后,也决定默认窗口帧的范围(从分区开头到当前行)。

「每个用户最新的一笔订单,而且要整行」这个需求,老写法是 SELECT user_id, MAX(created_at) FROM orders GROUP BY user_id 再 JOIN 回原表。麻烦的地方有三处:MAX 只拿到时间,金额、状态这些列不在结果里,必须再查一次;同一用户同一秒有两笔订单时,JOIN 会同时匹配两行,结果多出行;要多扫一次表,而且换个规则(比如按 id 取最新)就得改 JOIN 条件。

用窗口函数是一层子查询的事:

SELECT *
FROM (
  SELECT o.*,
         ROW_NUMBER() OVER (
           PARTITION BY user_id
           ORDER BY created_at DESC, id DESC
         ) AS rn
  FROM orders o
) t
WHERE rn = 1;

排序里带上 id DESC,同一秒的两笔订单也有了确定的先后,不会随机。ROW_NUMBER 遇到相同值也给不同序号,1、2、3 严格连续;RANK 遇到相同值给同一名次,后面跳号,1、1、3。

CNAME 指到 CDN 之后谁在调度

权威 DNS 把 www.example.com 解析成 CNAME 指向 CDN 厂商的域名之后,递归解析器会继续追这个域名,去问 CDN 的权威 DNS。CDN 那边通常是一套 GSLB 或智能 DNS,由它给出最终 IP。

它判断的依据主要是「谁来问的」,也就是递归解析器的 IP 属于哪个地域、哪个运营商,再叠加节点的健康状态、负载和探测到的延迟,决定返回哪一组 A 记录。

这种猜法会错。用户把 DNS 改成 8.8.8.8、1.1.1.1 这类公共 DNS,递归解析器在别人机房里,IP 不代表用户位置;运营商在一个大区可能只有一台 LDNS,很多用户共用,IP 只能定位到大区;企业内网、VPN、代理也会让这个 IP 和用户的实际位置脱节。EDNS Client Subnet 补的就是这块:递归解析器在查询里附带用户所在子网的前缀(比如 /24),权威 DNS 拿到更接近用户的位置信息再调度。代价是把用户网段暴露给权威服务器,隐私上有争议,部分公共 DNS 可以选择关闭。

除了按 DNS 回不同 IP,还有两种常见做法:先返回一个统一入口,入口再按用户 IP 做 302 重定向到就近节点;或者用 Anycast,同一个 IP 广播到多个节点,靠路由把用户带到最近的那个。

主从复制,以及读到旧数据

主库执行 INSERT,事务提交时写 binlog(row 格式记录行变更)。主库的 dump 线程把 binlog 发给从库,从库的 IO 线程接收后写进本地 relay log,再由 SQL 线程(或并行 worker)重放,应用到自己那份数据上。

延迟的常见来源:主库并发写,从库重放跟不上,尤其是老版本单线程重放;一次更新几百万行的大事务,binlog 一大段,从库要执行很久,后面的变更全等着;从库自己的磁盘慢、CPU 被大查询或报表占满;binlog 传输受网络带宽和延迟影响;从库重放时等锁,或者碰上 DDL 阻塞;并行复制配了但粒度太粗或压根没开。

延迟一上来,业务上看到的就是写后读不一致:用户刚下单跳到详情页,读的是从库,还没重放,页面提示订单不存在;刚改完资料刷新还是旧值;再叠加缓存,就分不清是缓存不一致还是主从延迟。

应对有两个方向。读己之写:写完之后的一段时间内,该用户的读请求强制走主库,或者把最近写过的 key 打上标记,读这些 key 时走主库。另一条是半同步复制加监控:至少一个从库确认收到 binlog 才算提交,缩小「主库提交了、从库完全没有」的窗口;同时监控复制延迟(比如 Seconds_Behind_Master 或 performance_schema 里的复制状态),超阈值告警、降级或把流量切走。

从库重放能不能并行?能,粒度是事务级不是语句级:MySQL 5.6 按库并行,5.7 起可以按组提交(LOGICAL_CLOCK)并行,8.0 还有基于行冲突判断的 WRITESET。

这一批里有两件事值得记住:窗口函数不折叠行,所以「取最新一条并保留整行」这类需求它是正解;主从延迟不是故障,是架构的固有属性,业务侧必须自己处理写后读。

← 返回文章列表