三轮技术练习:多线程入门、reduce 与路由模式、主键选型与事务边界

三轮技术练习:多线程入门、reduce 与路由模式、主键选型与事务边界

这一批练了三轮:Java 的多线程入门,前端的 reduce、路由模式与 grid/flex 布局,数据库与网络的主键选型、事务边界和半连接队列。前端那轮有几处只写出了代码,没有解释这些写法在做什么,下面按知识点整理成完整说明。

进程与线程:资源分配和 CPU 调度是两件事

我最初的回答停在内存层面:进程有自己独立的内存空间,进程之间默认隔离,线程运行在进程内部、共享这块内存。这个描述成立,但还缺一句对照:进程是操作系统分配资源的基本单位,线程是 CPU 调度的基本单位。前者说的是内存、文件描述符这类资源归谁持有,后者说的是 CPU 时间片分给谁执行。

为什么要开线程,以及线程过多的代价

多线程的收益来自两个方向。一是利用多核,单个线程在同一时刻只能占用一个核。二是掩盖等待,一处阻塞(网络请求、磁盘 IO)时其他线程可以继续使用 CPU。我举的场景是一个接口需要同时取库存、用户信息、优惠券和订单列表,串行调用时各段耗时相加,并发调用只需要等最慢的那一段。

线程不是越多越好。每个线程需要独立的栈,HotSpot 在 64 位 Linux 上的默认线程栈是 1MB,可以通过 -Xss 调整,几千个线程光栈就占几个 G。线程数远超核数时,CPU 有相当一部分时间花在保存和恢复上下文上。具体数量一般按任务类型定:CPU 密集型接近核数,IO 密集型可以高一些。

创建线程的两种写法

继承 Thread 并重写 run(),或者实现 Runnable 再交给 Thread。差别在职责划分:前一种把线程和任务绑在同一个类里,后一种把任务单独描述出来,线程只负责执行。我漏掉的是类继承这一条:一个类只能 extends 一个父类,继承了 Thread 就不能再继承别的,而 Runnable 是接口,实现它不影响继承体系。

更重要的是可组合性。Runnable 能直接交给线程池,submit 接收的是 Runnable 或 Callable,Thread 对象不在其中;同一份任务可以交给新线程,也可以交给线程池,代码不用改。JDK 8 之后 Runnable 是函数式接口,还能直接写成 lambda。

run() 和 start()

直接调用 run() 只是一次普通方法调用,代码运行在调用它的线程上,比如 main 线程,不会产生新的线程。start() 才是启动线程的入口:它是 native 方法,内部通过 start0 让 JVM 创建线程,状态从 NEW 转为 RUNNABLE,随后由新线程回调 run()。同一个 Thread 对象第二次调用 start() 会抛 IllegalThreadStateException。

i++ 为什么会丢更新

两个线程各执行一万次 i++,结果通常小于 20000。原因在 i++ 的步骤:读取当前值、加一、写回,三步之间可以被打断。两个线程都读到 100,各自算出 101 再写回,两次自增只留下一次效果,这叫丢失更新。

加 volatile 解决不了这个问题。它保证的是可见性,一个线程写入后其他线程能读到最新值,也禁止相关指令重排,但读、加、写三步仍然不是一个原子操作。要稳定得到 20000,需要把这三步整体保护起来,比如用 synchronized 或 AtomicInteger。

synchronized 提供的是互斥:一个线程进入临界区,另一个线程阻塞等待。这里有一个容易忽略的前提,两个线程必须锁在同一个对象上,锁 this 和锁一个各自 new 出来的对象,等于没有互斥。退出同步块时,修改过的变量会刷回主存,可见性因此也得到保证。

reduce 的参数和返回值

reduce 接收一个回调和一个初始值,回调的参数依次是累加器、当前元素、当前索引和原数组,返回值是累加器累积的结果,类型由初始值决定。

const sum = [1, 2, 3, 4].reduce((acc, cur) => acc + cur, 0)

const names = ['张三', '李四', '张三']
const count = names.reduce((acc, name) => {
  acc[name] = (acc[name] || 0) + 1
  return acc
}, {})

不传初始值时行为不同:空数组抛 TypeError,既没有初始值,也没有第一个元素可以充当累加器;非空数组把第一个元素当作初始值,回调从第二个元素开始,执行 length - 1 次;只有一个元素时直接返回该元素,回调不执行。

在 TypeScript 中,初始值 {} 会把累加器类型推断成空对象类型,随后给属性赋值会报类型错误,需要显式指定泛型,例如 reduce<Record<string, number>>(...)。

hash 模式和 history 模式

带 # 的是 hash 模式,用 createWebHashHistory 创建;路径干净的是 history 模式,用 createWebHistory。# 后面的部分浏览器不会发往服务器,它只修改当前文档的 URL 片段并触发 hashchange,路由监听到事件后替换组件,服务端收到的请求始终只包含 # 前面的路径。

history 模式在开发环境正常,上线后直接访问或刷新子路径会 404。开发服务器(Vite、webpack-dev-server)默认带 history fallback,找不到的路径回退到 index.html;线上 Nginx 按磁盘上的实际文件查找,/user 没有对应文件就返回 404。补法是把未匹配的请求指回入口文件:

location / {
  try_files $uri $uri/ /index.html;
}

/api 这类后端路径需要单独配置 location 排除,否则接口返回 404 时也会被回退成 index.html,前端拿到一段 HTML 再去做 JSON 解析,报出的错误与接口本身无关。

grid 和 flex 的分工

flex 是一维布局,一次控制一行或一列,适合导航条、工具栏、单元素居中这类沿主轴排列的场景。grid 是二维布局,行和列的尺寸同时参与计算,适合整页骨架、卡片阵列这类纵横都要对齐的结构。

.grid {
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  gap: 16px;
}

repeat(3, 1fr) 表示三条轨道,每条 1fr。fr 是剩余空间的份额,三列各占一份就是等分,并不是固定像素。让某个元素横跨两列,属性写在元素自身:grid-column: span 2。

和 flex 子项的 min-width: auto 类似,网格轨道的 1fr 最小尺寸默认也是 auto,内容里出现长单词或不换行的 URL 时会把网格撑破,写成 minmax(0, 1fr) 才能让轨道按份额收缩。

InnoDB 的主键选型

InnoDB 的表数据按主键顺序组织,主键本身就是聚簇索引。自增主键插入新行时基本追加在最后一页,页写满后开新页。UUID 是随机分布的,插入位置可能落在任意已有页的中间,目标页满了就要拆成两页并把一部分记录搬过去,这称为页分裂。分裂之后两页的填充率都偏低,空间利用率和扫描效率受影响,一次插入还可能牵动多个页的写回。

二级索引的叶子节点存放索引列的值和主键值,回表时用主键再去聚簇索引取整行。主键从 8 字节的 BIGINT 换成 36 字节的 CHAR(36) UUID,每个二级索引的每条记录都要多存这部分,索引体积变大,一次 IO 能读到的记录数变少,回表也更慢。想用 UUID 又不想承担随机性带来的代价,常见做法是有序 UUID(如 UUID v7)或带时间前缀的雪花 ID。

自增主键也有自己的问题:并发插入集中在最后一页形成热点;ID 连续,外部可以推算数据总量;分库分表时多个库的自增值会冲突,需要步长或号段方案。

隐式提交与事务边界

在 BEGIN 之后执行 ALTER TABLE,接着写 UPDATE,最后 ROLLBACK,ALTER 之前和之后的操作都回不去。MySQL 的 DDL 会隐式提交当前事务,DDL 本身也不支持回滚。触发隐式提交的不止 ALTER,还包括 CREATE、DROP、TRUNCATE、RENAME,以及 GRANT、REVOKE、LOCK TABLES、SET autocommit = 1。

这类语句带来的影响落在操作顺序上:它放在事务中间,之前未提交的写入会一起落地,后面的 ROLLBACK 只能撤销它之后的语句。

用 SAVEPOINT 跳过冲突行

批量插入一千条,其中可能有个别主键冲突,要求跳过冲突行、其余照插,同时不为每条记录单独开事务。做法是在每条插入前设置保存点,冲突时回滚到该点,只撤销这一条语句,事务继续。

BEGIN;
-- 循环体中
SAVEPOINT sp;
INSERT INTO t (...) VALUES (...);
-- 发生主键冲突时执行 ROLLBACK TO sp; 再继续下一条
COMMIT;

事务里调用第三方接口或发消息的风险有两个方向。接口响应慢时事务迟迟不提交,持有的锁一直不放,其他事务排队等待,严重时会撞上死锁检测;即使最后回滚,短信已经发出、支付已经扣款、消息已经投递,这些外部状态无法随数据库一起撤销。

半连接队列和 SYN Flood

服务端收到 SYN 并回复 SYN+ACK 后,客户端还没有回 ACK,这条连接尚未建立,它放在半连接队列里,状态是 SYN_RCVD。第三个 ACK 到达后连接转入全连接队列,等待应用调用 accept 取走。分成两个队列是因为此时服务端还无法确认客户端能否收到自己的响应,如果直接视为已建立,遇到伪造源地址的 SYN 就会长期占用资源等待一个不会到来的 ACK。

SYN Flood 打的就是半连接队列:攻击者发送大量伪造源地址的 SYN,服务端逐一回复 SYN+ACK,对方不回应,队列被迅速占满,正常请求的 SYN 进不来。开启 tcp_syncookies 后,服务端在这种情况不保存半连接状态,把必要信息编码进序列号随 SYN+ACK 发出,ACK 回来时再从序列号还原连接参数。全连接队列满时,新连接仍会建立但无法被应用取走,客户端表现为连接成功后的请求超时;半连接的 SYN 被丢弃时,客户端通常在 connect 阶段超时并重试。

这一批里前端那轮留下的问题最具体:grid 的代码写得出来,repeat 和 1fr 在做什么、怎么让元素跨列却说不上来。能默写配置和能解释机制是两件事。

← 返回文章列表