Essays · Notes · Field Reports

安橙的博客

👋 欢迎来到我的博客

257 Posts
11 Topics
6 Years
05.27 Updated · 2026
01

后端

17 posts 查看全部 →

Day 06 · JWT 深入 · JWS / JWE / JWK / JWA 全家族

认证授权 30 天 Day 06:拆开 JWT 的 Header.Payload.Signature 三段结构,理解 JWS/JWE/JWK/JWA 四条 RFC 的分工,辨析 HS256/RS256/PS256/ES256/EdDSA 五大算法的选型,掌握 iss/sub/aud/exp/nbf/iat/jti 七个标准声明。Phase 2 开篇。

Day 03 · PKI · TLS 1.3 与 mTLS

认证授权 30 天 Day 03:理解 PKI 信任锚 / X.509 证书链 / TLS 1.3 一发握手 / SNI 与 ALPN 扩展,用 openssl 自建 CA、用 Go 跑通 HTTPS 服务端 + mTLS 双向认证。

Day 04 · 会话管理与 Web 安全 · Session / CSRF / XSS

认证授权 30 天 Day 04:理解 Session 完整生命周期与 Session Fixation 攻击,对比 CSRF 的四种防御方案,区分 Stored/Reflected/DOM 三种 XSS,并用 Go 实现安全的 Cookie / CSRF Token / CSP 中间件。

Day 05 · 多因素认证 · TOTP · WebAuthn / Passkey

认证授权 30 天 Day 05:辨清 MFA 三要素与威胁模型,看懂 RFC 4226 / 6238 的 HOTP/TOTP 公式,理解 SMS OTP 为何被 NIST 弃用,用 Go 实现 TOTP 服务并跑通 WebAuthn / Passkey 登录。Phase 1 收官。

02

AI · LLM

43 posts 查看全部 →

Day 11 · 显存管理

拆开 PyTorch 的 caching allocator:Segment / Block 两级结构、small/large pool 分裂、stream 池隔离;掌握 memory_summary / memory_snapshot 工具链,学会 OOM 排查的标准流程,并理解 expandable_segments 如何根治碎片化。

Day 14 · Agent 与子代理 — 并行调度 · 自定义 agents · Worktree 隔离

派分身去干活——把开放式探索、独立评审、批量重构交给 subagent 在隔离上下文里跑;用 .claude/agents/ 把团队的工种沉淀成可复用资产;用 Git worktree 让多个 agent 在不同分支上同时改文件不会打架。一个任务,几只手并行。

Day 16 · 自动化工作流 — /loop · Cron · 后台执行 · Monitor

把 Claude 从『会话内的助手』变成『会话外的同事』——拆透 /loop 的两种节奏、Cron 的本地时区与抖动、Bash/Agent 的后台执行与回调、Monitor 的事件流与覆盖率铁律,再把四件套编排成一条真正能 24×7 运转的自动化流水线。

Day 17 · 全栈项目实战 — 从 CLAUDE.md 到上线的完整循环

把 Day 1–16 学过的所有招式拼成一个真实项目——以 Mini Task Tracker(FastAPI + React)为例,跑通 CLAUDE.md → 脚手架 → DB → API → 前端 → 测试 → 部署七步,看 Hooks / Skills / MCP / Subagent 在其中各自的位置。

03

工具

37 posts 查看全部 →

Day 06 · 搜索、替换与正则

VSCode 搜索两套体系 —— ⌘F 单文件 / ⌘⇧F 全工作区。正则捕获组 ($1 $2)、大小写转换 (\U \L)、lookbehind/lookahead、include/exclude glob。多光标是临时正则,正则是永久多光标。

Day 01 · VSCode 安装、CLI 与同步登录

从零开始:选择 Stable / Insiders、各平台安装、把 code 命令装进 PATH、用 Microsoft 或 GitHub 登录开启 Settings Sync,让任何一台新机器 10 分钟还原你的 VSCode。

Day 02 · 界面骨架与命令面板

拆解 VSCode 的「五大件」—— Activity Bar / Side Bar / Editor / Panel / Status Bar 的边界与协作,以及命令面板 ⌘⇧P 的四种模式(> / @ / # / :)—— 老司机的唯一菜单。

Day 03 · 设置三层与 settings.json 实战

VSCode 配置的三层架构 —— User / Workspace / Folder 的优先级与覆盖规则;为什么直接写 JSON 比点 UI 更高效;如何用 .vscode/settings.json 和 extensions.json 给团队建立统一规范;@modified / @lang / @feature 过滤器的实战用法。

04

生活

17 posts 查看全部 →

福建旅游规划

福建旅游规划

不同寻常

贴上“我的“标签, 带着强烈的执着, 一切都会显得不同寻常。 …

价值

有人随着年龄增值, 有人随着年龄贬值, 这就说明了追求生命内涵的重要性。 …

迷路

不走觉路, 就迷路了。 思考 人一旦不向内觉察、不清醒地活着,就很容易被情绪、欲望、执念和外界牵着走,最后失去方向。 …

05

基础设施

12 posts 查看全部 →

Grafana 复习

基础入门 - 基础入门 - 可观测性与 Grafana 概览 - 理解可观测性 - 什么是可观测性 - 指通过系统对外输出的数据,来推断系统内部状态的能力 - 和监控的区别:监控是预先定义好要看什么,可观测性让你能回答事先没想到的问题 - 三大支柱 - Metrics(指标): - 带事件戳的数值数据,例如 QPS、响应事件 P99、内存使用率 - 指标的特点是体积小、聚合性强,适合用来回答系统整体表现如何 - Logs (日志): - 是离散的事件记录,比如一条错误堆栈、一次用户登录、一个数据库慢查询 - 日志的特点是信息丰富但体积大,适合用来回答到底发生了什么 - 指标告诉你系统有问题,需要通过日志定位具体原因 - Traces(链路追踪): - 记录一个请求在分布式系统中经过的所有服务和耗时 - 一个 API 请求经过了网关 -> 用户服务 -> 订单服务 -> 数据库 - Trace 记录每一跳的耗时 - 适合回答请求慢在哪个环节 - 认知 Grafana 生态 - Grafana 是什么 - 是 Grafana Labs 开发的开源可视化与监控平台 - Grafana 本身不存储数据,是一个统一的查询和可视化前端 - LGTM 技术栈 - LGTM 代表四个核心组件-Loki、Grafana、Tempo 和 Mimir,每个负责可观测性的一个关键方面 - L - Loki:日志聚合系统,类似 ELK 中的 Elasticsearch 但是更轻量 - G - Grafana:可视化中心,所有数据汇聚于此 - T - Tempo:分布式追踪后端,存储和查询 Trace 数据 - M - Mimir:长期指标存储,可以理解为 Prometheus 的增强版 - 了解 Prometheus 在生态中的角色 - Prometheus 是什么 - 是一个开源的指标监控和告警系统 - 在 Grafana 中扮演数据采集和存储的角色 - 关键特征 - 拉取模型(Pull Model):Prometheus 主动去拉取目标服务的指标,而不是目标服务推送数据给它,服务只需暴露 /metrics HTTP 端点 - 时序数据库:所有指标都按时间戳存储,形成时间序列 - PromQL:Prometheus 自带的查询语言 - 环境搭建与初识 Grafana 界面 - 用 Docker Compose 搭建环境 - 创建项目目录 - ``` grafana-learning/ ├── docker-compose.yml └── prometheus/ └── prometheus.yml ``` - 启动服务 - 在 grafana-learning 目录打开终端,运行 ```bash docker-compose up -d ``` - 验证 prometheus - 打开浏览器访问 localhost:9090 - 在查询框输入 up 点击 execute - 结果 up{job="prometheus"} 的值为 1 - ![](https://an-img.oss-cn-hangzhou.aliyuncs.com/2026/04/10/20260410163113390.png,440,150) - 点击 Status -> Targets,看到 prometheus 的 target,状态为绿色的 UP - ![](https://an-img.oss-cn-hangzhou.aliyuncs.com/2026/04/10/20260410163148074.png,400,150) - 验证 Grafana - 打开 localhost:3000,使用 admin/admin123 登录 - ![](https://an-img.oss-cn-hangzhou.aliyuncs.com/2026/04/10/20260410163255350.png,309,338) - 熟悉 Grafana 界面 - Home:首页,包含最近访问的仪表盘和快捷入口 - Dashboard:管理和浏览所有仪表盘,核心功能区 - Explore:临时查询和调试数据的工作台 - Alerting:配置告警规则,通知渠道和静默策略 - Connections:管理数据源连接,在这里添加 Prometheus - Administration:系统管理(用户、组织、插件、服务器设置等) - 连接 Prometheus 并创建第一个面板 - 添加 Prometheus 数据源 - 在 Grafana -> Connections -> Data source -> Add data source (Prometheus) - ![](https://an-img.oss-cn-hangzhou.aliyuncs.com/2026/04/10/20260410163355890.png,400,300) - 配置 Connection URL:http://prometheus:9090 (容器名) - ![](https://an-img.oss-cn-hangzhou.aliyuncs.com/2026/04/10/20260410163429596.png,300,80) - 出现 queried the Prometheus API 提示,说明连接成功 - ![](https://an-img.oss-cn-hangzhou.aliyuncs.com/2026/04/10/20260410163506913.png,410,60) - 创建第一个 Dashboard - Dashboard -> New -> New dashboard -> Add visualization - ![](https://an-img.oss-cn-hangzhou.aliyuncs.com/2026/04/10/20260410163625329.png,430,290) - Metric ,输入 up,点击 Run queries,看到值为 1 的直线 - ![](https://an-img.oss-cn-hangzhou.aliyuncs.com/2026/04/10/20260410163741421.png,260,160) - ![](https://an-img.oss-cn-hangzhou.aliyuncs.com/2026/04/10/20260410163813561.png,260,190) - Save Dashboard 保存面板 - ![](https://an-img.oss-cn-hangzhou.aliyuncs.com/2026/04/10/20260410163933122.png,260,210) - 添加第二个面板 - Add -> Visualization - 输入 prometheus_target_interval_length_seconds - 记录了 Prometheus 实时抓取目标的时间间隔,会看到多条线,因为有不同的 quantile 标签 - ![](https://an-img.oss-cn-hangzhou.aliyuncs.com/2026/04/10/20260410164235654.png,260,200) - 面板设置,Title -> 抓取间隔分布,Legend 模式改为 Custom 输入 quantile - ![](https://an-img.oss-cn-hangzhou.aliyuncs.com/2026/04/10/20260410164840562.png,260,170) - 调整时间范围 - 调整时间选择器 Last 6 hours - ![](https://an-img.oss-cn-hangzhou.aliyuncs.com/2026/04/10/20260410165639451.png,340,320) - 理解 Prometheus 的基本概念 - Prometheus 的工作原理 - 拉取模型 - 传统的方式是应用主动把数据推给监控服务器 - Prometheus 按照固定的事件间隔,主动去拉取每个目标暴露的指标数据 - Prometheus 每隔 15s (scape_interval) 向目标服务发起一个 HTTP GET 请求,访问 /metrics 端点 - 解析返回的文本并存入本地的时序数据库 - 优点:被监控的服务不需要知道 Prometheus 地址,只需暴露一个 HTTP 端点 - Prometheus 可以集中管理所有采集目标的配置,如果某个目标挂了,Prometheus 能立刻发现 - metric 数据结构 - ![](https://an-img.oss-cn-hangzhou.aliyuncs.com/2026/04/10/20260410175651692.png,260,150) - 格式:指标名称{标签1="值1", 标签2="值2"} 数值 时间戳 - 四种指标类型 - Counter 计数器 - 只增不减,每次事件发生,值就加 1 - 典型用例是请求总数、错误总数、发送的字节数 - 几乎不会直接看 Counter 的原始值,而是用 rate() 函数计算它的增长速率 - 例如 rate(http_requests_total[5m]) 告诉过去 5m 平均每秒处理了多少请求 - Counter 在服务重启时会归零,但 rate() 函数能自动处理这种重置 - Gauge 仪表盘 - Gauge 可以上升也可以下降,代表某个瞬时状态的快照 - 典型用例是当前内存的使用率、CPU 温度、队列中任务量 - 与 Counter 不同,Gauge 的原始值本身就有意义,不需要 rate() - Histogram 直方图 - Histogram 把观测值按照预定义的区间分类计数 - 每次请求的响应时间会被分到不同的时间区间中 - Histogram 在 Prometheus 中实际会产生多个时间序列:_bucket(每个区间的累计计数)、_count(总观测次数)、_sum(所有观测值的总和) - Histogram 的核心价值在于它可以在服务端灵活计算任意分位数 - 可以用 histogram_quantile(0.95,...) 在查询时计算 P95 - Summary 摘要 - Summary 和 Histogram 解决类似的问题,但是它在客户端直接计算分位数 - Summary 的缺点是分位数在客户端计算后就固定了,无法跨实例聚合 - 实际项目中,Histogram 比 Summary 用的更多 - 理解标签 - 一个指标名称加上一组标签,唯一确定一条时间序列 - 什么是标签 - 查询 prometheus_http_requests_total: ``` prometheus_http_requests_total{code="200", handler="/api/v1/query", instance="prometheus:9090", job="prometheus"} → 42 prometheus_http_requests_total{code="200", handler="/metrics", instance="prometheus:9090", job="prometheus"} → 1580 prometheus_http_requests_total{code="302", handler="/", instance="prometheus:9090", job="prometheus"} → 3 ``` - code、handler、instance、job 都是标签,虽然指标名称是 prometheus_http_requests_total,但是每组不同的标签组合构成了独立的时间序列 - 标签可以做维度分析:按 HTTP 状态码筛选,按接口路径分组,按实例聚合 - 需要注意标签的基数,如果用用户 ID 作为标签,会导致存储爆炸 - 查询 - prometheus_http_requests_total{code="200"} 只看成功的请求 - 在 Grafana 中连接数据源与创建面板 - Explore 查询实验台 - 查询 Counter 指标 - prometheus_http_requests_total - ![](https://an-img.oss-cn-hangzhou.aliyuncs.com/2026/04/10/20260410191834327.png,400,400) - 只看 /metrics 端点的请求 - ![](https://an-img.oss-cn-hangzhou.aliyuncs.com/2026/04/10/20260410191950185.png,400,400) - 使用 rate() 函数把累计值转换为速率 - ![](https://an-img.oss-cn-hangzhou.aliyuncs.com/2026/04/10/20260410192131676.png,400,400) - [5m] 是范围选择器,表示过去 5min 内每秒的平均请求数,指定了计算速率的时间窗口 - 查询 Gauge 指标 - go_goroutines - ![](https://an-img.oss-cn-hangzhou.aliyuncs.com/2026/04/10/20260410192303151.png,400,400) - go_memstats_alloc_bytes/1024 - 是 Prometheus 进程当前分配的内存字节数,/1024 转换为 KB - ![](https://an-img.oss-cn-hangzhou.aliyuncs.com/2026/04/10/20260410192453732.png,400,400) - 观察 Histogram 指标的结构 - prometheus_http_request_duration_seconds_bucket - ![](https://an-img.oss-cn-hangzhou.aliyuncs.com/2026/04/10/20260410192606038.png,400,375) - 每个时间序列都带有 le 标签,表示 less than or equal - 计算 P90 - histogram_quantile(0.9, rate(prometheus_http_request_duration_seconds_bucket[5m])) - ![](https://an-img.oss-cn-hangzhou.aliyuncs.com/2026/04/10/20260410193139016.png,400,375) - 构建完整的 Dashboard - 创建 Dashboard - Dashboard -> New -> New dashboard,save dashboard 命名为 "Day 4-Prometheus 自监控" - 面板 1:Prometheus 运行状态 (Stat 面板) - Add -> Visualization,面板类型从 Time series 切换成 Stat - 查询输入 up{job="prometheus"} - Title 填运行状态 - Value mapping 部分,点击 Add Value mapping,添加 1->正常运行/绿色,0->已宕机/红色 - ![](https://an-img.oss-cn-hangzhou.aliyuncs.com/2026/04/10/20260410194252635.png,300,300) - 面板 2:每秒请求数 (Time Series 面板) - 查询输入 rate(prometheus_http_requests_total[5m]) - Title "HTTP 请求速率 (按 handler)" - Legend 输入 {{handler}} - ![](https://an-img.oss-cn-hangzhou.aliyuncs.com/2026/04/10/20260410194559215.png,300,300) - 面板 3:内存使用量 (Time Series 面板) - go_memstats_alloc_bytes / 1024 / 1024 - Title 填内存使用 (MB),右侧设置找到 Standard options -> Unit,搜索并选择 Megabytes (SI),设置 Decimals 为 1,让数值显示一位小数 - ![](https://an-img.oss-cn-hangzhou.aliyuncs.com/2026/04/12/20260412093035837.png,440,340) - 面板 4: Goroutine 数量 (Gauge 面板) - 这里的 Gauge 是 Grafana 的面板类型,不是 Prometheus 的指标类型,同名但是概念不同 - go_goroutines - Title 填 Goroutines,在 Standard options 中设置 Min 为 0,Max 为 100 - 找到 Threshholds 部分,设置阈值:绿色为基础色,50 以上为黄色,80 以上为红色 - ![](https://an-img.oss-cn-hangzhou.aliyuncs.com/2026/04/12/20260412093435497.png,420,340) - 面板 5: 请求耗时 P90 (Stat 面板) - 新建 Stat - histogram_quantile(0.9, rate(prometheus_http_request_duration_seconds_bucket[5m])) - Title 请求耗时 P90,在 Standard Options -> Unit 中选择 seconds - 布局建议 - 第一行放小面板-运行状态和请求耗时 P90 这种单值面板适合窄小的尺寸 - 第二行放 HTTP 请求速率这种需要横向展开的时序图 - 第三行并排放内存使用和 Goroutines - 面板探索设置 - Standard options 包含最常用的显示设置:Unit 单位、Min/Max 范围、Decimals 小数位数、Color Schema 配色方案 - Threshholds 用颜色编码数值区间,帮助快速判断好坏 - Overrides 允许为特定的时间序列覆盖全局设置 核心技能 - 核心技能 - PromQL - 两种向量 - PromQL 的基石 - 即时向量 - 即时向量返回每条时间序列在某一时刻的采样值,之前的大部分查询都是即时向量 - up/go_goroutines/prometheus_http_requests_total{handler="/metrics"} - 每条查询返回的是当前时刻每条匹配的事件序列的最新值 - 切换到 Table 视图看的更清晰 - 范围向量 - 返回每条时间序列在一个时间窗口内的所有采样值,语法是在指标名后加 [时间窗口] - prometheus_http_requests_total{handler="/metrics"}[5m] - 返回的是过去 5 分钟内的所有采样点 - 范围向量不能直接绘图,只能作为函数的输入转化为即时向量,最常见的就是 rate() - 时间窗口的写法 - 支持这些时间单位 s(秒)、m(分钟)、h(小时)、d(天)、w(周)、y(年) - 可以组合使用 [1h30m] 表示 1小时 30 分钟 - 窗口越短,曲线越尖锐,能捕捉到更细微的波动,窗口越长,曲线越平滑,反映的是更长期的趋势 - 5m 是最常用的窗口 - 核心函数 - rate() - 计算 Counter 在指定时间窗口内的平均增长率,会自动处理 Counter 重置 - rate(prometheus_http_requests_total[5m]) - irate() - irate() 只用时间窗口内最后两个数据点计算瞬时速率,对突刺更敏感 - irate(prometheus_http_requests_total[5m]) - 因为只看两个点,结果不够稳定,一般不用于告警规则 - increase() - 返回 Counter 在时间窗口内的总增长量 - increase(prometheus_http_requests_total{handler="/metrics"}[1h]) - 告诉你过去 1 h /metrics 端点一共被请求了多少次 - sum() - sum() 把多条时间序列的值加在一起 - sum(rate(prometheus_http_requests_total[5m])) - 把所有 handler、所有 code 的请求速率加总,得到一个总 QPS - sum by (code) (rate(prometheus_http_requests_total[5m])) - 可以用 by 子句按某个标签维度分组 - avg() - 有多个实例时,avg() 能给出平均值 - 也支持 by 分组 - count() - count(prometheus_http_requests_total) - 返回的是有多少条时间序列匹配这个查询 - 数学运算与常用模式 - 算术运算 - 支持标准的数学运算符 - go_memstats_alloc_bytes / 1024 / 1024 - 两个指标之间也能做运算 HTTP 请求的错误率 - sum(rate(prometheus_http_requests_total{code=~"5.."}[5m]))/sum(rate(prometheus_http_requests_total[5m])) - 比较运算 - go_goroutines > 30 - 聚合函数 - min(prometheus_http_request_duration_seconds_sum) - max(prometheus_http_request_duration_seconds_sum) - topk(3, prometheus_http_requests_total) - bottomk(2, prometheus_http_requests_total) - 标签匹配的四种方式 - 精确匹配 = - prometheus_http_requests_total{handler="/metrics"} - 精确排除 != - prometheus_http_requests_total{handler!="/metrics"} - 正则匹配 - prometheus_http_requests_total{handler=~"/api/v1/.*"} - 正则排除 - prometheus_http_requests_total{handler!~"/api/v1/.*"} - 聚合-by 与 without - by 保留指定标签 - by 告诉聚合函数"按这些标签分组,其余标签全部丢弃" - sum by (code) (rate(prometheus_http_requests_total[5m])) - 可以按多个标签分组 - sum by (code, handler) (rate(prometheus_http_requests_total[5m])) - without 排除指定标签 - 按除了这些标签之外的所有标签分组 - sum without (instance) (rate(prometheus_http_requests_total[5m])) - 会移除 instance 标签维度,保留其他所有标签。效果是把同一个 job 下不同实例的数据合并。 - 什么时候用 by/without - 关心的维度少,想丢弃的维度多,用 by - 时间偏移与数据对比 - offset 查看历史数据 - go_goroutines offset 1h - 返回的是 1 小时前的 goroutine 数量。单独看这个值意义不大,但它可以用来做环比对比。 - 计算同比/环比变化 - 用当前值减去历史值,就能得到变化量 - ``` rate(prometheus_http_requests_total{handler="/metrics"}[5m]) - rate(prometheus_http_requests_total{handler="/metrics"}[5m] offset 1h) ``` - 深入 Grafana 面板类型 - 面板选择的思考方式 - 单个关键数值 - Stat 或 Gauge - Stat 适合展示当前值,Gauge 适合展示有范围的百分比 - 随时间变化的趋势 - TimeSeries - 最常用的面板类型,适合 QPS、延迟、内存使用等需要观察时间趋势的指标 - 多项的大小对比 - Bar chart - 适合按维度比较大小,例如各服务的请求量排行、各状态码的占比 - 数据的分布密度 - Heatmap - 用颜色深浅表示值的密集程度,非常适合展示请求延迟分布随时间的变化,Histogram 数据的最佳搭档 - 结构化明细数据 - Table - 展示多列明细数据,适合需要精确数值和排序的场景 - TimeSeries 进阶 - 面板 1:多线对比 - 查询:rate(prometheus_http_requests_total[5m]) - Legend 填写 {{handler}} {{code}} - 右侧设置 Graph Styles,Line Width:2, Fill opacity:10(线条下方增加淡填充色) - Tooltip,设置为 All,Sort order: Descending - ![](https://an-img.oss-cn-hangzhou.aliyuncs.com/2026/04/12/20260412104830826.png,600,220) - 面板 2:堆叠面积图 - 查询:sum by (code) (rate(prometheus_http_requests_total[5m])) - Title: 请求量堆叠(按状态码) - Legend {{code}}、Graph styles,找到 Stack series 设置为 Normal - Fill opacity 调整到 50 - ![](https://an-img.oss-cn-hangzhou.aliyuncs.com/2026/04/12/20260412105336324.png,540,300) - Stat 面板的丰富配置 - 面板 3:带 sparkline 的 Stat - sum(rate(prometheus_http_requests_total[5m])) - Title:总 QPS,Stat Stype: Grap mode -> Area (在大数字下面显示迷你趋势线-sparkline) - Standard options -> Unit requests/sec (ps),Decimals=2 - ![](https://an-img.oss-cn-hangzhou.aliyuncs.com/2026/04/12/20260412105937729.png,551,342) - 面板 4:多值 Stat - 查询 A:sum(rate(prometheus_http_requests_total{code="200"}[5m])) - 查询 B:sum(rate(prometheus_http_requests_total{code!="200"}[5m])) - Legend:A -> 成功请求,B -> 非 200 请求 - Orientation -> Horizontal 两个值会并排显示 - Thresholds B 设置 0 为绿色,0.1 以上为红色 - ![](https://an-img.oss-cn-hangzhou.aliyuncs.com/2026/04/12/20260412110948641.png,541,320) - BarChart 维度对比 - 面板 5:按 handler 排行的柱状图 - sort_desc(sum by (handler) (increase(prometheus_http_requests_total[1h]))) - 这条查询计算过去 1 h内每个 handler 的请求总量,按降序排列 - Bar chart 显示效果不理想,PromQL 返回的是时间序列数据,Bar chart 更适合展示某一时刻的快照 - 在查询编辑器的 Options 区域,把 Format 改为 Table,把 Type 改为 Instant - 在右侧面板设置中,"Orientation" 改为 "Horizontal"(横向柱状图) - ![](https://an-img.oss-cn-hangzhou.aliyuncs.com/2026/04/12/20260412122419997.png,600,170) - Table 结构化明细 - 面板 6:指标明细表 - sort_desc(sum by (handler, code) (increase(prometheus_http_requests_total[1h]))) - 在 Option 中设置 Format 为 Table,Type 为 Instant - Overrides -> Add field override -> Fields with name -> 选择 value - 点击 "Add override property" → 在搜索框中输入 "cell" → 选择 "Cell type",然后设为 "Colored background" - 颜色效果依赖 Threshold 的配置 - ![](https://an-img.oss-cn-hangzhou.aliyuncs.com/2026/04/12/20260412151854303.png,422,282) - Heatmap 分布密度 - Heatmap 是 Histogram 数据的最佳搭档 - 用颜色深浅表示数据密度,X 轴是时间,Y 轴是数值区间 - 面板 7:请求延迟分布热力图 - sum(increase(prometheus_http_request_duration_seconds_bucket[5m])) by (le) - 把所有 handler 的延迟 bucket 数据汇总,le 标签表示 bucket 的上界 - Format -> Heatmap - Yaxis,Unit -> seconds,Colors -> Scheme 选择不同的配色方案 - Title:请求延迟分布 - ![](https://an-img.oss-cn-hangzhou.aliyuncs.com/2026/04/12/20260412152205566.png,530,310) - Dashboard 布局优化 - 第一行放概览指标(Stat 面板) - 第二行放趋势图,Time Series 面板(多线对比,堆叠面积图) - 第三方放分析图,Bar Chart 和 Heatmap 并排放在第三排 - 第四行放明细表,Table 面板放在最底部 - Dashboard 变量与交互设计 - 理解变量的作用 - 变量允许用户通过下拉框切换要查看的数据维度,而不用为每个维度创建单独的面板 - 创建一个变量 - 进入变量设置 - Dashboard 页面 -> Settings -> Variables -> Add variable - 创建 handler 变量 - Name 填 handler,这是变量的标识符,在查询中用 $handler 引用它 - Label 填接口,这是下拉框旁边显示的中文标签 - Type 选 Query,这表示变量的候选值从数据源查询获得 - Data source 选 prometheus - Query Type 选 Lable Values - Label 选 handler - Metric 选 prometheus_http_requests_total - 告诉 Grafana 从 prometheus_http_requests_total 中提取所有不同的 handler 标签值作为下拉选项 - ![](https://an-img.oss-cn-hangzhou.aliyuncs.com/2026/04/12/20260412153052768.png,280,300) - ![](https://an-img.oss-cn-hangzhou.aliyuncs.com/2026/04/12/20260412153132293.png,400,150) - 在面板使用变量 - 添加 Time Series 面板 - 查询:rate(prometheus_http_requests_total{handler="$handler"}[5m]) - Legend 填 {{code}},Title 填 $handler 请求速率,变量在 title 中也能用 - ![](https://an-img.oss-cn-hangzhou.aliyuncs.com/2026/04/12/20260412153254711.png,530,340) - 多选与全选 - Dashboard Settings -> Variables -> handler 变量进行编辑 - Selection options 部分,勾选 Multi-value,允许用户同时勾选多个 handler - 勾选 Include All option ,在下拉框增加 ALL 选项 - ![](https://an-img.oss-cn-hangzhou.aliyuncs.com/2026/04/12/20260412153554336.png,350,190) - 修改面板查询为 rate(prometheus_http_requests_total{handler=~"$handler"}[5m]) - Grafana 会将多选处理为正则表达式 - ![](https://an-img.oss-cn-hangzhou.aliyuncs.com/2026/04/12/20260412153751767.png,430,350) - 内置变量 - Grafana 提供了一些内置变量,不需要手动创建,可以直接在查询中使用 - $__interval 和 $__rate_interval - 是最重要的内置变量 - 会根据 Dashboard 的时间范围和面板宽度,自动计算一个合理的时间步长 - $__rate_interval 的智能之处 - 当查看过去一小时的数据,可能是 1 分钟 - 查看过去 7 天的数据,可能是 15 分钟 - 无论时间范围怎么变,都能返回合理密度的数据点 - $__rate_interval 和 $interval 的区别 - $__rate_interval 至少覆盖 4 个采样周期,更适合搭配 rate() 使用 - $__interval 纯根据面板像素密度计算步长 - $__range - 表示当前 Dashboard 选择的完整时间范围,在 increase 中有用 - increase(prometheus_http_requests_total{handler=~"$handler"}[$__range]) - $__dashboard 和 $__name - $__dashboard 是当前 Dashboard 的名称,$__name 是面板的名称 - 一般用在告警通知模板中,不常用在查询里 - 用 Go 构建可观测 HTTP 服务 - 编写带 Prometheus 指标的 Go 服务 - 构建一个模拟的订单 API 服务,会暴露:请求计数 (Counter)、请求延迟 (Histogram)、当前处理中的请求数 (Gauge)。 - 指标设计 - myapp_http_requests_total(Counter) - 按 method、endpoint、status 三个维度记录请求总数 - Counter 是因为请求数只增不减 - 三个标签维度可以让你从不同角度分析:按接口看、按状态码看,按 HTTP 方法看 - myapp_http_request_duration_seconds(Histogram) - 记录请求延迟分布 - 用 Histogram 而不是 Summary,是因为要计算不同分位 - myapp_http_requets_in_flight(Guage) - 当前正在处理的请求数 - Guage 是由于这个值可增可减 - myapp_orders_created_total(Counter) - 业务指标,按商品类型记录订单数 - myapp_order_queue_size(Guage) - 模拟的业务指标,队列深度 - 启动服务并验证 - 启动服务 - ```bash docker-compose down docker-compose up -d --build ``` - --build 参数会重新构建 myapp 镜像。首次构建需要下载 Go 依赖 - 验证 myapp - 访问 http://localhost:8080/health,看到 {"status": "healthy"} - 访问 http://localhost:8080/metrics,看到自定义指标和 Go 运行时指标 - 验证 prometheus 抓取 - 访问 http://localhost:9090/targets,看到两个 target:prometheus 和 myapp,状态是 up - 查询 myapp_http_requests_total,确认有数据返回 - 构建应用监控 Dashboard - 创建新 Dashboard "My GoApp-监控面板" - 创建变量 - Settings -> Variables - 添加 endpoint 变量: - Type -> Query,Data source -> Prometheus - query type -> label values,Label -> myapp,metric->myapp_http_requests_total - Multi-value 和 Include All option 勾选 - 面板 1:应用状态 - 查询 up{job="myapp"} - 类型 Stat,配置 Value mappings: 1->在线(绿色),0->离线(红色),Title->应用状态 - ![](https://an-img.oss-cn-hangzhou.aliyuncs.com/2026/04/12/20260412170743853.png,250,140) - 面板 2:总 QPS (state + sparkline) - 查询:sum(rate(myapp_http_requests_total{endpoint=~"$endpoint"}[$__rate_interval])) - 类型选 Stat,Graph mode 设置为 Area,Unit 设置为 reqps,Title 填写 QPS - ![](https://an-img.oss-cn-hangzhou.aliyuncs.com/2026/04/12/20260412170957566.png,250,140) - 面板 3:错误率 (Stat) - 查询: ``` sum(rate(myapp_http_requests_total{endpoint=~"$endpoint", status=~"4..|5.."}[$__rate_interval])) / sum(rate(myapp_http_requests_total{endpoint=~"$endpoint"}[$__rate_interval])) * 100 ``` - Unit 设为 "percent (0-100)" - Thresholds 设置:绿色为基础,5 以上黄色,10 以上红色。Title 填 "错误率"。 - ![](https://an-img.oss-cn-hangzhou.aliyuncs.com/2026/04/12/20260412171243965.png,250,150) - 面板 4:请求速率按状态码(Time Series 堆叠) - 查询:sum by (status) (rate(myapp_http_requests_total{endpoint=~"$endpoint"}[$__rate_interval])) - Legend 填 {{status}}。Graph styles 中 Stack series 设为 "Normal",Fill opacity 设为 40。 - Overide: 添加 "Fields with name" 为 "200",Override 属性选 "Color",设为绿色。重复操作,给 "500" 设红色,"400" 设黄色,"201" 设浅绿色。 - Title 填 "请求速率(按状态码)" - ![](https://an-img.oss-cn-hangzhou.aliyuncs.com/2026/04/12/20260412171817077.png,250,150) - 面板 5:请求延迟分位数 (Time Series) - 同时展示 P50,P90,P99 三条线 - 查询 A: - Legend P50 - histogram_quantile(0.5, sum by (le) (rate(myapp_http_request_duration_seconds_bucket{endpoint=~"$endpoint"}[$__rate_interval]))) - 查询 B: - Legend P90 - histogram_quantile(0.9, sum by (le) (rate(myapp_http_request_duration_seconds_bucket{endpoint=~"$endpoint"}[$__rate_interval]))) - 查询 C - Legent P99 - histogram_quantile(0.99, sum by (le) (rate(myapp_http_request_duration_seconds_bucket{endpoint=~"$endpoint"}[$__rate_interval]))) - Unit: seconds,Title:请求延迟分位数 - 通过 Overrides 给三条线设置不同的视觉效果:P50 绿色实线,P90 用黄色实线,P99 用红色实线 - ![](https://an-img.oss-cn-hangzhou.aliyuncs.com/2026/04/12/20260412225759292.png,310,150) 告警与日志 - 告警与日志 - Grafana 的告警系统 - 理解 Grafana 告警架构 - ![](https://an-img.oss-cn-hangzhou.aliyuncs.com/2026/04/18/20260418104323716.png,618,330) - 配置 Contact Point - 左侧导航栏 Alerting -> Contact points -> Create contact points - ![](https://an-img.oss-cn-hangzhou.aliyuncs.com/2026/04/18/20260418104545088.png,277,414) - ![](https://an-img.oss-cn-hangzhou.aliyuncs.com/2026/04/18/20260418104623733.png,400,150) - Name 填学习用 Webhook,Integration 选 Webhook,URL 填 http://myapp:8080/health - 用 Go 应用的健康检查端点作为 Webhook 接收地址,不会真正处理报警,但能验证通知是否发出 - ![](https://an-img.oss-cn-hangzhou.aliyuncs.com/2026/04/18/20260418104854183.png,400,250) - 内置了一个默认的 grafana-default-email Contact Point,生产环境会配置 Email、Slack、DingTalk、PagerDuty 等 - 创建第一条告警规则(监控 Go 应用的错误率) - 创建告警规则 - Alerting -> Alert Rules -> New alert rule - ![](https://an-img.oss-cn-hangzhou.aliyuncs.com/2026/04/18/20260418105203977.png,400,350) - 命名规则 - Rule name 填入应用错误率过高 - 定义查询和条件 - 在 Define query and alert condition 部分,会看到一个查询编辑器 - 查询 A: 选择数据源 Prometheus,切换到 Code 模式,输入 - sum(rate(myapp_http_requests_total{status=~"4..|5.."}[5m])) / sum(rate(myapp_http_requests_total[5m])) * 100 - 计算的是过去 5 分钟内的错误率百分比 - 下方 Set Alert Condition,设置 WHERE QUERY IS ABOVE 5 - ![](https://an-img.oss-cn-hangzhou.aliyuncs.com/2026/04/18/20260418105855803.png,400,125) - 通过 preview alert rule condition 可以预览报警状态,如果已经满足条件,会显示为 firing - 配置评估行为 - 在 Add foler and labels 下选择或创建一个文件夹 (学习告警) - Evaluation 创建一个新的,叫做默认评估组,评估间隔 1m,每分钟评估一次 - Pending period 填写 2,这个参数很重要,表示条件必须持续满足 2 分钟后才会真正触发告警 - 避免了短暂的尖峰导致的误报,告警会先进入 Pending 状态,2 分钟后才会变为 Firing - 添加标签和注释 - 在 Add folder and labels 部分 - 添加一个 Label: key 填 severity,value 填 warning。标签用于告警路由 - Notification Policy 可以决定把报警发给谁 - 在 Configure notification message 中:Summary 填应用错误率超过 5% - Description 填当前错误率为 {{$value.B.value}},已超过 5% 的告警阈值 - {{ $value.B.value }} 是模板变量,会在告警触发时被替换为实际的错误率数值。 - 理解告警状态与查看告警 - 在 Alerting -> Alert Rules 可以查看所有规则的状态 - ![](https://an-img.oss-cn-hangzhou.aliyuncs.com/2026/04/18/20260418174616492.png,300,100) - 在 Dashboard 中添加一个 Alert list 面板,会展示当前活跃的所有告警 - Loki 与日志监控 - Loki 的设计哲学 - 传统日志系统(ElasticSearch)会对日志内容做全文索引,每个词都被索引,搜索速度快但是存储和计算成本高 - Loki 只索引日志的元数据标签,不索引日志内容本身,查询先通过标签快速定位到相关的日志流,再在这些流中做文本搜索 - 部署 Loki 和 Promtail - Loki 是日志存储和查询引擎,Promtail 是日志采集代理,类似 Node Exporter 采集指标,Promtail 采集日志 - 更新 docker-compose.yml - 在底部的 volumns 部分添加 loki_data: - 创建 Promtail 配置文件 - 放在 grafana-learning/promtail 目录下 - 理解 Promtail 配置 - clients 指定了日志推送的目标地址 - Loki 的 api 端点 - 和 Prometheus 的拉取模型不同,Loki 使用推送模型:Promtail 主动把采集到的日志推送给 Loki - scrape_configs 定义了日志采集源 - 本次使用了 docker_sd_configs,通过 Docker Socket 自动发现所有运行中的容器并采集它们的标准输出日志 - relabel_configs 对标签做转换 - __meta_docker_container_name 是 Docker 自动提供的元数据,映射到 container 标签,就能按容器名筛选日志 - 验证 - 访问 http://localhost:3100/ready,返回 ready 说明 Loki 已启动 - docker-compose logs promtail --tail 20 - 能看到 Successfully connected to Loki 的日志 - 在 Grafana 中添加 Loki 数据源 - 在 Grafana 中点击 Connections -> Data sources -> Add data source,搜索 Loki - Connection URL 填写 http://loki:3100 - 用 Explore 查看日志 - 点击左侧 Explore,在顶部数据源下拉框中切换到 Loki - 第一次查询 - Loki 查询语言是 LogQL,结构和 PromQL 非常相似 - 最基本的查询是用花括号指定标签筛选:{container="myapp"} - 输入后点击 Run query - ![](https://an-img.oss-cn-hangzhou.aliyuncs.com/2026/04/18/20260418210037672.png,430,360) - 按容器筛选 - {container="prometheus} - 查看所有容器的日志 - {container=~".+"} - =~".+" 是正则匹配 - LogQL 管道操作 - 可以在标签筛选的基础上,用 | 符号串联多个处理步骤 - 文本过滤 - 只看包含 error 的日志 - {container="myapp"} |= "error" - |= 表示包含,!= 表示不包含 - {container="myapp"} != "health" - 正则过滤 - {container="myapp"} |~ "status.*500" - |~ 是正则匹配,!~ 是反向正则过滤 - 管道串联 - 多个过滤条件可以串联,形成管道 - {container="myapp"} |= "error" != "health" - 管道中的每一步都在上一步的结果基础上继续过滤 - JSON 解析 - 如果 Go 应用输出的是 JSON 格式的日志,可以用 | json 进行自动解析 - {container="myapp"} | json - 解析后,json 中的每个字段都变成了可筛选的标签 - {"level":"error"} - {container="myapp"} | json | level="error" - 行格式化 - | line_format 可以重新格式化日志的显示方式 - {container="myapp"} | line_format "{{.container}}: {{.__line__}}" - 从日志提取指标 - Metrics from Logs 能让你不需要在代码埋点就能从日志中生成监控指标 - 计算日志量速率 - rate({container="myapp"}[5m]) - 按容器分组统计 - sum by (container) (rate({container=~".+"}[5m])) - count_over_time - 计算时间窗口内的日志总行数 - count_over_time({container="myapp"} |= "error" [1h]) - 在 Dashboard 中整合日志面板 - 实时日志流 - 右侧面板类型选择 Logs,数据源选 Loki - 查询: {container="myapp"} != "metrics",排除 /metrics 端点的请求日志 - 在右侧面板设置中,找到 Log details 区域,确保 Show time 和 Wrap lines 都开启 - Tilte 填应用日志,这个面板会实时滚动 Go 应用的最新日志 - ![](https://an-img.oss-cn-hangzhou.aliyuncs.com/2026/04/18/20260418220304056.png,260,150) - 错误日志速率 (Time Series 面板) - 新建 Time Series 类型,数据源选择 Loki - rate({container="myapp"} |= "error" [5m]) - Title 填写错误日志速率,Unit 设置为 logs/sec - LogQL 进阶与指标日志联动 - 更多日志解析器 - logfmt 解析器 - logfmt 是 Go 生态常见的日志格式,形如 level=info msg="request handled" method="GET" - 如果日志是这种键值对格式,可以用:{container="myapp"} | logfmt - 解析后每个键值对都变成可以筛选的标签 - 当前 Go 应用用的是标准 log.Println 输出,不是 logfmt 格式 - pattern 解析器 - pattern 是最灵活的解析器,允许用模板来描述日志的结构,提取出其中的字段 - 语法是使用 <field_name> 来做占位符 - {container="myapp"} | pattern `<_> "<method> <path> <_>" <status> <_>` - 这条查询假设日志中包含类似 "GET /api/orders HTTP/1.1" 200 ... 的内容 - 会把 HTTP 方法提取为 method,路径提取为 path,状态码提取为 status - <_> 是丢弃占位符,匹配但是不保存 - regxp 解析器 - {container="myapp"} | regexp `(?P<method>GET|POST|PUT|DELETE) (?P<path>/\S+)` - (?P<name>...) 是命名捕获组,提取的值会变成同名标签,正则解析器功能最强但是性能最差 - 标签提取与 label_format - line_format 重新格式化日志显示 - line_format 用 Go 模板语法重新定义每行日志的显示内容 - {container=~"myapp|prometheus"} | line_format "[{{.container}}] {{.__line__}}" - 这会在每行日志前加上方括号包裹的容器名 - line_format 转换标签 - label_format 可以基于现有标签生成新标签或修改标签名 - {container="myapp"} | label_format short_id="{{.container_id | trunc 12}}" - 这条查询把容器 ID 截取前 12 位,存为 short_id 标签,trunc 12 是 Go 模板的字符串截取函数 - drop 和 keep 标签 - 当解析器提取了太多标签导致查询杂乱,可以用 drop 丢弃不需要的标签,或用 keep 只保留需要的 - {container="myapp"} | json | keep container, level, msg 附录 环境配置 docker-compose.yml …

Flink 复习

Flink 整体认知 - Flink 整体认知 - 认识 Flink - Flink 是什么 - Flink 是一个对于有界和无界数据流进行有状态计算的框架和分布式处理引擎 - 框架:Flink 不是一个单独的软件命令,而是一套开发 + 运行的系统 - Flink 和 Hadoop/Spark/Storm 的区别 - 基本定义 - Hadoop 是什么 - 大数据基础设施体系 - HDFS:分布式存储 - MapReduce:离线批处理计算 - YARN:资源管理 - 早期大数据时代的“存储+资源管理+离线计算“基础平台 - Spark 是什么 - 通用的大数据计算引擎 - 流处理主要通过 Structured Streaming - 更偏通用分析平台,批处理很强,流处理也能做 - Storm 是什么 - 是一个分布式实时计算系统 - 较早一代的纯流式处理框架,比 Flink 早 - 比较维度 - 主要定位 - Hadoop - 海量数据存储 - 离线批处理 - 高吞吐,不强调低延迟 - Spark - 通用大数据引擎 - 强批处理 - 可扩展到 SQL、ML、Streaming - Flink - 有状态流处理 - 事件时间 - 流批统一 - 更强实时计算语义 - 处理模型 - Hadoop: Batch - 读一批数据->map->shuffle->reduce->输出结果 - Spark: Batch + Micro-batch - Flink: Native Streaming,批流统一 - 延迟特征 - Hadoop - 延迟高,通常分钟级甚至更长 - Spark - micor-batch 延迟比原生流高一点 - Flink - 低延迟实时计算 - 状态能力 - Hadoop - 不擅长生命周期有状态流计算 - Spark - 能做状态处理,但不是以 stateful streaming 为核心 - Flink - stateful computations over data streams - exactly-once state consistency - 什么是批处理、流处理、实时计算 - 批处理 - 定义:先把一段时间内的数据收集起来,等数据攒够一批后,再统一进行计算 - 特点:先到齐,再处理、延迟高、吞吐高、程序有结束点 - 流处理 - 定义:把数据看成一个持续到来的事件流,数据一到,系统就持续处理 - 特点:连续不断、持续进行、低延迟、要处理历史记忆 - 实时计算 - 定义:从数据产生,到计算结果可用,时间尽可能短 - 实时计算不是来了就必须零延迟,而是在业务可接受的时间范围内尽快给出结果 - Flink 的应用场景 - 事件驱动应用 - 系统不是定时查数据库,而是收到事件才触发计算、状态更新或外部动作 - 典型业务 - 欺诈检测 - 异常检测 - 规则告警 - 业务流程监控 - 数据分析应用 - 从原始数据中提取信息和洞察 - 典型业务 - 每分钟订单量 - 热门榜单 - 实时数仓 - 离线 + 实时一体分析 - 数据管道与 ETF - 从上游系统读数据,做清洗、转换、补全、标准化、过滤、路由,再写到下游系统 - 典型业务 - Kafka 到 Kafka - Kafka 到数据库/搜索引擎/湖仓 - CDC + 实时同步 - Flink 基本架构 - JobManager、TaskManager - 最小 Flink 集群 - ![](https://an-img.oss-cn-hangzhou.aliyuncs.com/2026/03/24/20260324174529584.png,474,301) - 在本地或某台机器上提交作业 - JobManager 接收这个作业,分析要怎么跑 - 把真正要执行的子任务分发给多个 TaskManager - TaskManager 在各自机器上并行处理数据 - 什么是 JobManager - 是 Flink 集群的控制中心 - 负责接收/解析/调度/监控/作业、协调故障恢复、管理元信息 - 什么是 TaskManager - 是 Flink 集群里真正干活的 worker - 主要负责执行子任务/占用和提供计算资源/与其他 TaskManger 传输数据/向 JobManager 汇报状态 - Slot 是什么 - 定义 - Flink 用 Slot 告诉调度器:这台 TaskManager 最多能接多少份执行任务 - 为什么需要 Slot - 控制一个 TaskManager 能接多少任务 - 做资源切分 - 在隔离与利用率之间做平衡 - Slot 和 TaskManager 的关系 - 一个 TaskManager 可以有多个 Slot - Slot 是 TaskManager 提供给集群调度的执行容量 - Slot 是资源申请和任务放置的基本单位之一 - Slot 和并行度的关系 - 并行度指的是一个算子会拆成多少个并行子任务 - 执行这些子任务需要 Slot - 在默认 slot sharing 开启时,作业大致需要的 slot 数 = 该作业的最高并行度 - JobGraph、ExecutionGraph - 什么是 JobGraph - Flink 作业的逻辑图/逻辑执行计划 - JobGraph 里面的点,叫做 JobVertex,它是一个逻辑阶段/一个逻辑算子节点 - JobGraph 不强调每个并行子任务 - 什么是 ExecutionGraph - 是 JobGraph 经过并行展开后的实际执行拓扑 - ExecutionGraph 的核心点是 ExecutionVertex - 并行度是什么 - 本质 - Flink 算子可以想象成一个类似读 Kafka/做 map 转换/做窗口聚合的工作 - 并行度决定一个算子会被拆分成几个 subtask 一起做 - 为什么需要并行度 - 一份工作只让一个实例做,处理能力有限 - 提高并行度,本质就是把工作拆开,让多个 subtask 同时处理 - 一个 flink 作业如何运行起来 - 总流程 - 写 Flink 程序,调用 execute() - Client 负责提交作业 - 程序先形成逻辑数据流图 - JobManager 接收作业 - JobManager 把 JobGraph 转成 ExecutionGraph - 调度器根据并行度和可用 slot 做任务部署 - TaskManager 真正执行 subtask - 运行中持续监控、处理状态、必要时恢复故障 - 编写代码时,发生了什么 - 写的是逻辑处理链路,不是真正的分布式执行指令 - 调用 execute() 后,作业才真正进入提交流程 - Client 做了什么 - Client 是提交入口,负责将应用提交到集群 - Client 不是主要计算节点 - JobGraph 在什么时候出现 - 代码先被整理为数据流图,也就是 JobGraph - JobGraph 偏逻辑层,还没有真正展开成每个 subtask,不是最终的并发执行形态 - JobManager 接手后做什么 - 接收作业 - 管理作业生命周期 - 把逻辑图变成执行图 - 协调资源和任务部署 - Execution Graph 怎么来的 - ExecutionGraph 是 JobGraph 的并行化版本 - 并行度在这里真正展开 - 运行状态也在这里变得具体 - 调度阶段发生什么 - 要跑多少个 subtask - 这些 subtask 放到哪些地方跑 - 数据怎么在它们之间流动 - slot 发挥什么作用 - TaskManager 提供 slots - 调度器找空闲 slot - 默认 slot sharing 会影响所需 slot 数 - TaskManager 真正干活时发生了什么 - TaskManager 执行 subtask - 每个 subtask 处理自己那部分的输入数据 - 上下游 subtask 之间可能发生网络传输 - 状态、窗口、定时器等能力都在这里真正运作 - 作业运行中,JobManager 还在做什么 - 跟踪任务状态 - 监控资源与任务健康情况 - 失败时触发恢复 - 作业失败了会怎样 - JobManager 感知失败 - 调度器决定如何恢复 - TaskManager 上的任务可能被重新部署 - 搭建开发环境 - Maven 项目骨架 - POM 文件 - ![](https://an-img.oss-cn-hangzhou.aliyuncs.com/2026/03/25/20260325093933331.png,434,339) - fink-streaming-java - 是 DataStream API 入门最核心的依赖之一 - 编写 StreamExecutionEnvironment、DataStream 这类代码会用到它 - flink-clients - 用于本地执行、提交作业相关能力 - DataStream API 的基本项目结构 - 最小程序骨架 - ![](https://an-img.oss-cn-hangzhou.aliyuncs.com/2026/03/25/20260325094745793.png,603,238) - ![](https://an-img.oss-cn-hangzhou.aliyuncs.com/2026/03/25/20260325095338401.png,509,191) - 代码详解 - StreamExecutionEnvironment - 这是 DataStream 程序的入口 - StreamExectionEnviroment 是所有 Flink 程序的基础,通过 getExecutionEnvironment() 获取 - 是整个 Flink 程序的运行上下文或总入口 - 负责创建 source、串起数据流、设置并行度/checkpoint 等运行参数、触发执行 - Source - Source 就是数据从哪里来 - DataStrema 最初是从各种 source 创建出来的 - Transformation - 就是对数据流做处理 - DataStream 程序是对数据流做 transformation 的程序 - 基础类别 - map:一条变一条 - filter:按条件过滤 - flatMap:一条变多条 - keyBy:按 key 分组 - sum/reduce:聚合 - Sink - Sink 就是结果往哪里去 - 结果通过 sink 返回,可以写入文件、标准输出等 - print() 可以直接在控制台打印输出 - sinkTo(xxx) 可以写入自定义的目的 - execute() - Flink Job 是在调用 execute() 时创建并提交的 - DataStream 程序最后一步就是触发程序执行 - 跑通 WordCount - 最小 WordCount 代码 - ![](https://an-img.oss-cn-hangzhou.aliyuncs.com/2026/03/25/20260325133401924.png,572,459) - ![](https://an-img.oss-cn-hangzhou.aliyuncs.com/2026/03/25/20260325133433816.png,92,160) - 代码解析 - 创建执行环境 - StreamExecutionEnvironment 是每个 DataStream 程序的起点,表示开始描述一条 Flink 数据处理流水线了 - 创建输入流 - flatMap 把每一行拆分成单词 - flatMap 可以对一个输入产生任意多输出 - Tuple2<String, Integer> - 是 Flink 常见的二元组类型 - keyBy(value -> value.f0):按单词分组 - keyBy 会把流按照 key 逻辑分区,所有相同 key 会进入同一个分区 - sum(1):对第二个字段求和 - DataStreamAPI 基础 - Source、Transformation、Sink - Source - Flink 程序接收数据的地方 - 可以来自内存集合、文件、消息队列、socket 等 - 集合 Source - DataStream<String> stream = env.fromElements("a","b","c"); - DataStream<Integer> nums = env.fromElements(1,2,3); - 真实生产的 Source - Kafka - 文件 - Socket - 自定义 source - 其他外部系统 connector - Transformation - Operators 会把一个或多个 DataStream 转换成新的 DataStream - 程序可以把这些变换组合成复杂的数据流拓扑 - map - 一条输入变成一条输出 - DataStream<Integer> result = nums.map(x -> x*2) - map 不会改变条数 - 适合做字段转换、格式转换、简单加工 - flatMap - 一条输入可以变为多条输出,也可以一条都不输出 - ![](https://an-img.oss-cn-hangzhou.aliyuncs.com/2026/03/26/20260326225144380.png,509,92) - flat 适合切词、拆分、展开 - WordCount 通常用它来把一行拆分成多个词 - 比 map 更灵活 - filter - 按条件保留或丢弃数据 - DataStream<Integer> evenNums = nums.filter(x -> x % 2 == 0); - filter 不改数据结构,主要做筛选 - keyBy - 按 Key 对数据流做分组 - 所有具有相同 key 的记录会被分到同一个分区上,通常内部是哈希分区 - ![](https://an-img.oss-cn-hangzhou.aliyuncs.com/2026/03/26/20260326225552264.png,392,146) - keyBy 不是聚合 - 只是按 key 把数据归组 - 后面的 sum、reduce、状态、窗口通常都要建立在 keyBy 后面 - reduce - 对同一个 key 下的数据做增量操作 - ![](https://an-img.oss-cn-hangzhou.aliyuncs.com/2026/03/26/20260326225815274.png,627,58) - reduce 必须通常接在 keyBy 后面 - 是增量聚合 - 比一次性收集所有数据再算更高效 - Sink - result.print() - print() 是最小可用 Sink - 主要用于学习和调试 - 后续接 Kafka、文件、数据库,思路还是一样,只是 Sink 换了 - union/connect - union - 定义 - 会把两条或多条数据流合并为一条新流,新流包含所有输入流的元素 - 所有输入流必须是相同类型 - 示例 - ![](https://an-img.oss-cn-hangzhou.aliyuncs.com/2026/03/26/20260326230432339.png,365,77) - 关键点 - 类型必须一直 - union 后得到的还是同一种类型的普通 DataStream - union 适合本来就是同类数据,只是来自不同来源 - connect - 连接两条流,但是保留各自的类型信息 - 示例 - ![](https://an-img.oss-cn-hangzhou.aliyuncs.com/2026/03/27/20260327105019878.png,425,77) - connect 更像先把两条流并排放在一起,然后你自己决定怎么分别处理左边和右边 - connect 后怎么用 - connect 一般不会单独停在那,通常会继续接处理函数 - ![](https://an-img.oss-cn-hangzhou.aliyuncs.com/2026/03/27/20260327110726215.png,481,276) - RichFunction - 为什么要有 RichFunction - map/filter/flatMap 拿不到 Flink 运行时信息,也不方便做初始化和清理 - 需求:程序开始时初始化资源/程序结束时做清理/知道当前子任务编号/知道并行度/拿到运行时上下文 - RichFunction 定义了生命周期方法,以及访问函数执行上下文方法 - RichFunction 是什么 - RichFunction 是一个基础接口,很多常见的 rich 版本函数都建立在它之上 - 平时真正会写的,不是 RichFunction 本身,而是具体子类 - RichMapFunction - RichFilterFunction - RichFlatMapFunction - 可以理解为普通 Function 的增强版 - 和普通函数的区别 - 除了处理逻辑,还多了 open()、close()、getRuntimeContext() - open 用于初始化,close 用于清理,getRuntimeContext 用于获取运行时上下文 - RichFunction 最核心的三个能力 - open(OpenContext openContext) - 这是初始化方法 - 会在真正工作方法例如 map、join 前调用 - 适合做一次性初始化工作 - 就像开工前准备 - close() - 这是清理方法 - 会在主工作方法最后一次调用之后执行 - 适合做清理工作 - 就像收工后打扫现场 - getRuntimeContext() - 拿运行时上下文的方法 - 包含函数运行并行度、当前 subtask 的索引、执行该 task 的名称 - 例子 - ![](https://an-img.oss-cn-hangzhou.aliyuncs.com/2026/03/27/20260327151346647.png,646,581) - 基本调试方法 - 先用最小输入跑通 - 先用 fromElements() 构造很小的输入集 - 最基础的调试方式: print() - 中间的关键步骤也可以加上 print() - ![](https://an-img.oss-cn-hangzhou.aliyuncs.com/2026/03/27/20260327151659872.png,402,159) - 按 source/transformation/sink 分段排查 - 先怀疑 source - source 有没有真的产生数据 - 输入是不是空的 - 输入格式是不是和我想的不一样 - 可以先 lines.print("source") 确认下 - 再怀疑 transformation - 每做一步变化,就临时加一个 print() - 最后怀疑 sink 时间与窗口 - 时间与窗口 - 时间语义 - processing time - 什么是 processing time - processing time 就是某个算子在处理这条数据时,所在机器的系统时间 - 不看事件真正什么时候发生,而是看 Flink 现在处理到它时是几点 - processing time 的本质 - 用机器时钟当做时间标准 - 不需要从数据取时间戳 - 不需要 watermark 来推动时间前进 - event time - 什么是 event time - 不看 flink 什么时候处理到这条数据,而是看这条数据业务上到底是什么时候发生的 - 这个时间通常在进入 flink 前就嵌入到记录了 - 为什么 event time 重要 - 如果想得到可复现、符合业务真实时间的分析结果,就应该用 event time - 把统计口径从系统什么时间看到它,切回什么时候真正发生 - 本质 - 用事件自带的时间戳,作为所有时间相关操作的基准 - 必须能从数据集提取时间戳 - 系统需要某种机制来判断某个时间点之前的数据大致到齐了 - ingestion time - 事件进入 Flink 时被赋予的时间 - WaterMark - WaterMark 是什么 - WaterMark 是 Flink 用于衡量 EventTime 进度的机制 - Watermark 在数据流中携带一个时间戳 t,WaterMark(t) 表示事件已经推进到 t - 为什么需要 WaterMark - 真实世界里的数据经常会乱序到达 - 问题:应该等到什么时候,才能认为某个时间点之前的数据差不多到齐了 - Watermark 表示什么 - 到目前为止,我认为时间戳小于等于 t 的事件应该都到了 - Watermark 和窗口的关系 - Watermark 最常见的作用,就是触发 EventTime 窗口计算 - Watermark 决定什么时间可以开始算 - Watermark 和迟到数据有什么关系 - 如果某条事件的时间戳小于等于当前已经到达的 watermark,但现在才到,这条数据就是迟到数据 - Watermark 怎么来的 - 使用 Event Time 时,Flink 需要知道每个元素的时间戳 - 时间戳可以通过 TimestampAssigner 提取 - 需要 WatermarkGenerator 生成 watermark,两者一起通过 WatermarkStrategy 配置 - Watermark 来自于定义的时间策略 - Watermark 理解 - Watermark 就像 Event Time 世界里的逻辑时钟 - 系统告诉下游:可以把时间推进到这里了 - 有序和乱序流的 Watermark 生成方式 - 为什么分有序流和乱序流 - Watermark 的生成方式,本质取决于你的数据时间戳分布特征 - 有序流的 Watermark 生成方式 - 核心假设 - 后面到来的事件,时间戳不会比前面的小 - 生成逻辑 - 在有序流里,当前看到的最大时间戳就代表时间已经推进到那里了 - 乱序流的 Watermark 生成方式 - 核心假设 - 虽然事件会乱序到达,但是可以给出一个最大乱序范围 B - 生成逻辑 - 当前 watermark = 已观察到的最大事件时间戳 - 最大乱序容忍时间 - 具体例子 - ![](https://an-img.oss-cn-hangzhou.aliyuncs.com/2026/03/28/20260328195843653.png,729,820) - 事件对象里面要有事件时间字段 - Source 只是构造测试数据 - WatermarkStrategy 是核心 - forBoundedOutOfOrderness(Duration.ofSeconds(5)) - 允许这条流最多乱序 5s - Watermark 会根据当前看到的最大时间戳-5 秒来推进 - withTimestampAssigner()告诉 Flink 事件时间从那一列取 - assignTImestampsAnsWatermarks - 给流里的每个元素附上事件时间戳 - 同时让 Flink 根据策略找 Watermark - 窗口基础 - 为什么需要窗口 - 流数据是无界的,理论上不会自然结束 - 对这种数据不能简单说等全部到期再统计 - Flink 的窗口机制就是把连续到来的流按规则划分为一个个有序集合 - Flink 里窗口的总体分类 - Tumbling Window (滚动窗口) - 定义:是固定大小、连续、互不重叠的窗口 - 适合场景 - 每 5 分钟订单数 - 每小时 GMV - 每日 UV - 每分钟接口调用量 - Sliding Window (滑动窗口) - 定义:也是固定大小的窗口,会按照一个更小的步长持续滑动 - 示例:窗口大小 10 分钟,每 5 分钟滑动一次 - 适合场景 - 最近 10 分钟 PV,每分钟更新一次 - 最近 30 分钟订单量,每 5 分钟更新一次 - Session Window (会话窗口) - 定义 - Session Window 没有固定窗口,它的边界由活动间隔决定 - session window 的边界由 inactivity gap 定义 - 指定 gap 时间内没有新事件到来,当前 session 就关闭 - 如果中间一直没有很长时间断开,这些行为属于同一个会话 - 适合场景 - 用户会话分析 - App 连续使用时长 - 连续操作链路 - Count Window (计数窗口) - 定义 - Count Window 按元素个数切 - 每 10 条数据统计一次/每 100 条点击做一次聚合 - 适合常见 - 每 100 条日志做一次分析 - 每 50 条事件批量聚合 - 窗口最重要的两个维度 - 切分维度 - 按时间切 - 按数量切 - 是否重叠 - 不重叠 - 可能重叠 - 不固定边界 - 窗口对齐 - 时间窗口是对齐到 epoch 的 - 时间窗口默认是按时间对齐的,不是按程序启动时刻随意切 - 窗口聚合 - 窗口聚合解决什么问题 - 窗口把无界流切成有限片段后,通常还要对每个片段做计算 - 不同业务对怎么算有不同要求 - 有的业务只要一个简单结果,例如总数 - 有的要复杂中间结构,比如平均值要维护 sunm 和 count - 有的除了结果,还要输出窗口边界、key、触发时间等元信息 - 增量聚合 - 增量聚合,就是数据一到,就立即把它并入当前结果 - 不是把整个窗口所有元素都存起来,等窗口结束后再统一计算 - 全量窗口处理 - 先把窗口里所有元素都留着,等窗口触发时,再把完整集合交给函数处理 - reduce:最轻量的窗口聚合 - 本质 - ReduceFunction 的核心思想是:把两个同类型元素合并成一个同类型元素 - 为什么高效 - 窗口里不需要保留全部元素,只需要持续维护一个当前归并结果 - 最大的限制 - 输入类型和输出类型必须一致 - aggregate:更通用的增量聚合 - 本质 - 把聚合拆成了几个阶段 - 创建累加器 createAccumulator() - 把新元素加进累加器 add() - 从累加器得到最终输出 getResult() - 为什么比 reduce 更灵活 - 把三种类型分开 - 输入类型 IN - 累加器类型 ACC - 输出类型 OUT - ProcessWindowFunction:最灵活的窗口处理 - 本质 - 核心价值不是更快,而是信息更多、能力更强 - 适合什么场景 - 需要窗口开始时间/结束时间 - 需要访问 key - 需要输出带窗口元信息的结果 - 需要看完整窗口数据 - 需要复杂后处理逻辑 - 最大的优势 - 能够拿到窗口元信息 - 迟到数据处理 - 什么叫迟到数据 - 这条数据的事件时间本来属于某个旧窗口 - 但它到达系统时,这个窗口已经因为 watermark 推进而触发过了 - 它来晚了 - allow lateness - 是 Flink 为窗口保留的额外容忍时间 - allowedLateness(...) 指定窗口在 watermark 超过结束时间后还能保存多久,以接收迟到数据 - side output - side output 是把某些特殊数据从主流旁路输出的机制 - 把太晚了、已经不能再进主窗口的数据单独收集出来 - 结果修正 - 配置了 allow lateness 后,窗口第一次输出的结果可能不是最终结果 - 后面如果又来了还能接受的迟到数据,窗口结果会更新并再次触发输出 状态与容错 - 状态与容错 - State 基础 - 什么是状态 - 状态=程序对过去信息的记忆 - 没有状态:程序只看当前一条数据 - 有状态:程序处理当前数据时,还能参考历史数据 - 为什么流处理需要状态 - 要做累计 - 用户累计消费金额 - 某设备累计告警次数 - 某单词当前总出现次数 - 要做对比 - 当前温度和上一次温度相比是否突增 - 当前订单状态和之前状态是否矛盾 - 要做时序逻辑 - 10 s 内连续 3 次登录失败 - 下单后 15 分钟未支付 - 一段会话里的连续点击行为 - 要在故障后恢复业务上下文 - Key State - 定义 - Keyed State 是和 Key 绑定的状态 - Keyed State 只能用于 Keyed Stream 上 - 先对 DataStream 做 keyBy(...),然后才能使用 keyed state - 为什么重要 - 很多业务天然都是按照实体分开记忆的 - 底层直觉 - 一个分布式哈希表,按 key 切开,每个并行实例只负责自己那部分 key - Operate State - 定义 - Operator State 是和算子并行实例绑定到状态,而不是和业务 key 绑定 - 算子级记忆,而不是业务实体记忆 - 适合什么场景 - source/sink 级别的处理位点 - 算子实例自己的工作上下文 - Keyed State - ValueState:最常用的 Keyed State - 是什么 - ValueState<T> 表示:每个 key 只有一个值 - 典型场景 - 每个用户累计点击次数 - 每个订单当前状态 - 每个设备最近一次温度 - 每个用户最近一次登录时间 - 每个 key 当前告警开关状态 - ListState:每个 Key 存一串值 - 是什么 - ListState<T> 表示:每个 Key 下面维护一个列表 - 典型场景 - 缓存最近若干条事件 - 暂存等待拼接到多条消息 - 收集一个 key 下的一批原始记录 - 先攒起啦,后面统一处理 - 风险 - 容易无限增长 - MapState:每个 Key 下再套一个 map - 是什么 - MapState<UK,UV> 表示:每个 key 对应一个 map - 典型场景 - 每个用户下,不同商品类别的点击次数 - 每个设备下,不同传感器字段的最新值 - ReducingState:自动做同类型聚合 - 是什么 - ReducingState<T> 表示:每个 key 不直接存明细,而是存一个经过 reduce 的聚合结果 - AggregatingState:更灵活的聚合状态 - 是什么 - AggregatingState<IN, OUT> 和 Reducing State 很像,但更通用 - 包含输入类型 - ProcessFunction - 为什么需要 ProcessFunction - 传统的算子 - map/filter/flatMap/keyBy/window 聚合 - 更偏声明式处理,而不是自己掌控每条事件+时间回调+状态细节 - 无法解决的问题 - 订单创建后 15 分钟未支付就报警 - 用户 10s 内连续登录失败 3 次 - 某 key 1 分钟没有更新时输出当前统计值 - 无法解决问题共同点 - 要逐条处理事件 - 要记住状态 - 要在未来某个时间点触发逻辑 - ProcessFunction 是什么 - 当高级 API 不够表达你的业务规则时,就下到 ProcessFunction 这一层自己控制 - 更像一个加强版的 flatMap - 对每条输入都能处理 - 能产生 0、1 或多条输出 - 能访问上下文 - 能注册 timer - 在 timer 到点时再执行逻辑 - KeyedProcessFunction 的核心方法 - processElement(...) - 每来一条输入事件,Flink 就会调用它 - 事件到了,现在立即处理它 - onTimer(...) - 注册的 timer 到点时,Flink 会调用它 - 之前约好的时间到了,现在执行补充逻辑 - Context 提供什么 - 在 processElement(...),Flink 会提供一个 Context,这个 Context 能做几件事 - timestamp():拿当前元素时间戳 - timeService():访问定时器服务 - getCurrentKey():拿当前 Key - output(...):输出到 side output - 什么是 Timer - 在当前处理某条事件时,告诉 Flink:“到未来某个时间点,请再回头执行一次逻辑“ - Timer 为什么 和状态一起用 - Timer 只负责未来提醒你一次,但是提醒你时应该做什么,要靠状态来判断 - Checkpoint 与容错 - Checkpoint 是什么 - Checkpoint 是 Flink 对“作业状态+输入流位置“的一致性快照 - Checkpoint 至少包含两类东西 - 各个 Stateful operator 的状态快照 - 各个 source 当前读到哪儿的位点,例如 Kafka offset 这类输入位置 - 为什么流处理必须依赖 Checkpoint - 状态负责记忆,checkpoint 负责让记忆在失败后不丢 - Checkpoint 保存的是什么 - Operator/Keyed State - Source 位置 - Timers 连接外部系统 - 连接外部系统 - Flink 读写文件与接 - Kafka 集成 - Flink 与数据库 - 序列化与数据格式 Flink SQL 与 Table API - Flink SQL 与 Table API - 认识 Table API 与 Flink SQL - 为什么要有 Table API 与 SQL - DataStream 更像手写处理逻辑,Table API/SQL 更像声明我要算什么 - Table API 是什么 - Table API 是一种 language-integrated query API - 是 SQL 的超集,专为 Flink 设计 - Flink SQL 是什么 - 用 SQL 语法来描述 Flink 上的流批处理逻辑 - 可以理解为 - 输入表先注册好 - 直接写 SQL - Flink 帮你把 SQL 变为实际可执行程序 - Table API 和 SQL 的关系是什么 - 不是两套互斥系统,而是同一层抽象的两种表达方式 - SQL - 更适合分析型开发 - 数仓开发 - DDL/DML 风格任务 - 让更多懂 SQL 的人参与开发 - Table SQL - 想保留代码层面的类型和组合能力 - 需要和 Java/Scala/Python 代码更紧密集成 - 想在 API 层做更多程序化的表运算 - 什么是 Dynamic Table (动态表) - 动态表就是把“不断到来的流“逻辑上看成一张会持续变化的表 - 什么是 Continuous Query (连续查询) - Flink SQL 不是查一下就结束都传统 SQL,而是持续运行、持续更新结果的 SQL - append/retract/upsert 结果是什么意思 - 问题:结果表变化,怎么把这个变化告诉下游? - 动态表包含三种编码方式 - Append-only - 结果表只会新增,不会修改旧结果,只发追加记录就行 - Retract - 如果结果表中的旧值会被更新或删除,就不能只 append - retract stream 用 add/retract message 来表示 - 对于 update 会先发撤回旧值,再发一条新增新值 - Upsert - upsert stream 需要唯一键 - INSERT 和 UPDATE 都可以用 upsert message 表示 - DELETE 用 delete message 表示 - 相比 retract,upsert 只需一条消息 - Flink SQL 基础语法 - 整体心智模型 - 典型 Flink SQL 作业 - CREATE TABLE source ... - CREATE TABLE sink ... - INSERT INTO sink - SELECT ... FROM source WHERE ... GROUP BY ... - 四步流程 - 定义源表 - 定义结果表 - 写查询逻辑 - 把查询逻辑结果写到目标表 - CREATE TABLE 是什么 - 本质 - CREATE TABLE 是在当前 Catalog 里注册一个表定义,让这个表可以被 SQL 使用 - 告诉 Flink:有一张表,它的字段是什么,连接那个外部系统,怎么读,怎么写 - 最小骨架 - ![](https://an-img.oss-cn-hangzhou.aliyuncs.com/2026/03/29/20260329201448039.png,228,152) - 列定义:字段名和类型 - WITH (...):连接器和外部系统参数 - WITH(...) - 通常是连接外部系统所需的 table options - connector - 指定背后连接那个系统(Kafka/filesystem/upsert-kafka/print) - format 相关参数 - 告诉 Flink 数据是 JSON、CSV、Avro 等什么格式 - 外部系统连接参数 - 例如 Kafka topic、broker 地址、分区键等 - Flink SQL 最基础的查询语法 - SELECT - SELECT user_id, amount FROM orders; - WHERE - SELECT user_id, amount FROM orders WHERE amount > 100; - GROUP BY - SELECT user_id, COUNT(*) FROM orders GROUP BY user_id; - INSERT TO - INSERT INTO result SELECT user_id, COUNT(*) FROM orders GROUP BY user_id; - sqlQuery() 和 executeSql() 怎么理解 - sqlQuery() 用于 SELECT/VALUES 这类查询,返回一个 Table - executeSql() 用于执行 DDL、DML 等语句,例如 CREATE TABLE、INSERT INTO - 时间属性与窗口 SQL - 什么是时间属性 - 在流式 SQL 中,窗口函数要求引用的是有效的时间属性,也就是 processing time attribuet 或 event time attribute - 被 Flink 运行时承认,并能参与时间推进和窗口计算的特殊时间列 - 时间属性分类 - 处理时间属性 (Processing Time) - 这条记录被 Flink 当前机器处理到的时间 - 事件时间属性 (Event Time/Rowtime) - 事件时间是业务事件真正发生的时间 - 在 SQL 中定义处理时间属性 - 在 CREATE TABLE 时,可以定义一个处理时间列 - ![](https://an-img.oss-cn-hangzhou.aliyuncs.com/2026/03/29/20260329231102861.png,189,112) - 在 SQL 中定义事件时间属性 - 事件时间通常来自源数据中的某个时间段 - 仅仅有这个字段还不够,通常要搭配 WATERMARK - ![](https://an-img.oss-cn-hangzhou.aliyuncs.com/2026/03/29/20260329231325444.png,403,131) - ts 是事件时间类 - WATERMARK FOR ts AS ... 告诉 Flink 如何基于 ts 推进事件时间 - 窗口 SQL 推荐怎么写 - Windowing TVF (窗口表值函数) - 窗口聚合支持 TUMBLE/HOP/CUMULATE/SESSION 这些都通过 Windowing TVF 表达的 - 什么是 TUMBLE 窗口 SQL - TUMBLE 对应滚动窗口 - 按固定窗口大小分配数据 - 窗口不重叠 - 在结果中增加 window_start、window_end、window_time 三列 - ![](https://an-img.oss-cn-hangzhou.aliyuncs.com/2026/03/29/20260329231739213.png,522,109) - DESCRIPTOR(ts):告诉 Flink 用 ts 这列作为时间属性 - INTERVAL '5' MINUTES:窗口大小 5 分钟 - 什么是 HOP 窗口 SQL - HOP 对应滑动窗口 - HOP 有 slide 和 size - 它会创建可能重叠的窗口 - 同一条记录可能会进入多个窗口 - ![](https://an-img.oss-cn-hangzhou.aliyuncs.com/2026/03/29/20260329232103658.png,435,204) - 每 1 分钟滑动一次 - 每个窗口覆盖近 10 分钟 - 结果表同样会带窗口边界列 - 什么是 CUMULATE 窗口 SQL - CUMULATE 是累积窗口 - 适合总窗口范围较大,但想看逐步累积结果 - 每小时一个大窗口,每 5 分钟输出一次当前已累计到哪里的聚合值 - ![](https://an-img.oss-cn-hangzhou.aliyuncs.com/2026/03/29/20260329232258791.png,463,215) - step 是 5 分钟 - max size 是 1 小时 - 窗口会逐步扩张输出 - 什么是 SESSION 窗口 SQL - SESSION 对应会话窗口 - ![](https://an-img.oss-cn-hangzhou.aliyuncs.com/2026/03/29/20260329232406267.png,439,186) - 30 分钟内持续有事件,属于同一会话 - 维表 JOIN 与实时数仓 - 什么是事实流,什么是维表 - 实时流 - 不断到来的业务事件流 - 通常来自于 Kafka、CDC、日志流等 source table - 维表 - 维表通常是描述型、补充型信息 - 为什么实时数仓一定遇到维表 JOIN - 事实流提供发生了什么,维表提供这件事属于谁、是什么、怎么解释 - 什么是实时宽表 - 把事实流和一个或多个维表关联后,得到的一张字段更完整、可直接分析或落库的结果表 - 什么是 Lookup Join - 以事实流为驱动,对外部维表做按键查询 - 什么是 Temporal Join/Versioned Table Join - 按某个时间点,去取维表在那个时刻的版本,再和事实表做关联 - 什么是 Versioned Table - 一张会变化、并且保留变化历史的表 原理深入 - 原理深入 - 背压与性能调优 - 什么是背压 - 上游发太快,下游吃不动

计算机组成原理

全局框架 - 全局框架 - 系统层次 - ![](https://an-img.oss-cn-hangzhou.aliyuncs.com/2026/02/18/20260218105815855.png,424,537) - 核心矛盾:速度差 - CPU 运算快、内存慢、外设更慢 - 系统用层次结构(寄存器/Cache/内存/磁盘)和并行性(流水线/乱序/多核/DMA)来隐藏或者摊薄延迟 - 指令在 CPU 里按“取值-译码-执行-访存-写回“的数据通道运行 - 为了加速,做流水线把这 5 步重叠 - 内存访问通过 Cache 利用局部性降低平均延迟 - 虚拟内存通过 MMU/TLB 把程序看到的地址映射到物理内存 - I/O 慢是由于设备与总线带宽/延迟远弱于 CPU,因此依赖中断、缓冲与 DMA 来减少 CPU 参与 指令系统与数据表示 - 指令系统与数据表示 - 整数与位运算的数据表示 - 位、字节、字 - bit:0/1 - Byte:8bit - Word(字):CPU 自然处理的宽度,与寄存器/ALU/地址宽度相关 - 为什么 byte 是最小编址单位:大多数现代机器按照字节编址,方便表示字符/结构体/网络数据等 - 原码/反码/补码 - 原码 - 表示方法:最高位符号位,剩下是绝对值 - 问题: - 有 +0 和 -0 两种零 - 加减法需要额外处理符号,硬件复杂 - 反码 - 正数同原码 - 负数:对正数位取反 - 问题 - 仍然有 +0 和 -0 - 加法需要回卷进位 - 补码 - 正数:与无符号相同 - 负数:按位取反 + 1 - 关键性质: - 只有一个 0(没有 -0) - 加减法统一为同一套加法器:a-b=a+(~b+1) - 符号扩展简单:高位补符号位即可 - 范围不对称 [-2^{n-1},2^{n-1}-1] - 快速转换 - 补码->十进制 - 最高位 0:按无符号直接算 - 最高位 1:取反 +1 得到绝对值,再加负号 - 十进制->补码 - 正数:直接写二进制,左侧补 0 - 负数:先写绝对值二进制 -> 取反+1 - 有符号/无符号加法与溢出判断 - 无符号溢出(Unsigned overflow) - 本质:结果超出 [0, 2^n-1] - 判定方法:看进位 Carry-out - 有符号溢出 - 本质:结果超出 [-2^{n-1},2^{n-1}-1] - 判断口径统一 - 同号相加,异号结果 => 溢出 - 异号相加不会溢出 - 等价位级判定 - 符号位的进位与最高位进位输出不同=>溢出 - 字节序:大端 vs 小端 - 对 32-bit 只 0x12 34 56 78 高位在左 - 大端 - 低地址存高字节 - 内存从低地址到高地址 12 34 56 78 - 直觉:与人类书写一致 - 小端 - 低地址存低字节 - 内存从低地址到高地址:78 56 34 12 - 直觉:低地址就是低位,做逐字节扩展/截断更自然 - x86 等主流架构长期使用小端 - 对齐与 Padding - 对齐 - 要求:某类型对象的地址必须是某个中的倍数(4/8) - 为什么要对齐 - 硬件访问效率:CPU/总线按照字/双字读写;未对齐可能需要两次内存读写再拼接 - Cache line 与内存访问粒度:对齐能减少跨行/跨 cache line 的概率 - Padding(填充) - 为了满足对齐,编译器会在结构体字段之间或末尾插入空字节 - 结构体大小通常是其最大对齐需求的倍数 - 浮点数(IEEE 754) 与误差本质 - IEEE 754 基本格式 - 浮点数表示 - S(符号位):0 正 1 负 - E(指数/阶码):带拍支 bias - F(尾数/小数部分):有效数 significand 的小数部分 - ![](https://an-img.oss-cn-hangzhou.aliyuncs.com/2026/02/18/20260218112914998.png,218,39) - 单精度 float(32-bit) - S: 1 位 - E: 8 位,bias = 127 - F: 23 位 - 双精度 double(64-bit) - S:1 位 - E: 11 位,bias=1023 - F: 52 位 - 规格化、非规格、0、INF、NaN - 规格化 - 指数 E 既不是全 0 也不是全 1 - 有隐含的 leading 1:1.F - 非规格化 - 指数 E 全 0,尾数 F 非 0 - 没有隐含 1:有效数是 0.F - 目的:渐进下溢,让非常接近 0 的数仍然能表示 - 0 - E 全 0,F 全 0 - 有 +0 和 -0 - Inf 无穷 - E 全 0,F 全 0 - 有 +0 和 -0 - NaN(非数) - E 全 1,F 非 0 - 来自非法操作:0/0 - NaN 会传播 - 0.1 + 0.2 != 0.3 - 十进制小数在二进制中往往是无线不循环小数 - 0.1 的二进制是 0.0001100111 - float/double 只能保存有限位尾数 - 存储时发生舍入,计算时再舍入,误差叠加 - 比较时用 ==,就可能不相等 CPU 执行与控制 - CPU 执行与控制 - 单周期 Datapath - 必备组件 - ![](https://an-img.oss-cn-hangzhou.aliyuncs.com/2026/02/18/20260218153017482.png,429,229) - PC 当前指令地址寄存器 - Instruction Memory/I-Cache:根据 PC 取指令 - Register File (寄存器堆):两读一写(rs1、rs2 读;rd 写) - Immediate Generator(立即数生成/符号扩展) - ALU:算数逻辑/比较/地址计算 - Data Memory/D-Cache:load/store 访问 - Adders: - PC + 4 - PC + imm (分支目标) - 关键 MUX(多路选择器) - ALUSrc:ALU 第二操作数是来自 rs2 还是 imm - MemToReg:写回 rd 的数据来自 ALU 还是内存 - PCSrc:下一 PC 来自 PC+4 还是分支目标 - Control Unit(控制器):根据 opcode/funct 产生控制信号 - datapath = 一堆功能模块 + 多根线 + MUX 选路 - 四条指令在 datapath 里怎么走 - R 型算数:add rd, rs1, rs2 - 目标:rd = rs1 + rs2 - 数据流 - PC -> 取指令 - 寄存器堆读 rs1、rs2 - ALU 计算 ALUOut = rs1 + rs2 - 写回:rd <- ALUOut - PC 更新:PC <- PC + 4 - 关键控制 - RegWrite = 1 写寄存器 - ALUSrc = 0 第二操作数来自 rs2 - MemRead = 0, MemWrite = 0 - MemToReg = 0(写回来自 ALU) - Branch = 0(不分支) - ALUOp = R-type(由 funct 决定是 add/sub/and/or) - 访存读:lw rd, imm(rs1) - 目标:rd = Mem[rs1+imm] - 数据流 - 取指 - 读寄存器 rs1 (base),rs2 通常也读但不用 - ALU 计算有效地址:ALUOut = rs1 + imm - 数据内存读:MemOut = Mem[ALUOut] - 写回:rd <- MemOut - PC <- PC + 4 - 访存写:sw rs2, imm(rs1) - 目标:Mem[rs1+imm] = rs2 - 数据流 - 取指 - 读 rs1(base) 和 rs2(要写入的数据) - ALU 算地址:ALUOut = rs1 + imm - 数据内存写:Mem[ALUOut] <- rs2 - PC <- PC + 4 - 条件分支:beq rs1, rs2, imm - 目标:如果 rs1 == rs2,则 PC <- PC + imm;否则 PC <- PC + 4 - 数据流 - 取指 - 读 rs1,rs2 - ALU 做比较:Zero = (rs1-rs2==0) - 同时计算目标分支:Target = PC + imm - PC 选择 - if Branch & Zero:PC <- Target - else: PC <- PC + 4 - 单周期 vs 多周期 - 单周期 - 每条指令都在一个周期完成 - 周期必须长到容纳最慢指令路径 - 结果:简单但浪费,端指令也被迫跑长周期 - 多周期 - 把一条指令拆分成多个步骤,每一步一个周期 - 不同指令用不同步骤数量 - 可以复用硬件 - 性能 - 性能公式 - ![](https://an-img.oss-cn-hangzhou.aliyuncs.com/2026/02/18/20260218153851413.png,354,137) - IC:执行的指令条数 - CPI:平均每条指令需要多少周期 - Cycle Time:每周期多长时间 (=1/主频) - Amdahl 定律(优化上限) - 如果系统中某部分占比位 f,该部分加速 S 倍,总加速比 - ![](https://an-img.oss-cn-hangzhou.aliyuncs.com/2026/02/18/20260218154107114.png,227,64) - IPC、吞吐、延迟 - 延迟:完成单个任务/单条指令/一次请求需要的时间 - 吞吐:单位时间完成多少任务 - IPC:每周期完成多少条指令 - 吞吐 = IPC x 频率 流水线与冒险 - 流水线与冒险 - 五级流水线核心内容 - 五段功能定义 - IF:取值;PC->I-Cache;计算 PC+4 - ID:译码;读寄存器;生成立即数 - EX:ALU 运算;地址计算;分支比较 - MEM:访问 D-Cache - WB:写回寄存器 rd - 流水线寄存器 - 为了让每段每周期独立推进,段与段之间插入寄存器 - IF/ID、ID/EX、EX/MEM、MEM/WB 它们保存上一段的输出给下一段用(包含数据与控制信号) - 理想吞吐与延迟 - 非流水:一条指令用 5 个周期,下一条等它结束 - 流水:启动后每周期完成 1 条 - 延迟:单条仍然=5 个阶段 - 吞吐:理想从 1/5 提升到 1 - 流水线的代价 - 需要通多寄存器与控制逻辑 - 分支错预测要清刷流水线 - 结构冲突要加资源或停顿 - 结构冒险 - 定义 - 同一周期内两个阶段需要同一个硬件资源 - 例子 - 单端口存储器:IF 要取值,MEM 要访存 - 同一周期冲突 -> 必须 stall 或改造硬件 - 解决方案 - 分离 I-Cache/D-Cache - 多端口存储器 - 数据冒险 - 三种依赖 - RAW(Read After Write)真依赖:后读依赖前写,最关键 - WAR(Write After Read)反依赖:后写不能早于前读 - WAW(Write After Write)输出依赖:两条写同一目的寄存器 - 为什么会 RAW:写回晚,读取早 - 读寄存器在 ID - 写回寄存器在 WB - Fowwarding (旁路/转发)机制 - 不等写回到寄存器堆,直接把产生的结果从后端绕回到前段使用 - 转发路径 - EX/MEM -> ID/EX(给下一条的 EX 用) - MEM/WB -> ID/EX (给下一条或下下条的 EX 用) - Stall(插泡)与 load-use hazard - load-use 典型序列 - lw r1, 0(r2) - add r3, r1, r4 - 原因 - lw 的数据到 MEM 末才可用 - add 需要在 EX 用到 r1 - 时间上赶不上,即使有 forwarding 也不够快 - 结论:需要插入一个 bubble - 实现 - 冻结 PC 和 IF/ID - 往 ID/EX 注入一个 NOP (控制信号清零)形成 bubble - 控制冒险 - 定义:PC 的下一值不确定:到底走 PC+4 还是分支目标 - 分支决定在哪里做决定 penalty - 如果分支在 EX 才能确定 - 在分支结果出来之前,流水线已经取了后续若干条 - 一旦发现分支 taken,就要把错路径指令 flush 掉 - 基础策略 - stall until branch resolved:等分支算出来再取后续 - predict not taken:默认不跳,先取顺序;若实际 taken -> flush - 动态分支预测:用分支历史表、2-bit 饱和计数器等降低错预测 - BTB:预测 taken 时快速给出目标地址 - 延迟槽 - penalty - 分支罚时:错预测或 taken 导致需要清空的阶段数 存储体系 - 存储体系 - 核心矛盾 - CPU 周期是纳秒级,DRAM 访问通常是几十到上百纳秒级,差一个数量级 - 解决思路:分层存储 + 局部性 - Cache 用更贵更快的 SRAM 缓存最近用过的数据块,依赖时间/空间局部性降低平均访存时间 - Cache 基础概念 - Cache line (缓存行) - Cache 以“行/块“位单位搬运数据 - CPU 读写某地址时,实际会把该地址所在的整条 cache line 搬到 Cache。 - 推论:顺序访问数组通常很友好;跨行访问容易 miss - Hit/Miss 与三大指标 - Hit time:命中时访问延迟 - Miss rate:未命中比例 - Miss penalty:未命中后去下一级/内存取回的代价 - 平均访存时间 AMAT - AMAT = Hit Time + Miss Rate * Miss Penalty - MISS 分类 - Compulsory miss(冷启动):第一次访问必 miss - Capacity miss(容量):工作集超过 Cache 容量 - Conflict miss(冲突):映射/组相联导致不同块争同一行 - 映射方式:直接映射/全相联/组相联 - 前置数据 - Cache 总容量 (不含tag/元数据) = C bytes - Cache line 大小 = B bytes - 总行数 (lines) = L = C/B - 相联度 (ways) = A - 组数 (sets) = S = L/A - 直接映射 - 每个内存块只能映射到唯一一个 cache 行 - 优点:硬件简单、命中快 - 缺点:冲突 miss 多 - 全相联 - 任意内存块可放任意行 - 优点:几乎无冲突 miss - 缺点:需要对所有行比较 tag(实现复杂、命中更慢、耗能更高) - 组相联 - 内存块映射到某个 set,但可以放在 set 内任一 way。 - 折中方案 - 地址拆分 - 对于字节编址系统,地址通常拆分为:tag+index+block offset - offset 块内偏移 - 表示在一条 cache line 內的字节位置 - 位数 offset bits = log2(B) - index 组索引 - 选择 set - index bits = log2(S) - tag 标记 - 用于判断该 set 中某一 way 是否是想要的内存块 - tagbits = Address bits - index bits - offset bits - 替换策略 - LRU:替换最久没用的行 - Pseudo-LRU(伪 LRU):用更少 bit 近似 LRU - FIFO:先进先出 - 写策略 - write-throught(写穿) - 写命中:同时写 Cache 和下一级 - 优点:下一级始终最新,实现简单 - 缺点:写流量大 - write-back(写回) - 写命中:只写 Cache,把该行标记为 dirty - 该行被替换时才写回下一级 - 优点:显著减少写带宽压力 - 缺点:一致性更复杂(多核) - 写不命中 write miss 的两种策略 - write-allocate(写分配):先把整条 line 读入 Cache,再在 Cache 上写,与 write-back 搭配 - no-write-allocate(不写分配):不把 line 加入 Cache,直接写下一级,常与 write-throught 搭配 - 多级 Cache 的 AMAT - L1/L2 两级 - AMAT=HitTime_L1​+MissRate_L1​×(HitTime_L2​+MissRate_L2​×MissPenalty_L2​) - 多核:一致性 vs 一致性协议 - Cache Coherence(一致性) - 目标:对于同一内存地址 X,各核的 cache 中 X 的副本要看起来像一个指在变化,不能读各自的旧值 - 解决办法:一致性协议(MESI)保证写入能让其他核的副本失效/更新 - Memory Consistency(一致性模型,针对不同地址的顺序) - 目标:规定不同核对不同地址的读写在时间顺序上允许怎么样被观察到 - 是内存模型讨论的问题,涉及重排序与屏障。 - Coherence 解决同一地址副本一致,Consistency 解决不同地址操作的可见顺序,前者靠协议,后者靠内存模型和 fence。 - MESI 协议 - False Sharing - 定义 - 不同核访问的是不同变量,但是这些变量恰好落在同一条 cache line 里 - 其中一个核写它自己的变量,会把整条 line 的其他核副本失效,导致另一核反复 miss - 性能问题 - 引发 invalidation(整条 line 粒度) - 两个核交替写同一条 line 上不同字节 - line 在核之间反复迁移,带来大量一致性流量 虚拟内存 - 虚拟内存 - 要解决的问题 - 抽象:每个进程看到连续的虚拟内存空间(VA) - 保护:权限控制(R/W/X,用户态/内核态) - 共享:共享库/共享内存/页共享 - 扩展:按需分页 + 换入换出 - 页 - 虚拟页:进程看到的虚拟地址空间被切分成的固定大小块 - 物理页框:正式物理内存被切成同样大小的块 - 二者的大小相同,比如常见 4KB - 虚拟内存用虚拟页编号,物理内存用页框编号,页表负责把虚拟页号 -> 物理页框号 - 地址和页对应 - 页大小是 4KB - 4KB = 4096 字节 = 2^12 - 一个地址的低 12 位代表页内偏移 - 高位表示虚拟页号(VPN) - 地址转换链路 - VA -> TLB -> page table -> PTE -> PA -> Cache/Memory - 术语解释 - VA:进程发出的地址 - PA:真实内存地址 - VPN:虚拟页号 - VPO:页内偏移 - PPN:物理页框号 - PTE:页表项,记录 VPN -> PPN 的映射与权限等元数据 - TLB:页表项的高速缓存 - 页大小与地址拆分 - 页大小是 PageSize = 2^k 字节 - offsetBits = k - VPNBits = AddrBits - k - 转换过程 - CPU 产生 VA - MMU 用 VA 的 VPN 去查 TLB - TLB hit:直接得到 PPN + 权限 - 物理地址 PA = (PPN << offsetBits) | Offset - TLB miss:用 VPN 去查页表 - 拿到 PTE - 有效 -> 填入 TLB -> 形成 PA -> 继续访问 - 无效 -> 触发异常 - 页表结构 - PTE 包含什么 - PPN:物理页框号 - Present/Valid:是否在内存 - R/W/X:读写执行权限 - U/S:用户/内核权限 - Accessed/Referenced(A):是否被访问过 - Dirty(D):是否被写过 - 为什么需要多级页表 - 单级页表 - VA 空间大,VPN 组合数巨大 - 每个进程都有一张覆盖整个虚拟空间的大表 - 多级页表做法 - 把 VPN 再拆分成多端:VPN1/VPN2/.../VPNn - 顶层页表只在需要时为某个范围分配下一级页表 - 没用到的地址区间不分配页表 -> 按需分配页表 - 多级页表的 Page Walk - 先用 VPN 的高段索引顶层页表,取到二级页表的地址 - 再用下一段索引二级页表页 - 最终找到叶子 PTE 得到 PPN - 代价:要读多次内存 - TLB - 没有 TLB - 每次 load/store 都要走 page walk,可能是 4/5 级 - 意味着每次访问内存前都要额外多次访存 - TLB 的本质 - 缓存最近用过的 VPN -> PPN + 权限 - 命中时地址翻译几乎是常数空间 - 缺页异常 - 典型原因 - Demand paging(按需分页):页还没加载到内存 - Swap out:页被换出到磁盘 - 权限违规:写只读页 - Copy-on-write:写共享只读页 - 缺页处理流程 - CPU 访问 VA,发现 PTE present = 0 或者权限不符合 - 触发异常,陷入内核(保存上下文,切换到内核栈) - 内核 fault handler 判断 fault 类型 - 是合法的按需页/非法地址 - 合法 - 找到磁盘位置 - 分配物理页框 - 发起 IO 把页读入内存 - 更新页表 PTE - 更新 TLB - 恢复进程执行 - 页面置换 - 当需要一个物理页框但是没有空闲页时,OS 必须选择一个牺牲页换出 - LRU - Clock/Second-Chance(近似 LRU) - 维护环形指针 - 每个页有 Accessed/Referenced 位 - 扫描时 - 若 A=0:淘汰 - 若 A=1:把 A 清 0,给第二次机会,指针继续走 - 共享与写时复制(COW) - 为什么需要 COW - fork 的语义:子进程得到父进程内存的副本 - 如果把所有页都复制一遍,成本极高 - COW - fork 后,父子进程的页表都指向同一批物理页框 - 把这些页的 PTE 权限改为只读 - 当父或子尝试写入某页 - 写入触发 page fault - 内核分配新物理页 - 复制旧页内容到新页 - 修改该进程页表让它指向新页,恢复写权限 - 另一个进程仍然指向旧页 IO 总线 - IO 总线 - IO 本质上慢 - 设备物理特性:SSD/网卡/磁盘的延迟远远高于 CPU - 路径长:系统调用、内核态切换、驱动、协议栈、队列管理 - 数据搬运:拷贝次数多、缓存一致性/同步开销高 - I/O 的基本硬件/软件组件 - 设备、控制器、驱动 - 设备:网络、磁盘、USB 设备等 - 控制器:设备侧的执行单元,负责 DMA、队列、寄存器接口等 - 驱动:内核中的软件,负责配置设备、提交请求、处理中断、管理队列 - 数据通路常见参与者 - CPU 核心 - Cache - 内存 - I/O 互联 (PCIe) - 设备控制器 - 设备介质 - 轮询 vs 中断 - 轮询 - 做法:CPU 不断读取设备状态寄存器 - 优点:实现简单、延迟可控 - 缺点:浪费 CPU、高并发/多设备会严重占用 CPU - 中断 - 设备完成后发中断信号,CPU 暂停当前执行,进入中断处理程序(ISR) - 优点: - CPU 不需要忙等->提升整体吞吐 - 缺点: - 有中断开销:恢复/保存上下文、切换栈、处理 ISR - 中断频繁造成抖动 - DMA - 不用 DMA 的程序搬运 - CPU 循环从设备寄存器读数据,再写入内存 - 缺点:CPU 被数据搬运绑死,带宽高、功耗高 - DMA 的本质 - DMA 让设备/控制器直接读写内存,CPU 只做设置与收尾 - CPU 配置 DMA 描述符:内存地址、长度、方向、队列位置 - DMA 引擎通过总线把数据直接写入/读出 DRAM - 完成后用中断通知 CPU(或写状态寄存器) - DMA 为什么更快 - 释放 CPU 周期 - DMA 更贴近总线/设备,能更高效做 burst 传输 - 配合 ring buffer/descriptor queue 能批量提交 - 内存映射 I/O 与端口 I/O - MMIO - 设备寄存器被映射到某段物理地址空间 - CPU 用普通的 load/store 指令读写这些地址,就等价于读写设备寄存器 - Port-mapped I/O - 使用专门的 IN/OUT 指令访问 I/O 端口空间 - 现代系统多以 MMIO 为主 - 零拷贝 - 传统读文件再发网络的拷贝路径 - read():磁盘->内核页缓存->拷贝到用户缓冲区 - write():用户缓冲区->再拷贝回内核 socket buffer -> 网卡 DMA 发走 - 至少需要两次 CPU 拷贝(内核<->用户,用户<->内核) - sendfile 思路 - 直接让内核把 page cache 中的文件页挂到 socket 发送队列 - 避免拷贝到用户态再拷回内核态 - 网卡通过 DMA 从内核缓冲直接发 - mmap 思路 - mmap 把文件映射进进程地址空间 - 应用像访问内存一样访问文件内容,缺页时 OS 按需把页载入 page cache - 适合随机访问,但是直接转发场景 sendfile 更适合

计算机网络

网络分层 + 端到端路径 - 网络分层 + 端到端路径 - 分层模型 - 链路层 (LINK) - 负责:同一链路/局域网内把 IP 包送到下一跳 - 典型概念:以太网、WIFI、ARP、MAC、MTU - 网络层 (IP) - 负责:跨网络把包从源 IP 路由到目的 IP - 典型概念:IP 地址、路由表、NAT、ICMP、分片 - 传输层 (Transport) - 负责:端到端进程通信,提供端口与可靠性 - 应用层 (Application) - 负责:定义应用语义 - 端到端路径(一次 HTTPS URL 发生了什么) - URL 解析与策略决策(应用层前置) - 浏览器先拆 URL: - schema:https -> 需要 TLS - host:example.com -> 需要 DNS - port:默认 443 - path:/index.html - 是否可用缓存 - 是否复用已有连接 - DNS 解析:把域名变成 IP - 目标:得到服务器的 IP (A=IPv4/AAAA=IPv6) - 典型流程 - 浏览器/OS 先查本地缓存 - 没命中 -> 向递归解析器发起查询 - 递归解析器询问:根 -> TLD -> 权威 DNS - 返回 IP + TTL - TTL 决定缓存时长 - 同一域名可能返回多个 IP:简单的负载均衡,但是不可控 - TCP 连接(传输层) - 目标:建立一条到服务器IP:443的 TCP 连接 - 典型流程 - 客户端选一个临时端口作为源端口 - 形成 socket 四元组 - 三次握手完成后,连接进入 ESTABLISHED - 连接的唯一标识符是四元组 - NAT 环境下:srcIP 可能是私网 IP,但是公网会被 NAT 改写 - RTT 大时,建连成本高:三次握手至少消耗 1 个 RTT - NAT:内网 IP 也能访问公网(网络层/链路层交叉) - 大多数客户端在 NAT - 内网 192.168.x.x:ramdomPort - 出口 NAT 把它映射成:公网 IP:另一个 Port - NAT 设备维护一张映射表(内网四元组<->公网四元组) - 映射有超时:长时间无数据可能被回收 - 并发连接太多可能端口耗尽 - TLS 握手:在 TCP 上建立安全通道 - 目标 - 验证服务器身份 - 协商密钥 - 后续数据用对称加密 + 完整性保护 - TLS 发生在 TCP 建立之后、HTTP 之前 - 证书用于“证明对方是它声称的域名“ - 发送 HTTP 请求并接受响应 - HTTP 内容 - 请求行:GET /index.html - 头:Host、User-Agent、Accept、Cookie - 体:GET 通常无,POST 常见 - 服务器返回 - 状态码:200/301/404/500 - 头:Content-Type、Content-Length、Set-Cookie - 体:HTML/JSON/图片等 - 连接复用与关闭:不是每次都重新握手 - HTTP/1.1 默认倾向长连接(Keep-Alive) - 浏览器有连接池,会复用到同一个 host:port 的连接 - 关键概念锚点 - RTT:网络交互的时间单位 - RTT = 一个来回的延迟 - 建连/TLS/重传都依赖 RTT - 高 RTT 下,交互次数越多越慢 - 吞吐与 BDP(带宽-时延积) - MTU 月 MSS - MTU:链路层最大承载 - MSS:TCP 单段可承载的应用数据最大值 - 包太大可能被分片或者丢弃 - 合理 MSS 能减少分片风险,提升稳定性 TCP 精讲 - TCP 精讲 - TCP 本质:在不可靠 IP 上提供可靠字节流 - TCP 提供内容 - 面向连接:双方都维护连接状态(序号、窗口、定时器等) - 可靠、有序:丢包重传、乱序重排、去重 - 字节流:没有消息边界(粘包/拆包的根源) - 流量控制:避免把对方接收缓冲打爆(rwnd),端到端 - 拥塞控制:避免把网络打爆,端到网络 - 报文段关键字段 - Sequence Number:本段数据在字节流里的起点序号 - Acknowledgment Number:我期望收到的下一个收到的序号 - Flags:SYN/ACK/FIN/RST/PSH/URG - Window(rwnd):接收端通告窗口 - Options:MSS,Window Scale - 连接建立:三次握手 - 标准流程 - C->S: SYN(seq=x) - S->C: SYN+ACK (seq=y,ack=x+1) - C->S: ACK (ack=y+1) - ![](https://an-img.oss-cn-hangzhou.aliyuncs.com/2026/02/17/20260217111458329.png,514,267) - 为什么必须三次 - 双方都要确认两件事 - 对方能收:对方收到了我的东西 - 对方能发:对方能把东西发回来让我收到 - 两次握手只能证明客户端 -> 服务端这条路通了;但是服务端无法证明服务端->客户端也通、且客户端确实收到了服务端的回应,第三次 ACK 就是让服务端拿到这个确认(服务端无法确认客户端能收) - 如果只有两次握手 - 服务端回 SYN + ACK 丢失了 - 服务端仍为连接建立,开始分配连接资源,等待数据 - 客户端实际上没有收到任何回应,会重试或放弃 - 三次握手还能防止历史重复 SYN 造成的“幽灵连接“ - 历史重复 SYN 指的是:某次旧连接尝试时发出的 SYN,由于网络拥塞、路由绕行、链路重传、设备缓存/异常等原因,在很久之后才到达服务器 - 如果服务器把旧 SYN 当新建连: - 服务器会分配资源 - 如果后续又出现旧 ACK 包,让服务器误以为连接已建立,交付错误的数据 - 幽灵连接:就是服务端自嗨式建立,客户端不认可 - 半连接队列与 SYN Flood - 服务端收到 SYN 后进入 SYN_RECV,会占用半连接资源(backlog) - SYN Flood:大量 SYN 不完成握手 —> 半连接队列被占满 -> 正常连接进不来 - 典型缓解:SYN cookies、增大 backlog、限速、丢弃策略、前置抗 DDoS - 连接关闭:四次挥手、半关闭 - 主动关闭方 A->B:FIN(不再发了) - B -> A:ACK (知道了) - B -> A:FIN (我也不再发了) - A -> B:ACK (知道了) - ![](https://an-img.oss-cn-hangzhou.aliyuncs.com/2026/02/17/20260217113908534.png,440,230) - 为什么常见是四次 - TCP 是全双工:A 不发 != B 不发 - B 可能还有数据要发完,所以 ACK 和 FIN 分开 - 半关闭 - A 发 FIN 表示 A 的发送方向关闭,但仍然可以接受 B 的数据 - 工程上常见于“请求发送完但继续接受响应流“的场景 - RST:不是正常关闭,是异常打断 - RST(Reset)是 TCP 里面非常硬的控制信号:立刻终止连接/拒绝连接 - 不是正常的四次挥手关闭,告诉对方:“这条连接不存在/状态不一致,马上停“ - RST 常见原因 - 端口没监听 - 应用崩溃/强制关闭 - 中间设备丢弃状态后注入 RST - TIME_WAIT、CLOSE_WAIT - 主动关闭方在发送最后一个 ACK 后进入 TIME_WAIT,并等待 2MSL。 - 必须 TIME_WAIT - 保证对方收到最后 ACK:最后 ACK 丢了,对方重发 FIN;TIME_WAIT 还能再次 ACK - 让旧连接的延迟报文过期:避免旧数据混入新连接 - 为什么是 2MSL - MSL = 报文在网络中可能存活的最大时间 - 等待 2MSL 是为了覆盖对方 FIN 重传 + 我 ACK 再次发送等往返的不确定性。 - TIME_WAIT 多了怎么办 - 常见场景:短连接高 QPS - 治理思路 - 优先做连接复用:HTTP Keep-Alive/连接池 - 调整应用行为:减少主动 close、合并请求、复用长连接 - 系统参数:允许端口更快复用等 - 通常是我方主动关 + 短连接 - CLOSE_WAIT 多是什么 - CLOSE_WAIT 表示:对方已经 FIN 了,但是我方应用还没有 close - 常见原因:应用层忘记关闭连接、连接泄露、线程阻塞导致没有走到 close - 通常是我方没关 - 可靠传输机制:序号、ACK、重传、SACK - 序号与累计 ACK - TCP 把数据看成连续字节流:每个字节都有序号 - ACK 是累计的:ack = N 表示 0..N-1 都收到了,下一步要 N - 好处:ACK 开销小 - 坏处:遇到乱序/丢包,单靠累计 ACK 不够精细 - 重传触发机制 - 超时重传(RTO):等不到 ACK 就重发(RTO 动态估计,基于 RTT 抖动) - TCP 发送一个数据段后,会期待收到 ACK,如果在某个时间窗口没有等到 ACK,TCP 可能认为丢了,于是触发超时重传。 - 等多久的时间阈值就是 RTO(Retransmission Timeout)。 - 触发条件:发送后,计时器到期,仍未收到能确认该段的 ACK - 行为:重传未确认的数据段 - RTO 动态估计 - 网络 RTT 会变,而且会抖动,RTO 必须跟随着链路实际 RTT 变化 - SRTT + RTTVAR -> RTO - 名词解释 - RTT sample:一次测得的 RTT(从发出某段到收到它对应 ACK 的时间) - SRTT (Smoothed RTT):平滑后的 RTT 均值。 - RTTVAR:RTT 的波动。 - RTO = SRTT + 4 * RTTVAR - SRTT 给出正常情况下 ACK 来回要多久 - RTTVAR 给出不确定性有多大 - 乘以 4 是给一个安全幅度:抖动越大,RTO 增长越明显,避免误重传 - 指数退避:超时一次,RTO 翻倍 - RTO 重传 vs 快速重传 - RTO 重传 - 触发:计时器到期,没等到 ACK - 特点:更“保底“,但通常更慢;而且代表网络更糟糕 - 常伴随:指数退避,cwnd 通常会大幅下降 - Fast Retransmit (快速重传) - 触发:重复 ACK - 特点:更快,更轻判拥塞 - 一半比 RTO 更温和 - 快速重传(Fast Retransmit):收到多个重复 ACK (典型 3 个)推断某段丢失了,立即重传) - RTO 重传局限性 - 必须等待一段时间没收到 ACK 才重传 - 在 RTT 较大或 RTO 比较保守时,恢复会很慢 - 快速重传的思路 - 不等超时,利用接收端反馈的 ACK 形态更早推断丢包 - 重复 ACK - 如果接收端收到了更后面的段,但是缺了中间某段 - 不能把乱序段交付给应用 - 会继续发送同一个累计 ACK,告诉发送端还在等从 1000 开始的那段,后面来的我先缓存 - ACK 值没前进,但又反复出现的 ACK 就是重复 ACK - 3 个重复 ACK 会触发快速重传 - 收到 3 个重复 ACK 代表某段大概率丢了 - 立即重传缺口对应的最早未确认段 - 快恢复 - 原因 - 收到快重传代表完了给没有完全堵死(还能持续收到 ACK) - 不必像 RTO 那样极度保守把发送速率打到很低 - Reno 直觉 - 触发快速重传后,认为发生拥塞 -> 降低 cwnd - 但利用重复 ACK 的到来,允许发送端在恢复期间适度继续发,避免完全停摆 - 当收到新的 ACK 后,退出快恢复,回到拥塞避免阶段 - SACK(选择确认) - 痛点:累计 ACK 信息不够 - 当出现丢包 + 乱序到达时,累计 ACK 只能不断重复同一个 ACK=N,发送端只知道缺口在 N,不知道 - 后面哪些段已经到了 - 究竟丢失了几段,丢失了哪几段 - 重传时是否把已到达的也重传 - 目标是把接收端实际收到的乱序块显式告诉发送端 - SACK 是什么:在 ACK 里带已收到的区间块 - SACK 是 TCP 的一个 Option,启用后 - 接收端在 ACK 报文的 TCP Options 中携带若干个 SACK blocks - 每个 block 描述一个已经收到的连续字节区间 - SACK block 的语义 - SACK block = (Left Edge, Right Edge) - 表示接收端已经收到:[LeftEdge, RightEdge) 这个区间 - ACK 字段仍然存在,表示最左侧连续已收前缀的下一个期待序号 - 启用流程:SACK Permitted - SACK 需要在三次握手时协商 - 双方在 SYN/SYN+ACK 中携带 SACK Permitted 选项 - 只要双方都声明支持,后续才会在 ACK 中携带 SACK blocks - 乱序与重排 - 网络层可能乱序,TCP 负责在接收端按序交付给应用 - 接收端会缓存乱序段,等缺口补齐再交付 - 滑动窗口 - 痛点 - 如果每发一段就必须等 ACK 再发下一段,吞吐会被 RTT 严重限制:链路大部分时间在等回音。 - 滑动窗口允许发送端在未收到 ACK 前,先发出一批数据,让数据在路上飞,从而把链路利用起来。 - 窗口 = 允许在途未确认数据的上限;窗口越大,在高 RTT 链路上容易跑满带宽 - rwnd(receive window,接收窗口,流量控制) - 由接收端通告(在 TCP 头的 Window 字段里) - 表示接收端缓冲区还剩多少空间 - 目的:防止发送端把接收端内存撑爆 - cwnd(congestion window,拥塞窗口,拥塞控制) - 由发送端维护 - 表示发送端认为网络当前能够承受的在途数据量上限 - 目的:防止把网络打爆 - swnd(send window,实际发送窗口) - 发送端真正允许“在途未确认“的上限 - 近似于 min(rwnd,cwnd) - 发送端视角:窗口如何限制发送 - 发送端维护两个关键指针 - SND.UNA:最早未确认的序号(UnACKed) - SND.NXT:下一个准备发送的序号(Next to send) - 窗口约束可以理解为: - 允许发送的序号范围:[SND.UNA, SND.UNA + swnd) - 已发送未确认的范围:[SND.UNA, SND.NXT) - 还能继续发送的额度:swnd - (SND.NXT - SND.UNA) - ACK 到来时发生什么 - 收到 ACK,把 SND.UNA 往前推进 - 已确认数据从窗口左边滑出 - 窗口右边腾出了空间,允许继续发送新的数据 - 接收端视角:为什么会出现 dupACK/乱序缓存 - 接收端维护两个关键指针 - RCV.NXT:下一段按序期望的序号 - 接收缓冲:可以暂存乱序到达的段 - 出现乱序 - 接收端会缓存后到的段,但 ACK 仍然回 RCV.NXT(累计 ACK) - 发送端看到重复 ACK (dupACK),可能触发快速重传 - 窗口、吞吐、RTT - 吞吐上限 = 窗口大小/RTT - RTT=100ms,窗口=64KB,吞吐=64KB/0.1s=640KB/s - BDP(带宽-时延积),窗口需要=BDP 才能跑满带宽 - 表示一条网络路径在任意时刻在路上飞着的数据量大概有多少,也叫管道容量 - BDP = 带宽 * 往返时延 (RTT) - 想要把链路跑满,发送端需要让“在途未确认数据“接近 BDP - 在途未确认数据主要受窗口限制:in_flight <= min(cwnd, rwnd) - 要跑满带宽,需要窗口大小 >= BDP - 零窗口与窗口探测 - 如果接收端缓冲满了,会通告 rwnd=0 - 发送端此时必须停止发送新数据,但是要避免死锁 - TCP 有 persist timer:周期性发送 Zero Window Probe 探测包 - 一旦接收窗口恢复,发送端继续发 - 粘包/拆包 - 粘包:在发送端 send() 了两条业务消息,接收端一次 recv() 读到了两条合在一起的字节。 - 拆包:在发送端 send() 了一条业务消息,接收端需要 recv() 多次才能读完 - TCP 不保证 send 一次就对应端 recv 一次 - 原因 - TCP 的抽象是流 - 像读文件一样读一串字节 - send():把字节追加到发送缓冲区 - recv():从接收缓冲区取走当前可读的字节 - 分段由 TCP 决定,不由应用决定 - 接收端读取粒度由应用决定 - 解法 - 在应用层定义消息边界 - 长度前缀(Length-Prefixed,最通用) - 格式 | 4-byte length (big-endian) | payload bytes... | - length 表示 payload 长度 - 接收端先读满 4 字节得到 length,再继续读满 length 字节 - 分隔符(Delimiter-Based,适合文本协议) - 行协议:以 \r\n 结尾 - 固定长度(Fixed-Size) - 每条消息固定 N 字节 - 拥塞控制 - 拥塞不是对端瘦不下,那是流量控制 rwnd,拥塞是网络路径上的队列/链路承载不住 - 路由器/交换机队列堆积 -> RTT - 队列溢出 -> 丢包 - 丢包/排队抖动 -> 吞吐下降、延迟暴涨 - 拥塞控制控制的是发送端允许在网络中飞着的未确认数量,cwnd - Reno 的两个核心状态变量 - cwnd:拥塞窗口(发送端认为网络现在能承受的在途数据量) - ssthresh:慢启动阈值,决定从指数增长到线性增长的分界点 - 四个阶段 - 慢启动 - 目的:刚开始不知道网络能承受多少,先快速试探 - 规则: - 初始 cwnd 为一个较小值,历史是 1MSS - 每收到一个 ACK,cwnd 增加 1MSS - 因为一个 RTT 内会收到大约 cwnd/MSS 个 ACK,所以每个 RTT cwnd 近似翻倍 - 每 RTT 把在路上的包数翻倍,迅速逼近瓶颈带宽 - 太快可能把队列灌满,引发丢包 - 拥塞避免 - 当 cwnd 到达或超过 ssthresh,从慢启动切换到拥塞避免 - 每 RTT 让 cwnd 大约增加 1 MSS,每个 ACK 增加 MSS*MSS/cwnd - 大多数时间连接处于的阶段 - 快重传:用 dupACK 提前判断丢包,不等 RTO - 触发条件 - 收到 3 个重复 ACK dupACK - ACK 号不前进,连续出现多次 - 快恢复:丢包后减速但不中断,比 RTO 温和 - 发生快重传后的关键动作 - 设定新的值 - ssthresh = cwnd/2 - cwnd 不回到很小,而是进入快恢复逻辑 - 当收到能够推进 ACK 的新 ACK,退出快恢复,进入拥塞避免 - RTO vs dupACK - RTO:最严重,认为网络可能很糟 - ssthresh = cwnd / 2 - cwnd 下降到很小,经典是 1MSS - 回到慢启动(重新探测) - RTO 通常会指数退避,导致恢复更慢 HTTP 与 Web 体系 - HTTP 与 Web 体系 - HTTP 基础语义 - HTTP 是无状态应用层协议:每个请求都自包含信息,状态由 Cookie/Token/Session 等机制在应用层实现 - HTTP 定义的语义,具体传输方式由 HTTP/1.1、HTTP/2、HTTP/3 决定 - 方法:语义 + 幂等 + 安全性 - GET:获取资源,应当安全+幂等 - HEAD:只要响应头,不要响应体,用作探测/缓存探测 - POST:提交数据,创建子资源或触发动作,通常非幂等 - PUT:整体替换/创建指定资源,通常幂等 - PATCH:部分更新,是否幂等取决于语义设计 - DELETE:删除资源,通常幂等 - OPTIONS:探测服务器能力(常用于 CORS 预检) - 状态码 - 1xx:信息(少见) - 2xx:成功 - 200 OK - 201 Created 创建成功 - 204 No Content 成功但无响应体 - 3xx:重定向 - 301 永久 - 302 临时(历史语义混乱) - 303 让客户端用 GET 拿 - 307/308 保持方法不变 - 4xx:客户端错误 - 400 参数/格式问题 - 401 需要认证 - 403 拒绝 - 404 不存在 - 409 冲突 - 429 限流 - 5xx:服务器错误 - 500、502 上游坏/网关问题 - 503 不可用 - 504 上游超时 - Header - 通用与内容相关 - Content-Type:实体类型(json/html...) - Content-Length:长度(HTTP/1.1 重要) - Content-Encoding:gzip/br 等压缩 - Accept/Accept-Encoding/Accept-Language:内容协商 - Host:HTTP/1.1 必须(虚拟主机) - Connect:连接管理(HTTP/2 中不再使用) - 会话与安全 - Cookie/Set-Cookie - Authorization (Bearer/Basic 等) - Origin/Refer:CORS/CSRF 相关 - Strict-Transport-Security:强制 HTTPS - Content-Security-Policy(CSP):前端安全策略 - 缓存相关 - Cahce-Control - Expires - ETag/If-None-Match - Last-Modified/If-Modified-Since - Vary - Age (代理缓存的年龄) - Pragma:no-cache (历史兼容) - HTTP 缓存 - 两类缓存:强缓存vs协商缓存 - 强缓存 - 命中强缓存时浏览器直接用本地缓存,不走网络 - 典型控制 - Cache-Control: max-age= - Expires:老机制 - 协商缓存(发请求但可能不下载) - 浏览器发请求带校验文件,服务器判断资源是否变化 - ETag 流程 - 响应:ETag: "xxx" - 下次请求:If-None-Match: "xxx" - 若未变:返回 304 Not Modified - Last-Modified 流程 - 响应:Last-Modified: ... - 下次请求:If-Modified-Since:... - 未变:304 - Cache-Control 关键指令 - max-age=N:资源在 N 秒内视为新鲜(强缓存) - no-store:绝对不缓存 - no-cache:可以缓存,但每次使用前必须去服务器验证 - must-revalidate:过期后必须验证,不能用陈旧副本 - public:允许被共享缓存(CDN/代理)缓存 - private:只允许浏览器私有缓存,不允许共享缓存缓存 - s-maxage=N:给共享缓存的 max-age - Vary 存储维度的分桶键 - Vary 告诉缓存,同一个 URL 的响应会因为某些请求头不同而不同 - Vary:Accept-Encoding,gzip/br 不同版本要分开缓存 - Vary:Origin:CORS 常见 - Vary 过多会让缓存碎片化,命中率下降 - ETag 的强弱、代理与一致性 - 强 ETag:字节级一致才算命中 - 若 ETag (W/):语义上等价即可 - HTTP/1.1:连接、报文、队头阻塞 - HTTP/1.1 报文与传输 - 文本协议:请求行 + 头 + 空行 + body - 连接默认与持久化:Keep-Alive (减少握手成本) - Chunked Transfer-Encoding:服务端可流式传输,不提前知道 Content-Length - HTTP/1.1 的性能问题:应用层 HOL - 同一连接上请求/响应按顺序进行 - 一个慢请求会阻塞后续的请求(队头阻塞) - HTTP/2:二进制分帧与多路复用 - HTTP/2 为什么快 - 二进制分帧:把大消息拆成帧 - 多路复用:多个 stream 在同一 TCP 连接上并发交错传输 - 头部压缩:HPACK(减少重复头部开销) - HTTP/2 性能问题:TCP 层 HOL(队头阻塞) - TCP 一旦丢包,需要重传并按序交付 - 丢的那段之前的洞补不上,后续数据即使到了也不能交给上层 - 结果:一个丢包会让同连接上所有 stream 都受影响 - HTTP/3/QUIC - 核心变化 - HTTP/3 跑在 QUIC 上,QUIC 基于 UDP - QUIC 自己实现可靠传输与拥塞控制 - 按 stream 提供独立的有序交付,一个 stream 丢包不影响其他 stream 的交付 - TLS 与 HTTPS - 位置与成本 - HTTPS = TCP 建连后再 TLS 握手,再 HTTP - 首次访问慢常见原因:DNS + TCP + TLS 多次 RTT - 解决的问题 - 身份认证:验证连接的是域名的真实服务器 - 机密性:传输内容加密 - 完整性:防篡改 - TLS 握手 - TLS 1.2 - ClientHello:客户端支持的版本/套件,随机数,SNI 等 - ServerHello:选定版本/套件、随机数,让双方对本次会话参数单程一致 - Certificate:服务器发送证书链,客户端据此验证:链是否可信、域名是否匹配、是否过期,用途是否正确 - ServerKeyExchange:服务器提供 ECDHE 参数与公钥,客户端通过验证签名,确保密钥协商参数的安全性 - ServerHelloDone:告诉客户端,材料已经发完 - ClientKeyExchange:客户端协商参数,客户端发送自己的 ECDHE 公钥,双方据此计算共享 secret - ChangeCipherSpec + Finished:双方切换到加密通信并校验握手完整性 - Web 会话:Cookie/Session/Token - Web 会话解决的问题 - 认证:你是谁 - 授权:你能做什么 - 会话保持:登录态怎么持续、怎么过期、怎么注销 - 安全边界:防窃取、防伪造、防重放、防跨站 - Cookie:浏览器自动携带的会话容器 - Cookie 是浏览器保存的一组 key=value,由服务器通过响应头 Set-Cookie 下发。 - 后续请求中,浏览器会按照规则自动带上 Cookie 头,无需 JS 参与 - Cookie 关键属性 - Domain/Path:决定发送范围 - Domain=example.con:子域也可能携带 - 不设置 Domain:仅当前 host - Path=/api:只对当前路径生效 - Expires/Max-age:决定生命周期 - Max-Age=0:立即删除 - Session Cookie:不设过期时间,随浏览器进程结束而清理 - Secure:只在 HTTPS 发送 - HttpOnly:禁止 JS 读取 - 有 HttpOnly 的 cookie,JS 读取不到 - 不能防止 CSRF - SameSite:控制跨站请求是否携带 - Strict:几乎不跨站携带 - Lax:默认常用 - None:允许跨站携带,必须同时设置 Secure - Session:服务端保存状态,客户端只存门票 - Session 的典型模式 - 服务端维护 session_id -> session_data - 客户端用 Cookie 存 session_id - 每次请求:浏览器自动带 cookie -> 服务端查 session store -> 识别用户 - Token(尤其是 JWT):把身份证明带在请求里 - Token 的核心模式 - 服务端签发 token 给客户端 - 客户端后续请求带 token - 服务端验证 token 来识别用户 - JWT 结构 - JWT=header.payload.signature - header:算法等元信息 - payload:claims 声明 - signature:服务端用密钥签名 - Refresh Token 与 Access Token - Access Token:短有效期,每次用于请求 - Refresh Token:长有效期,用于换取新的 Access Token - 安全边界:XSS、CSRF、会话固定、重放 - XSS(跨站脚本) - 若 token/cookie 可以被 JS 读取,xss 可以直接窃取并盗用 - cookie 用 HttpOnly + Secure - CSP 减少脚本注入 - 输入输出编码 + 模板转义 - CSRF 跨站请求伪造 - CSRF 利用浏览器自动携带 cookie - 攻击者诱导用户在登录态下访问恶意页面,发起跨站请求到受害站点 - 防护手段 - SameSite Cookie (优先) - CSRF Token(表单/请求头携带,服务端校验) - 检查 Origin/Rerfer - 同源策略与 CORS - 痛点 - 浏览器安全模型默认假设:不同站点之间互不信任 - 浏览器用同源策略(Same-Origin Policy,SOP)限制一个网页脚本能读取另一个来源的资源内容 - 业务需要跨域调用 API,有了 CORS(CrossOrigin Resource Sharing):在 SOP 的基础上提供一个受控的放行机制。 - Orgin 源是什么:同源的三元组 - Origin = scheme + host + port - https://example.com:443 - 同源策略具体限制 - 跨源读被禁止 - JS 用 fetch 请求 https://xxx.com,请求可能能发出去,但是浏览器会阻止 JS 读取响应,除非响应满足 CORS 规则 - 跨源写/发送很多情况下是允许的 - <form action="xxx.com" method="POST"> 可以提交,因此 CSRF 能成立,请求可以发出去,但是页面读取不到 bank.com 的响应内容 - 部分资源加载天然跨源允许 - <img> <scirpt> <link> 浏览器允许加载这些资源 - CORS 本质:服务器声明允许,浏览器执行拦截 - 浏览器在请求带上 Origin - 服务器在响应返回 Access-Control-* 头 - 浏览器据此决定:是否把响应暴露给 JS - CORS 两种请求路径:简单请求 vs 预检请求 - 简单请求 - 方法是 GET/HEAD/POST - Content-Type 只能是三种之一 - application/x-www-form-urlencoded - multipart/form-data - text/plain - 请求头只能是 CORS-safelistedhead,不能随意加自定义头 - 预检请求 - 不满足简单请求条件,浏览器会先发送一个 OPTIONS 请求,询问服务器是否允许 - 预检请求携带关键头 - Origin: https://a.com - Access-Control-Request-Method:PUT - Access-Control-Request-Headers:x-token(将要携带的自定义头列表) - 服务器如果允许,需要在 OPTIONS 响应返回 - Access-Control-Allow-Origin - Access-Control-Allow-Methods - Access-Control-Allow-Headers - Access-Control-Max-Age(预检缓存多久) - 通过后,浏览器才会发真实请求 - CORS 响应头:每个头怎么用 - Access-Control-Allow-Origin - 指定允许的 Origin - 可以是精确 Origin 或者 * (允许任意源),如果要带 Cookie/凭证,不能用 * - Access-Control-Allow-Methods - 允许的方法列表:GET/POST - 主要用于预检响应 - Access-Control-Allow-Headers - 允许的自定义请求头列表 - 必须覆盖预检中 Access-Control-Request-Headers 请求到的那些头 - Access-Control-Allow-Credentials - true 表示允许携带凭证,例如 Cookie - 配合前端请求 credentials: 'include' - Access-Control-Expose-Headers - 默认情况下,JS 能够读取的响应头很有限,如果希望暴露,必须显式添加 - Vary: Origin - 按 Origin 分桶缓存 - DNS 解析 - 基础对象 - 记录类型 - A(IPv4) - AAAA(IPv6) - CNAME(别名) - NS(权威) - 解析角色 - Stub resolver(客户端/OS) - Recursive resolver(递归解析器:运营商/公司 DNS/公共 DNS) - Authoritative server (权威 DNS) - 正向代理、反向代理、透明代理与隧道 - 代理:本质上是位于客户端与服务端之间的一种中间节点,代表一侧发起连接、转发数据、并可能施加策略 - 承担职责 - 路由/转发:把请求送到正确上游 - 安全控制:鉴权、WAF、访问控制 - 性能优化:连接复用、压缩、缓存、限流 - 可观测性:日志、指标、追踪、采样 - 协议转换:HTTP <-> grpc 等 - 正向代理 - 正向代理站在客户端一侧,客户端明确知道自己在用代理,由代理代表客户端访问外部资源 - 反向代理 - 反向代理站在服务端一侧,对外它像真正的服务器入口,对内把请求转发到后端服务 - 反向代理常用原因 - 隐藏后端拓扑:外部只有一个入口 - 统一 TLS 终止:证书集中管理 - 统一流量治理:限流、熔断、灰度、路由、鉴权 - 连接复用:对外维持大量连接、对内复用少量连接 - 观测与审计:同一日志/指标/链路追踪 - 透明代理 - 隧道:Connect 到底干了什么 - 隧道:代理不解析上层协议,只做字节透传。 - HTTP Connect 隧道流程 - 客户端先对代理发 CONNECT - 客户端 -> 代理 - CONNECT bank.com:443 HTTP/1.1 - Host: back.com:443 - 代理建立到目标的 TCP 连接 - 代理 -> bank.com:443:发起 TCP 连接 - 代理回客户端 - HTTP/1.1 200 Connect Established - 隧道建立,字节透传 - 客户端与 bank.com 的 TLS 握手数据会被原封不动地通过代理转发,代理看不懂也不需要看懂 - TLS 会话在客户端<->目标服务器之间建立 - 负载均衡:L4 vs L7、算法、会话保持、健康检查 - 目标 - 扩展性:把流量分散到多台实例,提高吞吐 - 高可用:实例故障自动摘除,流量不中断或快速恢复 - 性能:就近、减少重连、减少排队、提升尾延迟 - 治理:灰度、限流、熔断、路由、可观测 - 边界 - LB 解决的是把请求推送到谁,不解决业务正确性 - LB 层任何自动重试/超时,都可能改变业务语义 - L4 与 L7 的区别 - L4 LB(传输层):基于 IP/端口/连接做转发 - 优点:性能高,对协议透明 - 缺点:无法基于 URL/Header 做路由 - 常见实现:内核转发、四层 VIP、DSR、NAT 模式等 - 适合长连接:WebSocket、MQ、数据库连接等 - L7 LB(应用层):理解 HTTP,按照 Host/Path/Header/Cookie 路由 - 常见常见:API Gateway、Ingress、CDN 回源入口 - 负载均衡算法 - Round Robin(轮询) - 依次分发到每个后端 - 适合:请求耗时相近、后端同质、短连接 - 问题 - 后端性能差异大时不均衡 - 长连接场景下连接一旦分配就会粘住,RR 也会失衡 - Weighted Round Robin - 按权重分配(机器规格不同,冷热分层) - 适合:异构机器,逐步扩容 - 问题:权重需要维护;负载时间随时间变化时仍然不准 - Least Connections(最少连接) - 连接数最少的后端连接 - 适合:长连接、连接数与负载相关的业务 - 问题: - 连接数不等于负载 - 需要 LB 维护连接计数状态 - Least Request/Least Load(最少请求/最小负载) - 按当前活跃请求数或综合指标选择 - 适合:请求耗时差异大,尾延迟敏感 - 问题:实现复杂、依赖观测准确性;指标延迟可能导致震荡 - Random/Power of Two Choices(随机/二选一) - 随机挑两个后端,选最优者 - 优点:简单且实际效果接近最优,分布更稳定,大规模下能显著降低热点概率 - 适合:大规模集群、需要避免中心化状态 - Consistent Hash(一致性 hash) - 按 key 映射到固定节点 - 适合: - 需要缓存命中(本地 cache) - 会话保持(不想引入共享 session) - 分片存储(按 key 分片) - 关键性质:节点增减时迁移量小 - 问题: - 热点 key 会压垮单节点(热点拆分/虚拟节点) - 节点故障时 key 重映射会引发抖动。 - 会话保持 - 为什么需要 sticky - 后端有状态 - 本地缓存必须命中 - WebSocket/长连接绑定到某实例 - 常见 sticky 手段 - Source IP hash - 按客户端 IP 做 hash 选节点 - 优点:简单 - 缺点: - NAT/代理下大量用户共享同一出口 IP -> 严重倾斜 - 用户换网 IP 变化导致漂移 - Cookie 粘性 - LB 注入 cookie ,后续按照 cookie 路由 - 优点:比 IP 稳定 - 缺点:跨域,隐私策略,cookie 清理,灰度/回源复杂 - 一致性哈希(按 userId/会话键) - 工程风险 - 降低容错:节点挂了,该节点的用户都受影响 - 产生热点:某些用户/租户流量大->单节点热 - 扩缩容抖动:重新分配导致缓存冷启动、延迟尖刺 - 推荐的替代方案 - 应用无状态 + 共享 session (Redis) - 把状态外置(DB/Cache),实例可以随时替换 - 长连接常见:连接层 sticky 不可避免,要做好限流与迁移策略 - 健康检查 - L4 健康检查 - 探测 TCP 能否 connect 成功 - 优点:便宜、通用 - 缺点:应用假活时仍然会通过(线程池满、依赖挂) - L7 健康检查 - 请求 /healthz 或 /readyz - 可检查 - 进程存活 - 是否可接新流量 - 关键依赖是否可用 - 设计要点 - liveness vs readliness 分离 - liveness:进程是否需要重启 - readliness:是否接流量 - 避免健康检查过重 - 如果每秒大量探测并且检查依赖,会把依赖也打挂 - 阈值与抖动控制 - 连续 N 次失败才摘除 - 连续 M 次成功才恢复 - 慢启动与预热 - 新实例启动后先不接流量,预热缓存/JIT,再标 ready - 网络排障 - L3/L4 基础联通与路径 - ping - 测试的是什么 - ping 用的是 ICMP Echo Request/Echo Reply - 发 Echo Request(带序号 seq、时间戳等) - 对端回 Echo Reply - 测量往返时延 RTT - ![](https://an-img.oss-cn-hangzhou.aliyuncs.com/2026/02/18/20260218103650584.png,492,184) - 查看延迟 - 看 avg 和 min/max - 抖动 - RTT 的波动程度 - stddev(标准差) 大,说明网络队列/无线/拥塞波动明显 - 丢包 - packet loss > 0 说明有包没有收到回包 - tracerout/mtr 的原理 - traceroute:用 TTL 把路径探测出来 - IP 包里有 TTL,每过一跳路由器把 TTL - 1 - 当 TTL 变为 0,路由器丢包并回 ICMP Time Exceeded - traceroute 依次发 TTL=1,2,3 的探测包 - 可以看到每一跳的地址与该跳的 RTT - 每一跳显式的 RTT 是你->该跳->你的往返,不是单程,也不是该跳->下一跳的延迟 - mtr:traceroute + ping 的结合 - mtr 会对每一跳持续发探测包并统计 - 丢包率 - 最快/平均/最慢 RTT - 抖动 - mtr 更适合定位抖动/偶发丢包/拥塞点 - 用 traceroute 看路径与绕路 -

06

前端

2 posts 查看全部 →

js 复习

语言核心基础 - 语言核心基础 - 变量与数据类型 - 变量声明 - JavaScript 有三种声明变量的关键字,它们的核心区别在于作用域和可变性 - var - var 是 ES5 时代的声明方式,它的作用域是函数级别的,而不是块级的。 - 这意味着在 if 或 for 的大括号里用 var 声明的变量,在外面依然可以访问 - 另外 var 存在"变量提升"(hoisting)——声明会被提升到函数顶部,但赋值不会,所以在声明前访问会得到 undefined 而不是报错 - ```js function example() { console.log(a); // undefined(不会报错,因为声明被提升了) var a = 10; if (true) { var b = 20; } console.log(b); // 20(var 没有块级作用域) } ``` - let - let 是 ES6 引入的,拥有块级作用域,不会被提升到可访问状态(存在"暂时性死区" TDZ),在声明前访问会直接报 ReferenceError。 - ```js function example() { // console.log(a); // ReferenceError! let a = 10; if (true) { let b = 20; } // console.log(b); // ReferenceError! b 只存在于 if 块内 } ``` - const - const 和 let 一样有块级作用域和 TDZ,但它要求声明时必须赋值,且绑定不可重新赋值。 - 注意"不可重新赋值"不等于"不可变"——如果 const 指向一个对象或数组,对象内部的属性是可以修改的。 - ```js const PI = 3.14159; // PI = 3; // TypeError: Assignment to constant variable const user = { name: 'An' }; user.name = 'Bob'; // ✅ 没问题,修改的是对象属性,不是重新赋值 // user = {}; // ❌ TypeError,不能重新绑定 ``` - 七种原始类型 - JavaScript 的数据类型分为原始类型和引用类型。 - 原始类型有 7 种,它们的值是不可变的,按值传递。 - string 字符串 - ```js const s1 = 'hello'; // 单引号 const s2 = "world"; // 双引号 const s3 = `你好 ${s1}`; // 模板字符串,支持插值和换行 ``` - 字符串是不可变的。s1[0] = 'H' 不会报错但也不会生效。所有字符串方法都返回新字符串。 - number 数字 - JavaScript 只有一种数字类型,使用 IEEE 754 双精度浮点数。这带来了经典问题 - ```js console.log(0.1 + 0.2); // 0.30000000000000004 console.log(0.1 + 0.2 === 0.3); // false // 特殊值 console.log(Infinity); // 正无穷 console.log(-Infinity); // 负无穷 console.log(NaN); // Not a Number console.log(NaN === NaN); // false!NaN 不等于自身 console.log(Number.isNaN(NaN)); // true,用这个判断 ``` - bigint 大整数 - 当数字超过 Number.MAX_SAFE_INTEGER(2⁵³ - 1)时使用 - ```js const big = 123456789012345678901234567890n; // 末尾加 n console.log(typeof big); // "bigint" // big + 1; // ❌ TypeError,bigint 不能和 number 混合运算 big + 1n; // ✅ ``` - boolean 布尔值 - 只有 true 和 false。但在条件判断中,其他类型会被隐式转换为布尔值。 - 记住这些 falsy 值(转换为 false 的值): - ```js // 以下全部是 falsy false, 0, -0, 0n, "", null, undefined, NaN // 其他一切都是 truthy,包括: // "0", " ", [], {}, function(){} 都是 truthy! ``` - null 空值 - 表示"有意为空",通常用于显式地标记一个变量"目前没有值"。 - ```js let user = null; // 明确表示:当前没有用户 ``` - undefined 未定义 - 表示变量已声明但未赋值,或者函数没有返回值 - ```js let x; console.log(x); // undefined function foo() {} console.log(foo()); // undefined ``` - symbol 符号 - ES6 引入,每个 Symbol 都是唯一的,主要用作对象属性的唯一标识符 - ```js const id1 = Symbol('id'); const id2 = Symbol('id'); console.log(id1 === id2); // false,每次调用都创建新的 const obj = { [id1]: '这个属性名是唯一的' }; ``` - 引用类型(Reference Types) - 除了原始类型,剩下的都是引用类型(对象)。 - 包括普通对象 {}、数组 []、函数 function、Date、RegExp 等等。 - 引用类型按引用传递,变量存的是内存地址而不是值本身 - ```js const a = { name: 'An' }; const b = a; // b 和 a 指向同一个对象 b.name = 'Bob'; console.log(a.name); // "Bob" —— a 也被改了! // 数组同理 const arr1 = [1, 2, 3]; const arr2 = arr1; arr2.push(4); console.log(arr1); // [1, 2, 3, 4] ``` - typeof 操作符 - typeof 返回一个字符串,表示值的类型 - ```js typeof 42; // "number" typeof 'hello'; // "string" typeof true; // "boolean" typeof undefined; // "undefined" typeof Symbol(); // "symbol" typeof 10n; // "bigint" // ⚠️ 两个著名的"坑" typeof null; // "object" —— 历史遗留 bug,null 其实是原始类型 typeof function(){};// "function" —— 虽然函数也是对象 typeof []; // "object" typeof {}; // "object" ``` - 判断数组应该用 Array.isArray(),判断 null 用 === null - 类型转换 - 显式转换 - ```js // 转字符串 String(123); // "123" (123).toString();// "123" // 转数字 Number("42"); // 42 Number(""); // 0 Number("hello"); // NaN Number(true); // 1 Number(null); // 0 Number(undefined);// NaN parseInt("42px");// 42 —— 会尽量解析 parseFloat("3.14abc"); // 3.14 // 转布尔 Boolean(0); // false Boolean(""); // false Boolean("0"); // true ← 注意!非空字符串都是 true Boolean([]); // true ← 注意!空数组也是 true ``` - 隐式转换 - ```js // + 号:只要有一边是字符串,就做字符串拼接 '5' + 3; // "53" 5 + '3'; // "53" // 其他算术运算符会把字符串转为数字 '5' - 3; // 2 '5' * 2; // 10 '5' / '2'; // 2.5 // == 的隐式转换(不推荐使用) 0 == false; // true "" == false; // true null == undefined; // true null == 0; // false ← 特殊规则 // === 严格相等,不做类型转换(推荐始终使用) 0 === false; // false "" === false; // false ``` - 运算符与流程控制 - 算术运算符 - 基本的 +、-、*、/ 之外,还有几个需要注意的 ```js // 取余(求模) console.log(10 % 3); // 1 console.log(-10 % 3); // -1(结果符号跟被除数一致) // 幂运算(ES2016) console.log(2 ** 10); // 1024,等价于 Math.pow(2, 10) // 自增/自减 let a = 5; console.log(a++); // 5(先返回再加) console.log(a); // 6 console.log(++a); // 7(先加再返回) ``` - 复合赋值运算符就是简写:+=、-=、*=、/=、%=、**=、??=、||=、&&= ```js let count = 10; count += 5; // 等价于 count = count + 5 count **= 2; // 等价于 count = count ** 2 // 逻辑赋值(ES2021) let name = null; name ??= 'Anonymous'; // 如果 name 是 null/undefined,才赋值 console.log(name); // "Anonymous" let config = ''; config ||= 'default'; // 如果 config 是 falsy,就赋值 console.log(config); // "default" ``` - 比较运算符 - ==(宽松相等) 会在比较前做隐式类型转换,规则非常复杂: - ```js 0 == false; // true(false 转为 0) '' == false; // true(两边都转为 0) '1' == 1; // true(字符串转为数字) null == undefined; // true(特殊规则,它俩只跟对方相等) null == 0; // false(null 不会被转为数字) NaN == NaN; // false(NaN 不等于任何东西包括自己) [] == false; // true([] → '' → 0,false → 0) [] == ![]; // true(这个最离谱,但确实是 true) ``` - ===(严格相等) 不做类型转换,类型不同直接返回 false: - ```js 0 === false; // false(number vs boolean) '' === false; // false '1' === 1; // false null === undefined; // false ``` - 实际开发原则:永远使用 === 和 !==。 - 唯一可能用 == 的场景是 x == null 来同时判断 null 和 undefined - 更推荐写成 x === null || x === undefined,或者用 x ?? defaultValue - 逻辑运算符与短路求值 - 逻辑运算符在 JS 中的行为和很多语言不同——它们返回的不是布尔值,而是操作数本身。 - &&(逻辑与):如果左侧是 falsy,返回左侧;否则返回右侧。 - ```js console.log(0 && 'hello'); // 0(左侧 falsy,直接返回左侧) console.log(1 && 'hello'); // "hello"(左侧 truthy,返回右侧) console.log('a' && 'b' && 0); // 0(逐个检查,遇到第一个 falsy 就返回) console.log('a' && 'b' && 'c'); // "c"(全 truthy,返回最后一个) // 实际用法:条件执行 user && user.login(); // 如果 user 存在才调用 login ``` - ||(逻辑或):如果左侧是 truthy,返回左侧;否则返回右侧。 - ```js console.log(0 || 'default'); // "default" console.log('hello' || 'default'); // "hello" // 经典用法:提供默认值 const port = config.port || 3000; // ⚠️ 陷阱:如果 config.port 是 0,也会被跳过! ``` - ??(空值合并,ES2020):只有左侧是 null 或 undefined 时才返回右侧。这解决了 || 的陷阱。 - ```js const port = config.port ?? 3000; // config.port 为 0 → 返回 0 ✅ // config.port 为 '' → 返回 '' ✅ // config.port 为 null/undefined → 返回 3000 // 对比 0 || 3000; // 3000(0 是 falsy) 0 ?? 3000; // 0(0 不是 null/undefined) '' || 'default'; // "default" '' ?? 'default'; // "" ``` - ! (逻辑非) - ```js console.log(!0); // true console.log(!''); // true console.log(!null); // true // 双重取反 !! 可以将任意值转为布尔值 console.log(!!0); // false console.log(!!'hello'); // true console.log(!!null); // false ``` - 可选链与其他实用运算符 - ?.(可选链,Optional Chaining):安全地访问嵌套属性,遇到 null/undefined 时短路返回 undefined 而不是报错。 - ```js const user = { name: 'An', address: { city: 'Tokyo' } }; // 不用可选链 const zip = user && user.address && user.address.zip; // 用可选链 const zip2 = user?.address?.zip; // undefined,不报错 const nothing = null?.foo?.bar; // undefined // 也可以用在方法调用和下标访问上 user.greet?.(); // 如果 greet 方法存在才调用 const first = arr?.[0]; // 如果 arr 存在才取下标 ``` - 逗号运算符:依次执行所有表达式,返回最后一个。用得不多,偶尔在 for 循环中见到 - ```js const result = (1, 2, 3); // result === 3 for (let i = 0, j = 10; i < j; i++, j--) { /* ... */ } ``` - 条件语句 - if/else if/else - ```js const score = 85; if (score >= 90) { console.log('优秀'); } else if (score >= 70) { console.log('良好'); } else if (score >= 60) { console.log('及格'); } else { console.log('不及格'); } ``` - 三元表达式:适合简单的二选一,不要嵌套太深 - ```js const status = score >= 60 ? '及格' : '不及格'; // ❌ 不推荐:嵌套三元,可读性很差 const grade = score >= 90 ? 'A' : score >= 70 ? 'B' : score >= 60 ? 'C' : 'D'; // ✅ 这种情况用 if/else 或函数更清晰 ``` - switch:适合对同一个值做多重精确匹配,注意 break 不能忘 - ```js const day = 'Monday'; switch (day) { case 'Monday': case 'Tuesday': case 'Wednesday': case 'Thursday': case 'Friday': console.log('工作日'); break; // ← 不写 break 会"穿透"到下一个 case case 'Saturday': case 'Sunday': console.log('周末'); break; default: console.log('无效日期'); } ``` - switch 使用 === 进行比较,不会做隐式类型转换。 - 循环语句 - for 循环:最经典的循环,适合需要精确控制索引的场景: ```js for (let i = 0; i < 5; i++) { console.log(i); // 0, 1, 2, 3, 4 } // 倒序遍历 for (let i = arr.length - 1; i >= 0; i--) { console.log(arr[i]); } ``` - while 与 do...while: ```js // while:先判断再执行 let n = 5; while (n > 0) { console.log(n); n--; } // do...while:先执行一次再判断,至少执行一次 let input; do { input = prompt('请输入密码'); } while (input !== 'secret'); ``` - for...of:遍历可迭代对象(数组、字符串、Map、Set 等)的值: ```js const fruits = ['苹果', '香蕉', '橘子']; for (const fruit of fruits) { console.log(fruit); // "苹果", "香蕉", "橘子" } // 遍历字符串的每个字符(对中文友好) for (const char of '你好世界') { console.log(char); // "你", "好", "世", "界" } // 如果同时需要索引,用 entries() for (const [index, fruit] of fruits.entries()) { console.log(`${index}: ${fruit}`); } ``` - for...in:遍历对象的可枚举属性名(键名): ```js const user = { name: 'An', age: 25, city: 'Tokyo' }; for (const key in user) { console.log(`${key}: ${user[key]}`); } // ⚠️ 不推荐用 for...in 遍历数组! // 它会遍历原型链上的属性,顺序也不保证 // 遍历数组请用 for...of 或数组方法 ``` - break、continue 与标签 - ```js // break:立即退出整个循环 for (let i = 0; i < 10; i++) { if (i === 5) break; console.log(i); // 0, 1, 2, 3, 4 } // continue:跳过本次迭代,进入下一次 for (let i = 0; i < 10; i++) { if (i % 2 === 0) continue; // 跳过偶数 console.log(i); // 1, 3, 5, 7, 9 } // 标签(label):用于跳出嵌套循环,实际开发很少用 outer: for (let i = 0; i < 3; i++) { for (let j = 0; j < 3; j++) { if (i === 1 && j === 1) break outer; // 直接跳出外层循环 console.log(i, j); } } ``` - 函数基础 - 函数声明 vs 函数表达式 - JavaScript 中定义函数有两种基本方式,它们最大的区别在于提升(hoisting)行为。 - 函数声明(Function Declaration):整个函数会被提升到作用域顶部,因此可以在声明之前调用。 - ```js // ✅ 可以在声明之前调用 greet('An'); function greet(name) { console.log(`你好,${name}!`); } ``` - 函数表达式(Function Expression):把一个匿名或具名函数赋值给变量。变量声明会提升,但赋值不会,所以在赋值之前调用会报错。 - ```js // ❌ TypeError: sayHi is not a function // sayHi('An'); const sayHi = function(name) { console.log(`Hi, ${name}!`); }; sayHi('An'); // ✅ // 具名函数表达式:函数名只在函数内部可见,主要用于递归和调试 const factorial = function fact(n) { return n <= 1 ? 1 : n * fact(n - 1); }; // fact(5); // ❌ ReferenceError,外部不可见 factorial(5); // ✅ 120 ``` - 箭头函数 - ES6 引入的简洁写法,但它不仅仅是语法糖,和普通函数有几个本质区别。 - 基本语法 ```js // 完整写法 const add = (a, b) => { return a + b; }; // 单个表达式可以省略 {} 和 return(隐式返回) const add2 = (a, b) => a + b; // 只有一个参数可以省略括号 const double = x => x * 2; // 没有参数必须写空括号 const getRandom = () => Math.random(); // ⚠️ 返回对象字面量必须加括号,否则 {} 会被当成函数体 const makeUser = (name) => ({ name, active: true }); ``` - 箭头函数与普通函数的区别 ```js // 1. 没有自己的 this,继承外层作用域的 this const team = { name: 'Frontend', members: ['An', 'Bob'], // ❌ 普通函数:this 指向调用者,forEach 的回调中 this 不是 team showBad() { this.members.forEach(function(member) { console.log(`${member} belongs to ${this.name}`); // this.name 是 undefined }); }, // ✅ 箭头函数:继承 showGood 的 this,即 team showGood() { this.members.forEach(member => { console.log(`${member} belongs to ${this.name}`); // "Frontend" }); } }; // 2. 没有 arguments 对象 function normalFn() { console.log(arguments); // ✅ [1, 2, 3] } const arrowFn = () => { // console.log(arguments); // ❌ ReferenceError }; normalFn(1, 2, 3); // 3. 不能用作构造函数 const Person = (name) => { this.name = name; }; // new Person('An'); // ❌ TypeError: Person is not a constructor // 4. 没有 prototype 属性 ``` - 回调函数、数组方法的参数优先用箭头函数 - 对象方法用普通函数(需要自己的 this) - 永远不要用箭头函数定义对象方法或构造函数。 - 参数处理 - 默认参数(Default Parameters): - ```js function createUser(name, role = 'viewer', active = true) { return { name, role, active }; } createUser('An'); // { name: 'An', role: 'viewer', active: true } createUser('An', 'admin'); // { name: 'An', role: 'admin', active: true } createUser('An', undefined, false); // { name: 'An', role: 'viewer', active: false } // 注意:传 undefined 会触发默认值,传 null 不会 createUser('An', null); // { name: 'An', role: null, active: true } // 默认值可以是表达式,甚至引用前面的参数 function createId(prefix, timestamp = Date.now(), id = `${prefix}-${timestamp}`) { return id; } ``` - 剩余参数(Rest Parameters): - 用 ... 收集剩余的参数到一个真正的数组中(不同于 arguments 类数组对象): - ```js function sum(first, ...rest) { console.log(first); // 1 console.log(rest); // [2, 3, 4, 5](真正的数组) return rest.reduce((acc, val) => acc + val, first); } sum(1, 2, 3, 4, 5); // 15 // 剩余参数必须是最后一个参数 // function bad(a, ...b, c) {} // ❌ SyntaxError // 搭配解构使用 function processConfig({ host, port, ...otherOptions }) { console.log(host); // "localhost" console.log(port); // 3000 console.log(otherOptions); // { debug: true, timeout: 5000 } } processConfig({ host: 'localhost', port: 3000, debug: true, timeout: 5000 }); ``` - 返回值 - 函数只能返回一个值。如果需要返回多个值,用对象或数组: ```js // 不写 return 或 return 后没有值,返回 undefined function noReturn() { console.log('hello'); } console.log(noReturn()); // undefined // 返回多个值的模式 function getMinMax(arr) { return { min: Math.min(...arr), max: Math.max(...arr) }; } const { min, max } = getMinMax([3, 1, 4, 1, 5, 9]); // 用数组返回,配合解构 function divide(a, b) { return [Math.floor(a / b), a % b]; // [商, 余数] } const [quotient, remainder] = divide(17, 5); // 3, 2 ``` - return 换行陷阱 ```js function gotcha() { return // ← JS 在这里自动插入分号,函数返回 undefined { name: 'An' }; } console.log(gotcha()); // undefined! // 正确写法:左大括号跟 return 同一行 function correct() { return { name: 'An' }; } ``` - 函数是一等公民 - 在 JavaScript 中,函数和其他值(数字、字符串)没有区别,可以被赋值、传递、返回。 - 赋值给变量: ```js const greet = function(name) { return `Hello, ${name}`; }; const sayHi = greet; // 函数可以像值一样赋给另一个变量 sayHi('An'); // "Hello, An" ``` - 作为参数传递(回调函数): ```js function repeat(n, action) { for (let i = 0; i < n; i++) { action(i); } } repeat(3, console.log); // 0, 1, 2 repeat(3, i => console.log(i * i)); // 0, 1, 4 // 数组方法中大量使用回调 const nums = [1, 2, 3, 4, 5]; const evens = nums.filter(n => n % 2 === 0); // [2, 4] const doubled = nums.map(n => n * 2); // [2, 4, 6, 8, 10] const sum = nums.reduce((acc, n) => acc + n, 0); // 15 ``` - 作为返回值 ```js function multiplier(factor) { return (number) => number * factor; // 返回一个新函数 } const double = multiplier(2); const triple = multiplier(3); double(5); // 10 triple(5); // 15 // 这就是闭包的雏形——返回的函数"记住"了 factor 的值 // Day 4 会深入讲解 ``` - 存储在数据结构中: ```js const strategies = { add: (a, b) => a + b, subtract: (a, b) => a - b, multiply: (a, b) => a * b, }; function calculate(strategy, a, b) { return strategies[strategy](a, b); } calculate('add', 10, 5); // 15 calculate('multiply', 10, 5); // 50 ``` - IIFE(立即调用函数表达式) - IIFE 是一种定义后立即执行的函数模式。在 ES6 模块化之前,它是避免全局变量污染的主要手段。 - ```js // 经典写法 (function() { const secret = '只在这里可见'; console.log(secret); })(); // console.log(secret); // ❌ ReferenceError // 带参数 (function(global) { global.myLib = { version: '1.0' }; })(window); // 箭头函数版 (() => { console.log('立即执行'); })(); // 带返回值 const result = (() => { const a = 10; const b = 20; return a + b; })(); console.log(result); // 30 ``` - 现在有了 ES Modules 和块级作用域(let/const),IIFE 用得少了,但在一些老代码库和特定场景(如隔离作用域执行异步代码)中仍然常见。 - 作用域与闭包 - 作用域的三种层级 - 作用域决定了变量在哪里可以被访问。JavaScript 有三种作用域层级。 - 全局作用域:在所有函数和块之外声明的变量,任何地方都能访问。在浏览器中,全局变量会挂载到 window 对象上。 - ```js const APP_NAME = 'MyApp'; // 全局作用域 function foo() { console.log(APP_NAME); // ✅ 可以访问 } // 浏览器环境下 var x = 10; console.log(window.x); // 10(var 声明的全局变量挂到 window) // let/const 声明的不会挂到 window let y = 20; console.log(window.y); // undefined ``` - 函数作用域:在函数内部声明的变量,外部不可见。var、let、const 在函数内都有这个特性。 - ```js function createSecret() { var secret1 = 'var secret'; let secret2 = 'let secret'; const secret3 = 'const secret'; return 'done'; } createSecret(); // console.log(secret1); // ❌ ReferenceError // console.log(secret2); // ❌ ReferenceError // console.log(secret3); // ❌ ReferenceError ``` - 块级作用域:let 和 const 在 {}(if、for、while 或单独的块)中声明时,只在该块内可见。var 没有块级作用域。 - ```js { let a = 1; const b = 2; var c = 3; } // console.log(a); // ❌ ReferenceError // console.log(b); // ❌ ReferenceError console.log(c); // 3 ✅ var 穿透了块 // 经典面试题:for 循环中的 var vs let for (var i = 0; i < 3; i++) { setTimeout(() => console.log(i), 100); } // 输出:3, 3, 3(var 没有块级作用域,循环结束后 i 是 3) for (let j = 0; j < 3; j++) { setTimeout(() => console.log(j), 100); } // 输出:0, 1, 2(let 每次迭代创建新的块级作用域) ``` - 作用域链 - 当代码访问一个变量时,JS 引擎会沿着作用域链由内向外逐层查找,直到找到或到达全局作用域。 - ```js const global = 'global'; function outer() { const outerVar = 'outer'; function middle() { const middleVar = 'middle'; function inner() { const innerVar = 'inner'; // inner 可以访问所有外层变量 console.log(innerVar); // "inner" ← 自身作用域 console.log(middleVar); // "middle" ← 父级作用域 console.log(outerVar); // "outer" ← 祖父级作用域 console.log(global); // "global" ← 全局作用域 } inner(); // console.log(innerVar); // ❌ 外层不能访问内层 } middle(); } outer(); ``` - 词法作用域 - JavaScript 使用词法作用域(也叫静态作用域),意思是函数的作用域在定义时就确定了,而不是在调用时。 - ```js const x = 'global'; function printX() { console.log(x); // 这里的 x 在定义时就绑定了全局的 x } function wrapper() { const x = 'wrapper'; printX(); // 虽然在 wrapper 内部调用,但打印的是 "global" } wrapper(); // "global",不是 "wrapper" ``` - 这个特性是闭包的基础。函数"记住"的是它被创建时的环境,而不是被调用时的环境。 - ```js function createPrinter() { const message = '来自 createPrinter'; return function() { console.log(message); // 词法作用域:绑定的是 createPrinter 内的 message }; } const printer = createPrinter(); function someOtherPlace() { const message = '来自 someOtherPlace'; printer(); // "来自 createPrinter",不受调用位置影响 } someOtherPlace(); ``` - 闭包 - 闭包是指一个函数能够访问其词法作用域中的变量,即使这个函数在其词法作用域之外执行。 - ```js function createCounter() { let count = 0; // 这个变量被"封闭"在闭包中 return function() { count++; return count; }; } const counter = createCounter(); console.log(counter()); // 1 console.log(counter()); // 2 console.log(counter()); // 3 // count 变量从外部完全无法访问 // console.log(count); // ❌ ReferenceError // 每次调用 createCounter 创建独立的闭包 const counter2 = createCounter(); console.log(counter2()); // 1(独立的 count) ``` - 闭包的本质:createCounter 执行完后,正常来说内部变量 count 应该被垃圾回收。但因为返回的函数仍然引用着 count,JS 引擎就保留了这个变量。返回的函数 + 它引用的外部变量 = 闭包。 - 闭包应用场景 - 数据私有化 / 模块模式 ```js function createWallet(initialBalance) { let balance = initialBalance; // 私有变量,外部无法直接修改 return { deposit(amount) { if (amount <= 0) throw new Error('金额必须大于 0'); balance += amount; return balance; }, withdraw(amount) { if (amount > balance) throw new Error('余额不足'); balance -= amount; return balance; }, getBalance() { return balance; } }; } const wallet = createWallet(100); wallet.deposit(50); // 150 wallet.withdraw(30); // 120 wallet.getBalance(); // 120 // wallet.balance; // undefined,无法直接访问 // balance = 999999; // 不可能,balance 被封闭在闭包中 ``` - 工厂函数 / 函数生成器 ```js function createGreeter(greeting) { return function(name) { return `${greeting}, ${name}!`; }; } const hello = createGreeter('Hello'); const nihao = createGreeter('你好'); hello('An'); // "Hello, An!" nihao('An'); // "你好, An!" // 更实用的例子:创建带预设配置的函数 function createLogger(prefix) { return function(message) { console.log(`[${prefix}] ${new Date().toISOString()}: ${message}`); }; } const dbLog = createLogger('DB'); const apiLog = createLogger('API'); dbLog('连接成功'); // [DB] 2026-04-05T...: 连接成功 apiLog('请求收到'); // [API] 2026-04-05T...: 请求收到 ``` - 柯里化(Currying):把接受多个参数的函数,变成一系列只接受一个参数的函数 ```js // 普通函数 function add(a, b, c) { return a + b + c; } add(1, 2, 3); // 6 // 柯里化版本(手动) function curriedAdd(a) { return function(b) { return function(c) { return a + b + c; // 三层闭包,每层记住一个参数 }; }; } curriedAdd(1)(2)(3); // 6 // 拆开用 const addOne = curriedAdd(1); const addOneAndTwo = addOne(2); addOneAndTwo(3); // 6 // 箭头函数简写 const curriedAdd2 = a => b => c => a + b + c; // 实际应用:事件处理器工厂 const handleClick = (action) => (id) => (event) => { console.log(`Action: ${action}, ID: ${id}, Target: ${event.target}`); }; // button.addEventListener('click', handleClick('delete')(42)); ``` - 防抖(Debounce) ```js function debounce(fn, delay) { let timerId = null; // 闭包保存定时器 ID return function(...args) { clearTimeout(timerId); // 每次调用都清除上一次的定时器 timerId = setTimeout(() => { fn.apply(this, args); // delay 毫秒后才真正执行 }, delay); }; } // 使用:搜索框输入时,停止输入 300ms 后才发请求 const search = debounce((query) => { console.log(`搜索: ${query}`); }, 300); search('h'); // 被取消 search('he'); // 被取消 search('hel'); // 被取消 search('hello'); // 300ms 后执行 → "搜索: hello" ``` - 节流(Throttle) ```js function throttle(fn, interval) { let lastTime = 0; // 闭包保存上次执行时间 return function(...args) { const now = Date.now(); if (now - lastTime >= interval) { lastTime = now; fn.apply(this, args); } }; } // 使用:滚动事件每 200ms 最多触发一次 // window.addEventListener('scroll', throttle(handleScroll, 200)); ``` - 对象与数组 - 对象字面量与属性 - 对象是 JavaScript 中最基础的数据结构,用来存储键值对。 - 创建与属性访问: ```js // 对象字面量 const user = { name: 'An', age: 25, 'favorite-color': 'blue', // 包含特殊字符的键名需要引号 1: 'one' // 数字键会被转为字符串 }; // 点语法访问 console.log(user.name); // "An" // 方括号访问(适用于动态键名或特殊字符) console.log(user['favorite-color']); // "blue" console.log(user[1]); // "one" const key = 'age'; console.log(user[key]); // 25(动态访问) ``` - 属性简写与计算属性名(ES6): ```js const name = 'An'; const age = 25; // 属性简写:变量名和属性名相同时可以简写 const user = { name, age }; // 等价于 { name: name, age: age } // 方法简写 const calculator = { // ES6 之前:add: function(a, b) { return a + b; } add(a, b) { return a + b; }, subtract(a, b) { return a - b; } }; // 计算属性名:用表达式作为键名 const field = 'email'; const obj = { [field]: 'an@example.com', // { email: 'an@example.com' } [`${field}Verified`]: true, // { emailVerified: true } ['get' + 'Name']() { return 'An'; } // { getName() { return 'An'; } } }; ``` - 属性的增删改查: ```js const user = { name: 'An' }; // 增 user.age = 25; user['city'] = 'Tokyo'; // 改 user.name = 'Bob'; // 删 delete user.city; console.log(user.city); // undefined // 查(判断属性是否存在) console.log('name' in user); // true(包括原型链) console.log(user.hasOwnProperty('name')); // true(只查自身) console.log(user.toString !== undefined); // true(原型链上的) console.log(user.hasOwnProperty('toString')); // false ``` - 对象的遍历与常用静态方法 - ```js const user = { name: 'An', age: 25, city: 'Tokyo' }; // Object.keys() — 返回键名数组 Object.keys(user); // ['name', 'age', 'city'] // Object.values() — 返回值数组 Object.values(user); // ['An', 25, 'Tokyo'] // Object.entries() — 返回 [key, value] 二维数组 Object.entries(user); // [['name','An'], ['age',25], ['city','Tokyo']] // 用 for...of 配合 entries 遍历 for (const [key, value] of Object.entries(user)) { console.log(`${key}: ${value}`); } // Object.fromEntries() — entries 的反操作 const entries = [['a', 1], ['b', 2]]; const obj = Object.fromEntries(entries); // { a: 1, b: 2 } // 实用场景:过滤对象属性 const filtered = Object.fromEntries( Object.entries(user).filter(([key]) => key !== 'age') ); // { name: 'An', city: 'Tokyo' } ``` - Object.assign() 与冻结: ```js // Object.assign() — 合并对象(浅拷贝) const defaults = { theme: 'light', lang: 'zh', debug: false }; const userPrefs = { theme: 'dark', debug: true }; const config = Object.assign({}, defaults, userPrefs); // { theme: 'dark', lang: 'zh', debug: true } // 后面的属性覆盖前面的 // Object.freeze() — 冻结对象,不能增删改 const frozen = Object.freeze({ name: 'An', age: 25 }); frozen.name = 'Bob'; // 静默失败(严格模式下报错) frozen.city = 'NYC'; // 静默失败 console.log(frozen); // { name: 'An', age: 25 } // ⚠️ freeze 是浅冻结 const obj = Object.freeze({ inner: { value: 1 } }); obj.inner.value = 999; // ✅ 生效了!内层对象没有被冻结 // Object.isFrozen() console.log(Object.isFrozen(frozen)); // true ``` - 解构赋值 — 对象 - 解构让你用简洁的语法从对象中提取值。 ```js const user = { name: 'An', age: 25, address: { city: 'Tokyo', zip: '100-0001' }, hobbies: ['coding', 'reading'] }; // 基本解构 const { name, age } = user; console.log(name); // "An" console.log(age); // 25 // 重命名 const { name: userName, age: userAge } = user; console.log(userName); // "An" // 默认值 const { name: n, role = 'viewer' } = user; console.log(role); // "viewer"(user 中没有 role) // 嵌套解构 const { address: { city, zip } } = user; console.log(city); // "Tokyo" // 剩余属性 const { name: nm, ...rest } = user; console.log(rest); // { age: 25, address: {...}, hobbies: [...] } ``` - 函数参数中的解构(非常常用): ```js // ❌ 不解构:参数含义不清晰 function createUser(name, age, role, active) { // name 是第几个参数?容易搞混 } // ✅ 解构:清晰且支持默认值、可选参数 function createUser({ name, age, role = 'viewer', active = true } = {}) { return { name, age, role, active }; } // 调用时不需要关心参数顺序 createUser({ age: 25, name: 'An' }); createUser({}); // 全部使用默认值 createUser(); // 因为有 = {},不传也不报错 ``` - 数组基础与常用方法 - 创建数组: ```js const arr1 = [1, 2, 3]; const arr2 = new Array(5); // [empty × 5](5个空位,不推荐) const arr3 = Array.from({ length: 5 }, (_, i) => i); // [0, 1, 2, 3, 4] const arr4 = Array.of(1, 2, 3); // [1, 2, 3] ``` - 增删元素(会修改原数组): ```js const arr = [1, 2, 3]; // 尾部操作 arr.push(4); // [1, 2, 3, 4],返回新长度 4 arr.pop(); // [1, 2, 3],返回被删除的元素 4 // 头部操作 arr.unshift(0); // [0, 1, 2, 3],返回新长度 4 arr.shift(); // [1, 2, 3],返回被删除的元素 0 // splice — 万能的增删改 const items = ['a', 'b', 'c', 'd', 'e']; // splice(起始索引, 删除个数, ...插入的元素) items.splice(2, 1); // 删除:items = ['a','b','d','e'],返回 ['c'] items.splice(1, 0, 'x'); // 插入:items = ['a','x','b','d','e'] items.splice(1, 2, 'y','z');// 替换:items = ['a','y','z','d','e'] ``` - 不修改原数组的方法: ```js const arr = [1, 2, 3, 4, 5]; // slice(start, end) — 切片,左闭右开 arr.slice(1, 3); // [2, 3] arr.slice(-2); // [4, 5](最后两个) arr.slice(); // [1, 2, 3, 4, 5](浅拷贝) // concat — 拼接 arr.concat([6, 7]); // [1, 2, 3, 4, 5, 6, 7] // indexOf / lastIndexOf — 查找索引 [1, 2, 3, 2].indexOf(2); // 1(第一个) [1, 2, 3, 2].lastIndexOf(2); // 3(最后一个) [1, 2, 3].indexOf(99); // -1(未找到) // includes — 是否包含 [1, 2, 3].includes(2); // true [1, 2, 3].includes(99); // false // ⚠️ indexOf 和 includes 对引用类型无效 const obj = { id: 1 }; [{ id: 1 }].includes(obj); // false(不同的引用) // join — 数组转字符串 ['a', 'b', 'c'].join('-'); // "a-b-c" ['a', 'b', 'c'].join(''); // "abc" // flat — 扁平化 [1, [2, [3, [4]]]].flat(); // [1, 2, [3, [4]]](默认一层) [1, [2, [3, [4]]]].flat(2); // [1, 2, 3, [4]] [1, [2, [3, [4]]]].flat(Infinity); // [1, 2, 3, 4](完全扁平) // at — 支持负索引(ES2022) const letters = ['a', 'b', 'c', 'd']; letters.at(0); // "a" letters.at(-1); // "d"(最后一个) letters.at(-2); // "c" ``` - 解构赋值-数组 - ```js const rgb = [255, 128, 0]; // 基本解构 const [r, g, b] = rgb; console.log(r, g, b); // 255 128 0 // 跳过元素 const [first, , third] = [1, 2, 3]; console.log(first, third); // 1 3 // 默认值 const [a, b, c, d = 0] = [1, 2, 3]; console.log(d); // 0 // 剩余元素 const [head, ...tail] = [1, 2, 3, 4, 5]; console.log(head); // 1 console.log(tail); // [2, 3, 4, 5] // 交换变量(不需要临时变量) let x = 1, y = 2; [x, y] = [y, x]; console.log(x, y); // 2 1 // 从函数返回值解构 function getCoords() { return [35.6762, 139.6503]; } const [lat, lng] = getCoords(); // 嵌套解构 const matrix = [[1, 2], [3, 4]]; const [[a1, a2], [b1, b2]] = matrix; ``` - 扩展运算符(Spread Operator) - ... 在不同上下文中扮演不同角色。用在函数参数中是"收集"(rest),用在数组/对象字面量中是"展开"(spread) - 数组展开 ```js const arr1 = [1, 2, 3]; const arr2 = [4, 5, 6]; // 合并数组 const merged = [...arr1, ...arr2]; // [1, 2, 3, 4, 5, 6] // 浅拷贝 const copy = [...arr1]; // [1, 2, 3](新数组,不同引用) // 在特定位置插入 const withInsert = [...arr1.slice(0, 1), 99, ...arr1.slice(1)]; // [1, 99, 2, 3] // 展开字符串 const chars = [..."hello"]; // ['h', 'e', 'l', 'l', 'o'] // 配合 Math 使用 const nums = [3, 1, 4, 1, 5, 9]; Math.max(...nums); // 9 ``` - 对象展开 ```js const base = { a: 1, b: 2 }; const extra = { b: 3, c: 4 }; // 合并对象(后面覆盖前面) const merged = { ...base, ...extra }; // { a: 1, b: 3, c: 4 } // 浅拷贝 + 修改部分属性(React 中常用) const user = { name: 'An', age: 25, city: 'Tokyo' }; const updated = { ...user, age: 26 }; // { name: 'An', age: 26, city: 'Tokyo' } // 条件展开 const isAdmin = true; const config = { theme: 'dark', ...(isAdmin && { adminPanel: true, debugMode: true }) }; // isAdmin 为 true 时:{ theme: 'dark', adminPanel: true, debugMode: true } // isAdmin 为 false 时:{ theme: 'dark' }(false 被展开为空) ``` - 浅拷贝与深拷贝 - JavaScript 中引用类型赋值只是复制地址,理解拷贝层级非常重要。 - 浅拷贝(Shallow Copy):只拷贝第一层,嵌套的对象/数组仍然是共享引用。 ```js const original = { name: 'An', scores: [90, 85, 95], address: { city: 'Tokyo' } }; // 三种浅拷贝方式 const copy1 = { ...original }; const copy2 = Object.assign({}, original); const copy3 = structuredClone ? null : null; // 这个是深拷贝,后面说 // 浅拷贝的问题:嵌套对象是共享的 copy1.name = 'Bob'; // ✅ 不影响 original copy1.scores.push(100); // ❌ original.scores 也变了! copy1.address.city = 'Osaka'; // ❌ original.address.city 也变了! console.log(original.scores); // [90, 85, 95, 100] console.log(original.address.city); // "Osaka" ``` - 深拷贝(Deep Copy):递归拷贝所有层级,完全独立。 ```js // 方法1:structuredClone(现代浏览器和 Node 17+ 原生支持,推荐) const deep1 = structuredClone(original); deep1.scores.push(100); deep1.address.city = 'Osaka'; console.log(original.scores); // [90, 85, 95] 不受影响 ✅ console.log(original.address.city); // "Tokyo" 不受影响 ✅ // 方法2:JSON 序列化(简单但有限制) const deep2 = JSON.parse(JSON.stringify(original)); // ⚠️ 限制: // - undefined、function、Symbol 会丢失 // - Date 会变成字符串 // - RegExp 会变成空对象 // - 不支持循环引用 // 示例:JSON 方法的数据丢失 const problematic = { fn: () => 'hello', // 函数 date: new Date(), // Date undef: undefined, // undefined regex: /abc/g // 正则 }; const jsonCopy = JSON.parse(JSON.stringify(problematic)); console.log(jsonCopy); // { date: "2026-04-05T...", regex: {} } // fn 和 undef 直接丢了,date 变成了字符串,regex 变成了空对象 ``` - 数组高阶方法 - 什么是高阶方法 - 高阶方法就是接受函数作为参数的方法 - 每个方法的回调函数都接收三个参数:(当前元素, 索引, 原数组),大多数情况只用第一个。 - map - 映射转换 - 对数组每个元素执行回调,将返回值收集成一个等长的新数组。不修改原数组。 - ```js const nums = [1, 2, 3, 4, 5]; const doubled = nums.map(n => n * 2); // [2, 4, 6, 8, 10] const strings = nums.map(n => `第${n}项`); // ['第1项', '第2项', '第3项', '第4项', '第5项'] // 实际场景:从 API 数据中提取需要的字段 const users = [ { id: 1, name: 'An', email: 'an@test.com', age: 25 }, { id: 2, name: 'Bob', email: 'bob@test.com', age: 30 }, { id: 3, name: 'Cat', email: 'cat@test.com', age: 22 } ]; const names = users.map(u => u.name); // ['An', 'Bob', 'Cat'] const cards = users.map(({ id, name, age }) => ({ id, label: `${name} (${age}岁)` })); // [{ id: 1, label: 'An (25岁)' }, ...] // 使用索引参数 const indexed = ['a', 'b', 'c'].map((item, i) => `${i}: ${item}`); // ['0: a', '1: b', '2: c'] ``` - 常见错误 ```js // ❌ map 中不 return(箭头函数加了 {} 就必须写 return) nums.map(n => { n * 2 }); // [undefined, undefined, ...] // ❌ 用 map 做副作用操作(不关心返回值时应该用 forEach) users.map(u => console.log(u.name)); // 能跑,但语义错误 // ❌ parseInt 的经典陷阱 ['1', '2', '3'].map(parseInt); // [1, NaN, NaN] // 原因:map 传了 (元素, 索引, 数组) 三个参数给 parseInt // parseInt('1', 0) → 1 // parseInt('2', 1) → NaN(1进制无效) // parseInt('3', 2) → NaN(2进制没有3) // ✅ 正确写法 ['1', '2', '3'].map(s => parseInt(s, 10)); // [1, 2, 3] ['1', '2', '3'].map(Number); // [1, 2, 3] ``` - filter-过滤筛选 - 对数组每个元素执行回调,保留返回值为 truthy 的元素,返回一个新数组(长度 ≤ 原数组)。 - ```js const nums = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]; const evens = nums.filter(n => n % 2 === 0); // [2, 4, 6, 8, 10] const bigNums = nums.filter(n => n > 5); // [6, 7, 8, 9, 10] // 过滤对象数组 const users = [ { name: 'An', age: 25, active: true }, { name: 'Bob', age: 17, active: true }, { name: 'Cat', age: 30, active: false }, { name: 'Dan', age: 22, active: true } ]; const activeAdults = users.filter(u => u.active && u.age >= 18); // [{ name: 'An', ... }, { name: 'Dan', ... }] // 移除 falsy 值 const messy = [0, 'hello', '', null, 42, undefined, false, 'world']; const clean = messy.filter(Boolean); // ['hello', 42, 'world'] // 去重(Day 5 用的 Set 更简洁,这里展示 filter 做法) const arr = [1, 2, 2, 3, 3, 3]; const unique = arr.filter((item, index) => arr.indexOf(item) === index); // [1, 2, 3] ``` - reduce — 归约累积 - reduce 是最强大也最灵活的数组方法,它将数组"归约"为一个值(可以是任何类型)。 - 语法:array.reduce((accumulator, currentValue, index, array) => {...}, initialValue) - ```js // 最基本:求和 const sum = [1, 2, 3, 4, 5].reduce((acc, cur) => acc + cur, 0); // 执行过程: // acc=0, cur=1 → 1 // acc=1, cur=2 → 3 // acc=3, cur=3 → 6 // acc=6, cur=4 → 10 // acc=10, cur=5 → 15 // 结果:15 // 求最大值 const max = [3, 1, 4, 1, 5, 9].reduce((a, b) => a > b ? a : b); // 没有初始值时,第一个元素作为初始值 // 结果:9 ``` - find 与 findIndex - 查找 - find 返回第一个满足条件的元素本身,找不到返回 undefined。 - findIndex 返回第一个满足条件的元素索引,找不到返回 -1。 - ```js const users = [ { id: 1, name: 'An', role: 'dev' }, { id: 2, name: 'Bob', role: 'admin' }, { id: 3, name: 'Cat', role: 'dev' } ]; const admin = users.find(u => u.role === 'admin'); // { id: 2, name: 'Bob', role: 'admin' } const notFound = users.find(u => u.role === 'ceo'); // undefined const adminIndex = users.findIndex(u => u.role === 'admin'); // 1 // findLast / findLastIndex(ES2023)从后往前找 const lastDev = users.findLast(u => u.role === 'dev'); // { id: 3, name: 'Cat', role: 'dev' } ``` - some 与 every — 条件判断 - some 只要有一个元素满足条件就返回 true(类似逻辑或)。 - every 要求所有元素都满足条件才返回 true(类似逻辑与)。 - ```js const nums = [1, 2, 3, 4, 5]; nums.some(n => n > 4); // true(5 > 4) nums.some(n => n > 10); // false nums.every(n => n > 0); // true(全部大于0) nums.every(n => n > 3); // false(1, 2, 3 不满足) // 空数组的行为 [].some(() => true); // false(没有元素满足) [].every(() => false); // true(没有元素不满足 —— 空真) // 实际场景 const cart = [ { name: '键盘', inStock: true }, { name: '鼠标', inStock: true }, { name: '显示器', inStock: false } ]; const allInStock = cart.every(item => item.inStock); // false const anyOutOfStock = cart.some(item => !item.inStock); // true // 表单验证 const fields = [ { name: 'email', valid: true }, { name: 'password', valid: true }, { name: 'phone', valid: false } ]; const canSubmit = fields.every(f => f.valid); // false ``` - sort 排序 - sort 会修改原数组,并且默认按字符串的 Unicode 码点排序,这对数字来说通常不是你想要的。 - ```js // ❌ 默认排序的坑 [10, 9, 2, 21, 3].sort(); // [10, 2, 21, 3, 9] // 因为 "10" < "2"(按字符串比较,'1' 的码点 < '2' 的码点) // ✅ 数字排序:提供比较函数 // 返回负数 → a 排前面 // 返回正数 → b 排前面 // 返回 0 → 保持原序 [10, 9, 2, 21, 3].sort((a, b) => a - b); // [2, 3, 9, 10, 21](升序) [10, 9, 2, 21, 3].sort((a, b) => b - a); // [21, 10, 9, 3, 2](降序) // 对象数组排序 const users = [ { name: 'Cat', age: 30 }, { name: 'An', age: 25 }, { name: 'Bob', age: 22 } ]; // 按年龄升序 users.sort((a, b) => a.age - b.age); // 按名字字母序 users.sort((a, b) => a.name.localeCompare(b.name)); // localeCompare 支持中文拼音排序: // ['张三','李四','王五'].sort((a,b) => a.localeCompare(b, 'zh-CN')) ``` - 不想修改原数组? 用 toSorted()(ES2023)或先拷贝: ```js const original = [3, 1, 2]; // ES2023 const sorted = original.toSorted((a, b) => a - b); // [1, 2, 3] console.log(original); // [3, 1, 2](不变) // 兼容写法 const sorted2 = [...original].sort((a, b) => a - b); ``` - flatMap — 映射 + 扁平化 - flatMap 等价于先 map 再 flat(1),但只扁平一层。它特别适合一对多的映射场景。 - ```js // 普通 map 产生嵌套数组 const sentences = ['hello world', 'goodbye moon']; sentences.map(s => s.split(' ')); // [['hello', 'world'], ['goodbye', 'moon']] // flatMap 自动扁平一层 sentences.flatMap(s => s.split(' ')); // ['hello', 'world', 'goodbye', 'moon'] // 一对多:每个用户有多个标签 const users = [ { name: 'An', tags: ['dev', 'js'] }, { name: 'Bob', tags: ['design', 'css'] } ]; const allTags = users.flatMap(u => u.tags); // ['dev', 'js', 'design', 'css'] // 条件性映射:过滤 + 转换一步完成 const nums = [1, 2, 3, 4, 5, 6]; const doubledEvens = nums.flatMap(n => n % 2 === 0 ? [n * 2] : []); // [4, 8, 12] // 偶数返回 [n*2](包含一个元素的数组),奇数返回 [](空数组被扁平掉了) ``` - forEach — 遍历执行 - forEach 对每个元素执行回调,没有返回值(返回 undefined)。它是纯粹用于副作用的方法。 - ```js const users = ['An', 'Bob', 'Cat']; users.forEach((name, index) => { console.log(`${index + 1}. ${name}`); }); // 1. An // 2. Bob // 3. Cat ``` 面向对象与核心机制 - 面向对象与核心机制 - this 指向 - 为什么 this 这么重要 - 和大多数语言不同,JS 中 this 的值不取决于函数在哪里定义,而取决于函数怎么被调用(箭头函数除外) - 规则一:默认绑定 - 当函数作为独立函数调用(不通过对象、不用 call/apply/bind、不用 new),this 指向全局对象(浏览器中是 window,Node 中是 global)。严格模式下 this 是 undefined。 - ```js function showThis() { console.log(this); } showThis(); // 浏览器:window,Node:global // 严格模式 function strictShow() { 'use strict'; console.log(this); } strictShow(); // undefined ``` - 规则二:隐式绑定 - 当函数作为对象的方法调用时(obj.method()),this 指向调用它的那个对象。 - ```js const user = { name: 'An', greet() { console.log(this.name); } }; user.greet(); // "An",this === user // 链式对象:this 指向最近的调用者 const company = { name: 'MiniMax', department: { name: 'Agent Team', getName() { return this.name; } } }; company.department.getName(); // "Agent Team",不是 "MiniMax" // this 指向 department,因为 department 是最近的调用者 ``` - 规则三:显式绑定 — call / apply / bind - 当你想手动指定 this 时,使用这三个方法 - call — 立即调用,逐个传参 ```js function introduce(greeting, punctuation) { console.log(`${greeting}, I'm ${this.name}${punctuation}`); } const user = { name: 'An' }; introduce.call(user, 'Hello', '!'); // "Hello, I'm An!" // 第一个参数是 this 的值,后面依次是函数参数 ``` - apply — 立即调用,参数以数组形式传入 ```js introduce.apply(user, ['Hi', '~']); // "Hi, I'm An~" // call 和 apply 的唯一区别就是参数传递方式 // 助记:Apply 用 Array // 经典用法:借用方法 const arrayLike = { 0: 'a', 1: 'b', 2: 'c', length: 3 }; const realArray = Array.prototype.slice.call(arrayLike); // ['a', 'b', 'c'] // 求数组最大值(ES5 时代) Math.max.apply(null, [3, 1, 4, 1, 5]); // 5 // 现在用展开运算符更好:Math.max(...[3, 1, 4, 1, 5]) ``` - bind — 返回新函数,永久绑定 this(不立即调用): ```js const user = { name: 'An' }; function greet(greeting) { console.log(`${greeting}, ${this.name}`); } const greetAn = greet.bind(user); greetAn('Hello'); // "Hello, An" greetAn('Hey'); // "Hey, An" // bind 也可以预设参数(偏函数/Partial Application) const helloAn = greet.bind(user, 'Hello'); helloAn(); // "Hello, An"(greeting 已经被绑定为 'Hello') // 解决回调中 this 丢失的问题 setTimeout(user.greet.bind(user), 100); // ✅ 不会丢失 this // bind 返回的函数,this 不可被再次修改 const bound = greet.bind(user); bound.call({ name: 'Bob' }, 'Hi'); // "Hi, An"(仍然是 An,bind 的优先级更高) ``` - 规则四:new 绑定 - 当用 new 调用函数时,JS 会自动创建一个新对象,将 this 绑定到这个新对象上。 - ```js function User(name, age) { // new 调用时,this 自动指向新创建的空对象 {} this.name = name; this.age = age; // 不需要 return,自动返回 this } const user = new User('An', 25); console.log(user); // User { name: 'An', age: 25 } // new 到底做了什么?等价于: function myNew(Constructor, ...args) { // 1. 创建空对象,原型指向构造函数的 prototype const obj = Object.create(Constructor.prototype); // 2. 执行构造函数,this 绑定到新对象 const result = Constructor.apply(obj, args); // 3. 如果构造函数返回了对象,就用那个对象;否则返回新创建的对象 return result instanceof Object ? result : obj; } const user2 = myNew(User, 'Bob', 30); console.log(user2); // User { name: 'Bob', age: 30 } ``` - 箭头函数中的 this - 箭头函数没有自己的 this,它的 this 继承自定义时所在的外层作用域(词法 this),且永远不可改变。 - ```js const user = { name: 'An', // 普通方法 greetNormal() { console.log(this.name); // this 取决于调用方式 }, // 箭头函数作为方法 ❌ greetArrow: () => { console.log(this.name); // this 是外层作用域的 this,不是 user } }; user.greetNormal(); // "An" ✅ user.greetArrow(); // undefined ❌(箭头函数的 this 是全局/模块作用域) ``` - 绑定优先级 - new 绑定 > 显式绑定(call/apply/bind) > 隐式绑定(obj.fn()) > 默认绑定(fn()) - 原型与原型链 - 为什么需要原型 - 在没有原型机制的情况下,每次用构造函数创建实例,方法都会被重复创建 ```js function User(name) { this.name = name; // 每个实例都创建一个新的 greet 函数,浪费内存 this.greet = function() { return `Hi, I'm ${this.name}`; }; } const u1 = new User('An'); const u2 = new User('Bob'); console.log(u1.greet === u2.greet); // false —— 两个不同的函数对象 // 如果创建 10000 个用户,就有 10000 份完全相同的 greet 函数 // 原型就是用来解决这个问题的 ``` - prototype 与 __proto__ - prototype:只有函数才拥有的属性,它是一个对象,当用 new 调用该函数时,新创建的实例的原型会指向这个对象。 - __proto__(或者说 [[Prototype]]):每个对象都有的内部属性,指向它的原型对象。__proto__ 是非标准但被广泛支持的访问方式,标准方式是 Object.getPrototypeOf()。 ```js function User(name) { this.name = name; } // 在 prototype 上定义方法,所有实例共享 User.prototype.greet = function() { return `Hi, I'm ${this.name}`; }; const u1 = new User('An'); const u2 = new User('Bob'); // 实例的 __proto__ 指向构造函数的 prototype console.log(u1.__proto__ === User.prototype); // true console.log(Object.getPrototypeOf(u1) === User.prototype); // true(标准写法) // 所有实例共享 prototype 上的方法 console.log(u1.greet === u2.greet); // true ✅ 同一个函数 u1.greet(); // "Hi, I'm An" u2.greet(); // "Hi, I'm Bob" ``` - constructor 属性 - 每个函数的 prototype 对象默认有一个 constructor 属性,指回函数本身 - ```js function User(name) { this.name = name; } console.log(User.prototype.constructor === User); // true const u = new User('An'); console.log(u.constructor === User); // true(通过原型链找到的) // 可以用 constructor 判断实例是由哪个构造函数创建的 // 但不如 instanceof 可靠,因为 constructor 可以被覆盖 ``` - 原型链(Prototype Chain) - 当你访问一个对象的属性时,JS 引擎会沿着原型链逐层查找,直到找到该属性或到达链的顶端 null - ```js function Animal(name) { this.name = name; } Animal.prototype.eat = function() { return `${this.name} is eating`; }; function Dog(name, breed) { Animal.call(this, name); // 调用父构造函数 this.breed = breed; } // 建立原型链:Dog.prototype 的原型指向 Animal.prototype Dog.prototype = Object.create(Animal.prototype); Dog.prototype.constructor = Dog; // 修复 constructor Dog.prototype.bark = function() { return `${this.name} says woof!`; }; const dog = new Dog('Buddy', 'Golden'); // 属性查找过程 dog.name; // "Buddy" ← 在 dog 自身找到 dog.bark(); // "Buddy says woof!" ← 在 Dog.prototype 上找到 dog.eat(); // "Buddy is eating" ← 在 Animal.prototype 上找到 dog.toString(); // "[object Object]" ← 在 Object.prototype 上找到 dog.whatever; // undefined ← 一路找到 null,没找到 ``` - Object.create() - Object.create(proto) 创建一个新对象,将它的 __proto__ 设为指定的对象。这是建立原型链最干净的方式。 - Class 语法与继承 - Class 的本质 - ES6 的 class 是原型继承的语法糖。 - 它不引入新的对象模型,底层仍然是原型机制,但写法更清晰、更接近传统面向对象语言。 - ```js // ES5 写法 function User(name, age) { this.name = name; this.age = age; } User.prototype.greet = function() { return `Hi, I'm ${this.name}`; }; // ES6 class 写法(完全等价) class User { constructor(name, age) { this.name = name; this.age = age; } greet() { return `Hi, I'm ${this.name}`; } } // 验证底层仍然是原型 console.log(typeof User); // "function" console.log(User.prototype.greet); // function console.log(new User('An', 25).__proto__ === User.prototype); // true ``` - class 和构造函数的区别: - ```js // 1. class 必须用 new 调用 class Foo {} // Foo(); // ❌ TypeError: Class constructor Foo cannot be invoked without 'new' // 构造函数不强制 function Bar() {} Bar(); // ✅ 不报错(但通常是 bug) // 2. class 内部默认严格模式 class StrictExample { method() { // 这里自动处于严格模式 // 未声明的变量赋值会报错等 } } // 3. class 声明不会提升(存在 TDZ) // const u = new User(); // ❌ ReferenceError // class User {} // 4. class 中的方法不可枚举 class MyClass { method() {} } console.log(Object.keys(MyClass.prototype)); // [](空,method 不可枚举) // 对比 ES5 function MyFunc() {} MyFunc.prototype.method = function() {}; console.log(Object.keys(MyFunc.prototype)); // ['method'](可枚举) ``` - constructor 构造函数 - constructor 是 class 中的特殊方法,在 new 时自动调用。每个 class 只能有一个。 - ```js class User { constructor(name, age) { // this 指向新创建的实例 this.name = name; this.age = age; this.createdAt = new Date(); } } const u = new User('An', 25); console.log(u.name); // "An" console.log(u.createdAt); // 当前时间 // 如果不写 constructor,会有一个默认的空 constructor class Empty {} // 等价于 class Empty { constructor() {} } // constructor 中可以做参数验证 class PositiveNumber { constructor(value) { if (typeof value !== 'number' || value <= 0) { throw new Error('必须是正数'); } this.value = value; } } new PositiveNumber(5); // ✅ // new PositiveNumber(-1); // ❌ Error: 必须是正数 ``` - 实例方法、访问器与类字段 - ```js class User { // --- 类字段(Class Fields,ES2022)--- // 直接在 class body 中声明,会成为实例自身的属性 role = 'viewer'; loginCount = 0; constructor(name, email) { this.name = name; this.email = email; } // --- 实例方法 --- // 定义在 User.prototype 上,所有实例共享 greet() { return `Hi, I'm ${this.name}`; } login() { this.loginCount++; return `${this.name} logged in (${this.loginCount} times)`; } // --- getter / setter --- // 像属性一样访问,但实际上是方法调用 get displayName() { return `${this.name} <${this.email}>`; } get isFrequent() { return this.loginCount > 10; } set nickname(value) { if (value.length < 2) { throw new Error('昵称至少2个字符'); } this._nickname = value; } get nickname() { return this._nickname || this.name; } } const u = new User('An', 'an@test.com'); // 类字段 console.log(u.role); // "viewer" console.log(u.loginCount); // 0 // 实例方法 u.login(); // "An logged in (1 times)" // getter(不加括号) console.log(u.displayName); // "An <an@test.com>" console.log(u.isFrequent); // false // setter(像赋值一样使用) u.nickname = 'AnDev'; console.log(u.nickname); // "AnDev" // u.nickname = 'A'; // ❌ Error: 昵称至少2个字符 ``` - 静态方法与静态字段 - static 定义在类本身上,不是在实例上。通过类名调用,不能通过实例调用。 - ```js class MathUtils { // 静态方法 static add(a, b) { return a + b; } static multiply(a, b) { return a * b; } // 静态字段 static PI = 3.14159; // 静态方法可以互相调用 static circleArea(radius) { return MathUtils.PI * radius ** 2; // 或者用 this(this 在静态方法中指向类本身) // return this.PI * radius ** 2; } } MathUtils.add(1, 2); // 3 MathUtils.PI; // 3.14159 MathUtils.circleArea(5); // 78.53975 // 不能通过实例调用 // const m = new MathUtils(); // m.add(1, 2); // ❌ TypeError: m.add is not a function ``` - 私有字段(Private Fields) - ES2022 引入 # 前缀表示真正的私有属性和方法,只能在类内部访问。 ```js class BankAccount { // 私有字段 #balance; #owner; #transactionHistory = []; constructor(owner, initialBalance) { this.#owner = owner; this.#balance = initialBalance; } // 私有方法 #recordTransaction(type, amount) { this.#transactionHistory.push({ type, amount, date: new Date(), balance: this.#balance }); } // 公开方法(通过它们间接访问私有数据) deposit(amount) { if (amount <= 0) throw new Error('金额必须大于0'); this.#balance += amount; this.#recordTransaction('deposit', amount); return this; } withdraw(amount) { if (amount > this.#balance) throw new Error('余额不足'); this.#balance -= amount; this.#recordTransaction('withdraw', amount); return this; } get balance() { return this.#balance; } get history() { // 返回副本,防止外部修改 return [...this.#transactionHistory]; } // 静态私有方法 static #validateAmount(amount) { return typeof amount === 'number' && amount > 0; } } const account = new BankAccount('An', 1000); account.deposit(500).withdraw(200); // 链式调用 console.log(account.balance); // 1300 console.log(account.history); // [{...}, {...}] // ❌ 私有字段外部完全不可访问 // account.#balance; // SyntaxError // account.#recordTransaction; // SyntaxError ``` - extends 与 super — 类继承 - extends 建立继承关系,super 用于调用父类。 - ```js class Animal { constructor(name) { this.name = name; this.energy = 100; } eat(amount) { this.energy += amount; return `${this.name} ate, energy: ${this.energy}`; } sleep(hours) { this.energy += hours * 10; return `${this.name} slept ${hours}h, energy: ${this.energy}`; } info() { return `${this.name} (energy: ${this.energy})`; } } class Dog extends Animal { // 子类的 constructor 必须在使用 this 之前调用 super() constructor(name, breed) { super(name); // 调用 Animal 的 constructor this.breed = breed; } // 新方法 bark() { this.energy -= 5; return `${this.name} says Woof!`; } // 重写父类方法 eat(amount) { // 调用父类的 eat 方法 super.eat(amount * 2); // 狗吃东西恢复双倍能量 return `${this.name} gobbled food! energy: ${this.energy}`; } // 扩展父类方法 info() { return `${super.info()} [${this.breed}]`; } } const dog = new Dog('Buddy', 'Golden'); dog.eat(10); // "Buddy gobbled food! energy: 120"(10*2=20,100+20=120) dog.bark(); // "Buddy says Woof!" dog.sleep(2); // "Buddy slept 2h, energy: 135"(继承自 Animal) dog.info(); // "Buddy (energy: 135) [Golden]" // 原型链验证 console.log(dog instanceof Dog); // true console.log(dog instanceof Animal); // true ``` - 字符串与正则表达式 - 模板字符串深入 - ```js // 基本插值 const name = 'An'; const greeting = `Hello, ${name}!`; // 插值中可以放任意表达式 const price = 99; const qty = 3; console.log(`总价: ¥${price * qty}`); // "总价: ¥297" console.log(`状态: ${qty > 0 ? '有货' : '缺货'}`); // "状态: 有货" // 多行字符串(保留换行和缩进) const html = ` <div class="card"> <h2>${name}</h2> <p>Price: ¥${price}</p> </div> `; // 嵌套模板 const items = ['JS', 'CSS', 'HTML']; const list = ` <ul> ${items.map(item => `<li>${item}</li>`).join('\n ')} </ul> `; ``` - 模板标签 - 标签模板允许你用一个函数来处理模板字符串,这是一个强大但不太常见的特性: - ```js // 标签函数接收两个参数: // strings — 模板中的静态文本部分(数组) // values — 插值表达式的值(rest 参数) function highlight(strings, ...values) { return strings.reduce((result, str, i) => { const value = values[i] !== undefined ? `【${values[i]}】` : ''; return result + str + value; }, ''); } const lang = 'JavaScript'; const exp = 5; console.log(highlight`我学了 ${lang} ${exp} 年`); // "我学了 【JavaScript】 【5】 年" // 实际应用1:SQL 防注入 function sql(strings, ...values) { return { text: strings.join('$'), // 用占位符替代 values // 参数化查询 }; } const userId = "1; DROP TABLE users; --"; const query = sql`SELECT * FROM users WHERE id = ${userId}`; // { text: "SELECT * FROM users WHERE id = $", values: ["1; DROP TABLE users; --"] } // 实际应用2:国际化 // 实际应用3:CSS-in-JS(styled-components 就是用标签模板实现的) ``` - 常用字符串方法 - ```js const str = ' Hello, World! Hello, JavaScript! '; // --- 查找类 --- str.indexOf('Hello'); // 2(第一次出现的位置) str.lastIndexOf('Hello'); // 17(最后一次出现的位置) str.indexOf('Python'); // -1(未找到) str.includes('World'); // true str.startsWith(' Hello'); // true str.endsWith('! '); // true str.search(/hello/i); // 2(用正则查找,返回索引) // --- 提取类 --- str.slice(2, 7); // "Hello"(起始, 结束索引,左闭右开) str.slice(-5); // "t! "(从倒数第5个到末尾) str.substring(2, 7); // "Hello"(类似 slice,但不支持负数) str.at(2); // "H"(ES2022,支持负索引) str.at(-3); // "! " → 不对,at 只取单个字符 → "!" // 更正:str.at(-3) 返回 '!' // --- 修改类(返回新字符串,原字符串不变)--- str.trim(); // "Hello, World! Hello, JavaScript!" str.trimStart(); // "Hello, World! Hello, JavaScript! " str.trimEnd(); // " Hello, World! Hello, JavaScript!" str.toUpperCase(); // " HELLO, WORLD! HELLO, JAVASCRIPT! " str.toLowerCase(); // " hello, world! hello, javascript! " 'hello'.repeat(3); // "hellohellohello" // --- 填充类 --- '42'.padStart(5, '0'); // "00042"(左填充到5位) '42'.padEnd(5, '.'); // "42..."(右填充到5位) '7'.padStart(2, '0'); // "07"(常用于格式化月份/日期) // --- 分割类 --- 'a,b,c,d'.split(','); // ['a', 'b', 'c', 'd'] 'hello'.split(''); // ['h', 'e', 'l', 'l', 'o'] 'a-b-c'.split('-', 2); // ['a', 'b'](limit 参数) // --- 替换类 --- 'hello world'.replace('world', 'JS'); // "hello JS"(只替换第一个) 'aabbcc'.replace('b', 'X'); // "aaXbcc" 'aabbcc'.replaceAll('b', 'X'); // "aaXXcc"(ES2021,替换全部) ``` - replace 高级用法 - ```js // 函数替换:每次匹配时调用函数,返回值作为替换内容 const result = 'hello-world-foo'.replace(/\w+/g, (match) => { return match.charAt(0).toUpperCase() + match.slice(1); }); // "Hello-World-Foo" // 替换函数的参数:(match, ...groups, offset, fullString) '2026-04-07'.replace( /(\d{4})-(\d{2})-(\d{2})/, (match, year, month, day) => `${day}/${month}/${year}` ); // "07/04/2026" // 实际场景:模板引擎 function template(str, data) { return str.replace(/\{\{(\w+)\}\}/g, (match, key) => { return data[key] ?? match; // 找不到就保留原文 }); } template('Hello, {{name}}! You have {{count}} messages.', { name: 'An', count: 5 }); // "Hello, An! You have 5 messages." // 驼峰转换 function camelToKebab(str) { return str.replace(/[A-Z]/g, (match) => `-${match.toLowerCase()}`); } camelToKebab('backgroundColor'); // "background-color" camelToKebab('fontSize'); // "font-size" function kebabToCamel(str) { return str.replace(/-([a-z])/g, (match, letter) => letter.toUpperCase()); } kebabToCamel('background-color'); // "backgroundColor" ``` - 正则表达式基础语法 - 正则表达式用于模式匹配,由模式和标志组成。 - 创建方式 ```js // 字面量(推荐,不需要转义反斜杠) const re1 = /hello/i; // 构造函数(适用于动态构建正则) const re2 = new RegExp('hello', 'i'); const keyword = 'world'; const re3 = new RegExp(keyword, 'gi'); // 动态模式 ``` - 常用标志 ```js /pattern/g // g — global,匹配所有,不只第一个 /pattern/i // i — case insensitive,忽略大小写 /pattern/m // m — multiline,^ 和 $ 匹配每行的开头结尾 /pattern/s // s — dotAll,让 . 也匹配换行符 /pattern/u // u — unicode,正确处理 Unicode(如 emoji、中文) /pattern/d // d — indices,返回匹配的位置信息(ES2022) // 可以组合 /pattern/giu ``` - 正则核心语法 - 字符类 ```js // 普通字符:匹配自身 /hello/ // 匹配 "hello" // 元字符 . // 任意单个字符(除换行符,除非用 s 标志) \d // 数字 [0-9] \D // 非数字 [^0-9] \w // 单词字符 [a-zA-Z0-9_] \W // 非单词字符 \s // 空白字符(空格、tab、换行等) \S // 非空白字符 \b // 单词边界 \B // 非单词边界 // 字符集合 [] [abc] // 匹配 a、b 或 c 中的任意一个 [a-z] // a 到 z [A-Z0-9] // 大写字母或数字 [^abc] // 不是 a、b、c(^ 在 [] 内表示取反) // 示例 /\d{3}-\d{4}/.test('123-4567'); // true /[aeiou]/i.test('Hello'); // true(有元音字母) /^[a-zA-Z_]\w*$/.test('myVar_1'); // true(合法的变量名) ``` - 量词 ```js * // 0 次或多次 + // 1 次或多次 ? // 0 次或 1 次 {n} // 恰好 n 次 {n,} // 至少 n 次 {n,m} // n 到 m 次 // 示例 /colou?r/ // "color" 或 "colour"(u 出现 0 或 1 次) /\d{3,4}/ // 3 位或 4 位数字 /ha+/ // "ha", "haa", "haaa"... // 贪婪 vs 非贪婪 // 默认贪婪:尽可能多地匹配 '<div>hello</div>'.match(/<.+>/); // ["<div>hello</div>"](匹配了整个) // 加 ? 变成非贪婪(懒惰):尽可能少地匹配 '<div>hello</div>'.match(/<.+?>/); // ["<div>"](匹配第一个完整标签就停) ``` - 位置锚点 ```js ^ // 字符串开头(m 标志下匹配行开头) $ // 字符串结尾(m 标志下匹配行结尾) \b // 单词边界 /^hello/.test('hello world'); // true(以 hello 开头) /world$/.test('hello world'); // true(以 world 结尾) /^hello$/.test('hello'); // true(整个字符串就是 hello) // \b 单词边界 'cat concatenate'.match(/\bcat\b/g); // ["cat"](只匹配独立的 cat) 'cat concatenate'.match(/cat/g); // ["cat", "cat"](两个都匹配) ``` - 分组与或 ```js // 分组 () /(ab)+/.test('ababab'); // true(ab 重复多次) /(foo|bar)/.test('foobar'); // true // 或 | /cat|dog/.test('I have a cat'); // true /^(http|https):\/\//.test('https://example.com'); // true // 非捕获分组 (?:) — 分组但不捕获 /(?:http|https):\/\/(\w+)/.exec('https://example.com'); // 结果中不包含 http/https 的捕获组 ``` - 捕获组与反向引用 - ```js // 编号捕获组 const dateRe = /(\d{4})-(\d{2})-(\d{2})/; const match = dateRe.exec('2026-04-07'); console.log(match[0]); // "2026-04-07"(完整匹配) console.log(match[1]); // "2026"(第1个捕获组) console.log(match[2]); // "04"(第2个捕获组) console.log(match[3]); // "07"(第3个捕获组) // 命名捕获组(ES2018,强烈推荐) const namedRe = /(?<year>\d{4})-(?<month>\d{2})-(?<day>\d{2})/; const m = namedRe.exec('2026-04-07'); console.log(m.groups.year); // "2026" console.log(m.groups.month); // "04" console.log(m.groups.day); // "07" // 解构命名捕获组 const { groups: { year, month, day } } = namedRe.exec('2026-04-07'); // 在 replace 中使用捕获组 '2026-04-07'.replace( /(?<y>\d{4})-(?<m>\d{2})-(?<d>\d{2})/, '$<d>/$<m>/$<y>' ); // "07/04/2026" // 编号引用 '2026-04-07'.replace(/(\d{4})-(\d{2})-(\d{2})/, '$3/$2/$1'); // "07/04/2026" // 反向引用:在正则内部引用前面的捕获组 // 匹配重复的单词 /\b(\w+)\s+\1\b/.test('the the'); // true(\1 引用第一个捕获组) /\b(\w+)\s+\1\b/.test('the cat'); // false ``` - Map、Set 与迭代器 - Map — 键值映射 - Map 是 ES6 引入的键值对集合,和普通对象的核心区别在于:任何类型都可以作为键。 - ```js const map = new Map(); // 设置键值对 map.set('name', 'An'); map.set(42, 'number key'); map.set(true, 'boolean key'); const objKey = { id: 1 }; const fnKey = () => {}; map.set(objKey, '对象作为键'); map.set(fnKey, '函数作为键'); // 获取 map.get('name'); // "An" map.get(42); // "number key" map.get(objKey); // "对象作为键" map.get({ id: 1 }); // undefined!不是同一个引用 // 检查与删除 map.has('name'); // true map.delete(42); // true(删除成功) map.size; // 4 // 清空 map.clear(); map.size; // 0 ``` - 用可迭代对象初始化: ```js // 用二维数组初始化 const map = new Map([ ['name', 'An'], ['age', 25], ['city', 'Tokyo'] ]); // 从对象转换 const obj = { a: 1, b: 2, c: 3 }; const mapFromObj = new Map(Object.entries(obj)); // 从 Map 转回对象 const backToObj = Object.fromEntries(mapFromObj); // { a: 1, b: 2, c: 3 } ``` - Map vs 普通对象 ```js // 1. 键的类型 // 对象的键只能是 string 或 Symbol const obj = {}; obj[1] = 'one'; obj['1'] = 'string one'; console.log(obj[1]); // "string one"(数字键被转成了字符串,覆盖了) // Map 的键保持原始类型 const map = new Map(); map.set(1, 'number one'); map.set('1', 'string one'); map.get(1); // "number one" map.get('1'); // "string one"(两个不同的键) // 2. 顺序保证 // Map 始终按插入顺序遍历 // 对象的键顺序在现代引擎中也基本有序,但规范上数字键会排在前面 // 3. 性能 // Map 在频繁增删键值对时性能更好 // Map 有原生的 size 属性(O(1)),对象需要 Object.keys(obj).length // 4. 原型污染 // 对象会继承 Object.prototype 上的属性 const plain = {}; console.log('toString' in plain); // true(原型上的) // Map 没有这个问题 const safeMap = new Map(); safeMap.has('toString'); // false ``` - Set - 唯一值集合 - ```js const set = new Set(); // 添加 set.add(1); set.add(2); set.add(3); set.add(2); // 重复,被忽略 set.add('2'); // 字符串 "2" 和数字 2 是不同的值 console.log(set.size); // 4 // 检查与删除 set.has(2); // true set.delete(2); // true set.has(2); // false // 初始化 const set2 = new Set([1, 2, 3, 2, 1]); // Set { 1, 2, 3 } // 清空 set.clear(); ``` - 可迭代协议 - JavaScript 中 for...of、展开运算符 ...、解构赋值等特性,都依赖可迭代协议。 - 一个对象如果实现了 Symbol.iterator 方法,并且该方法返回一个迭代器,这个对象就是可迭代的。 - ```js // 内置可迭代对象 // Array, String, Map, Set, TypedArray, arguments, NodeList // 验证是否可迭代 function isIterable(obj) { return obj != null && typeof obj[Symbol.iterator] === 'function'; } isIterable([1, 2, 3]); // true isIterable('hello'); // true isIterable(new Map()); // true isIterable(new Set()); // true isIterable({ a: 1 }); // false(普通对象不可迭代) isIterable(42); // false ``` - 迭代器协议: - 迭代器是一个对象,它有一个 next() 方法,每次调用返回 { value, done }。 - ```js // 手动使用迭代器 const arr = [10, 20, 30]; const iterator = arr[Symbol.iterator](); iterator.next(); // { value: 10, done: false } iterator.next(); // { value: 20, done: false } iterator.next(); // { value: 30, done: false } iterator.next(); // { value: undefined, done: true } // for...of 本质就是自动调用迭代器 // 等价于: const iter = arr[Symbol.iterator](); let result = iter.next(); while (!result.done) { console.log(result.value); result = iter.next(); } ``` - 自定义可迭代对象 - ```js // 实现一个范围对象 class Range { constructor(start, end, step = 1) { this.start = start; this.end = end; this.step = step; } [Symbol.iterator]() { let current = this.start; const end = this.end; const step = this.step; return { next() { if (current <= end) { const value = current; current += step; return { value, done: false }; } return { value: undefined, done: true }; } }; } } const range = new Range(1, 5); for (const n of range) { console.log(n); // 1, 2, 3, 4, 5 } // 因为实现了迭代协议,所有相关语法都自动支持 console.log([...range]); // [1, 2, 3, 4, 5] const [a, b, c] = range; // a=1, b=2, c=3 const nums = Array.from(range); // [1, 2, 3, 4, 5] const set = new Set(range); // Set { 1, 2, 3, 4, 5 } // 带步长 const evens = new Range(0, 10, 2); console.log([...evens]); // [0, 2, 4, 6, 8, 10] ``` - 生成器函数 - 生成器用 function* 声明,yield 暂停并产出值。它自动实现了迭代器协议。 - ```js // 基本语法 function* numberGenerator() { yield 1; yield 2; yield 3; } const gen = numberGenerator(); gen.next(); // { value: 1, done: false } gen.next(); // { value: 2, done: false } gen.next(); // { value: 3, done: false } gen.next(); // { value: undefined, done: true } // 生成器自动可迭代 for (const n of numberGenerator()) { console.log(n); // 1, 2, 3 } console.log([...numberGenerator()]); // [1, 2, 3] ``` - 错误处理 - 错误类型 - ```js // 1. SyntaxError — 语法错误,代码根本无法解析 // const x = ; // SyntaxError: Unexpected token ';' // 这种错误在代码运行前就会被发现 // 2. ReferenceError — 引用了不存在的变量 // console.log(notDefined); // ReferenceError: notDefined is not defined // 3. TypeError — 类型错误,对值进行了不支持的操作 // null.toString(); // TypeError: Cannot read properties of null // (42)(); // TypeError: 42 is not a function // const x = 1; x = 2; // TypeError: Assignment to constant variable // 4. RangeError — 值超出有效范围 // new Array(-1); // RangeError: Invalid array length // (1.5).toFixed(200); // RangeError: toFixed() digits argument must be between 0 and 100 // 5. URIError — URI 编解码错误 // decodeURIComponent('%'); // URIError: URI malformed // 6. EvalError — eval 相关(现代 JS 中很少见) // 所有错误都继承自 Error const err = new TypeError('出错了'); console.log(err instanceof TypeError); // true console.log(err instanceof Error); // true console.log(err.message); // "出错了" console.log(err.name); // "TypeError" console.log(err.stack); // 完整的调用栈信息 ``` - try/catch/finally - ```js // 基本结构 try { // 可能出错的代码 const data = JSON.parse('invalid json'); } catch (error) { // 出错时执行 console.log(error.message); // "Unexpected token i in JSON at position 0" console.log(error.name); // "SyntaxError" } finally { // 无论是否出错都执行 console.log('清理工作'); } // catch 可以省略参数(ES2019) try { JSON.parse('{}'); } catch { // 不需要 error 对象时可以省略 console.log('解析失败'); } // finally 即使有 return 也会执行 function example() { try { return '来自 try'; } finally { console.log('finally 仍然执行'); // ⚠️ 如果 finally 里也有 return,会覆盖 try 的 return // return '来自 finally'; // 不推荐这样做 } } example(); // 打印 "finally 仍然执行",返回 "来自 try" ``` - throw 与自定义 Error - ```js // 基本自定义错误 class AppError extends Error { constructor(message, code) { super(message); this.name = 'AppError'; this.code = code; } } // 特定业务错误 class ValidationError extends AppError { constructor(field, message) { super(message, 'VALIDATION_ERROR'); this.name = 'ValidationError'; this.field = field; } } class NotFoundError extends AppError { constructor(resource, id) { super(`${resource} with id ${id} not found`, 'NOT_FOUND'); this.name = 'NotFoundError'; this.resource = resource; this.id = id; } } class AuthenticationError extends AppError { constructor(message = '未认证') { super(message, 'UNAUTHORIZED'); this.name = 'AuthenticationError'; } } // 使用 function getUser(id) { if (typeof id !== 'number') { throw new ValidationError('id', 'ID 必须是数字'); } const user = database.find(u => u.id === id); if (!user) { throw new NotFoundError('User', id); } return user; } ``` - 模块化 - ES Modules — import / export - ES Modules 是 JavaScript 的官方模块系统,每个文件就是一个模块,有自己的作用域。 - 命名导出(Named Exports): - ```js // utils.js // 声明时导出 export const PI = 3.14159; export function add(a, b) { return a + b; } export class Calculator { multiply(a, b) { return a * b; } } // 也可以统一导出 const subtract = (a, b) => a - b; const divide = (a, b) => a / b; export { subtract, divide }; ``` - 命名导入 - ```js // main.js // 导入指定的导出 import { add, PI, Calculator } from './utils.js'; console.log(add(1, 2)); // 3 console.log(PI); // 3.14159 // 重命名导入 import { add as sum, subtract as sub } from './utils.js'; sum(1, 2); // 3 // 导入全部到一个命名空间 import * as Utils from './utils.js'; Utils.add(1, 2); Utils.PI; ``` - 默认导出 - 每个模块只能有一个默认导出。 - ```js // logger.js export default class Logger { log(msg) { console.log(`[LOG] ${msg}`); } error(msg) { console.error(`[ERR] ${msg}`); } } // 也可以导出函数或值 // export default function() { ... } // export default 42; ``` - ```js // 导入默认导出(不需要花括号,名字可以随意取) import Logger from './logger.js'; import MyLogger from './logger.js'; // 不同名字也行 const logger = new Logger(); logger.log('hello'); ``` - 混合使用 ```js // api.js export default class API { constructor(baseUrl) { this.baseUrl = baseUrl; } } export const VERSION = '2.0'; export function createAPI(url) { return new API(url); } // 同时导入默认和命名导出 import API, { VERSION, createAPI } from './api.js'; ``` - 高级模块用法 - 重新导出 ```js // models/user.js export class User { /* ... */ } // models/post.js export class Post { /* ... */ } // models/comment.js export class Comment { /* ... */ } // models/index.js — 统一入口 export { User } from './user.js'; export { Post } from './post.js'; export { Comment } from './comment.js'; // 重新导出全部 export * from './user.js'; // 重新导出并重命名 export { User as UserModel } from './user.js'; // 重新导出默认导出 export { default as User } from './user.js'; ``` - 动态导入 ```js // 按需加载 async function loadChart() { const { Chart } = await import('./chart.js'); const chart = new Chart('#container'); chart.render(data); } // 条件加载 async function loadLocale(lang) { const messages = await import(`./locales/${lang}.js`); return messages.default; } // 搭配 UI 事件 button.addEventListener('click', async () => { const { openModal } = await import('./modal.js'); openModal(); }); // 多个模块并行加载 const [moduleA, moduleB] = await Promise.all([ import('./a.js'), import('./b.js') ]); ``` - CommonJS 对比 - CommonJS 是 Node.js 传统的模块系统,和 ES Modules 有重要区别。 - ```js // CommonJS 语法 // 导出 module.exports = { add, subtract }; // 或 exports.add = add; // 导入 const { add, subtract } = require('./utils'); const utils = require('./utils'); // 主要区别: // 1. CJS 是同步加载,ESM 是异步的 // 2. CJS 的 require 可以在任何地方调用,ESM 的 import 必须在顶层 // 3. CJS 导出的是值的拷贝,ESM 导出的是活绑定 // 4. CJS 用 .js 后缀,ESM 在 Node 中用 .mjs 或在 package.json 中设 "type": "module" // Node.js 中使用 ESM 的两种方式: // 方式1:文件后缀用 .mjs // 方式2:package.json 中添加 "type": "module" ``` 异步编程与浏览器 API - 异步编程与浏览器 API - 异步基础与回调 - 同步 vs 异步 - JavaScript 是单线程语言——同一时刻只能执行一段代码 - 现实中很多操作是耗时的(网络请求、文件读写、定时器),如果同步等待,整个程序就会卡住 - 事件循环 - ![](https://an-img.oss-cn-hangzhou.aliyuncs.com/2026/04/07/20260407204918171.png,531,461) - 宏任务与微任务 - ```js console.log('1. 同步'); setTimeout(() => { console.log('2. 宏任务 - setTimeout'); }, 0); Promise.resolve().then(() => { console.log('3. 微任务 - Promise.then'); }); queueMicrotask(() => { console.log('4. 微任务 - queueMicrotask'); }); console.log('5. 同步'); // 输出顺序: // 1. 同步 // 5. 同步 // 3. 微任务 - Promise.then // 4. 微任务 - queueMicrotask // 2. 宏任务 - setTimeout // 解释: // 第一轮:执行所有同步代码 → 1, 5 // 清空微任务队列 → 3, 4 // 取一个宏任务 → 2 ``` - setTimeout 与 setInterval - setTimeout — 延迟执行: - ```js // 基本用法 const timerId = setTimeout(() => { console.log('3 秒后执行'); }, 3000); // 取消定时器 clearTimeout(timerId); // 传递参数给回调 setTimeout((name, age) => { console.log(`${name}, ${age}`); }, 1000, 'An', 25); // 1 秒后打印 "An, 25" // ⚠️ 延迟时间不是精确的 // setTimeout(fn, 100) 意思是"至少 100ms 后执行" // 实际可能更长(调用栈忙、页面后台时浏览器会节流等) // 最小延迟:嵌套 setTimeout 超过 5 层后,最小延迟被限制为 4ms function nested(depth = 0) { const start = performance.now(); setTimeout(() => { console.log(`depth ${depth}: ${(performance.now() - start).toFixed(1)}ms`); if (depth < 10) nested(depth + 1); }, 0); } // 前几层接近 0ms,深层后会变成 4ms+ ``` - 回调函数模式 - 回调是 JavaScript 最早的异步处理方式——把"完成后要做的事"作为函数传进去。 - ```js // 最简单的回调 function fetchData(callback) { setTimeout(() => { const data = { name: 'An', age: 25 }; callback(data); }, 1000); } fetchData((data) => { console.log('收到数据:', data); }); // Node.js 风格的回调:错误优先(Error-first Callback) function readFile(path, callback) { setTimeout(() => { if (path === '') { callback(new Error('路径不能为空'), null); } else { callback(null, `${path} 的内容`); // 第一个参数是错误,第二个是结果 } }, 500); } readFile('data.txt', (error, data) => { if (error) { console.error('读取失败:', error.message); return; } console.log('读取成功:', data); }); ``` - Promise - Promise 是什么 - Promise 是一个代表异步操作最终结果的对象。它解决了回调地狱的问题,让异步代码像同步一样线性书写。 - 一个 Promise 有三种状态,一旦从 pending 变为 fulfilled 或 rejected,就不可再改变: - ```js // pending(进行中)→ fulfilled(已成功) // pending(进行中)→ rejected(已失败) // 创建 Promise const promise = new Promise((resolve, reject) => { // 这个函数(executor)立即同步执行 console.log('executor 执行了'); // 异步操作 setTimeout(() => { const success = true; if (success) { resolve('成功的数据'); // pending → fulfilled } else { reject(new Error('失败了')); // pending → rejected } }, 1000); }); console.log('Promise 创建后'); // 输出顺序: // "executor 执行了"(同步) // "Promise 创建后"(同步) // (1 秒后)promise 被 resolve ``` - then/catch/finally - then — 处理成功和失败: - ```js const promise = new Promise((resolve) => { setTimeout(() => resolve(42), 1000); }); // then 接受两个回调:onFulfilled 和 onRejected promise.then( (value) => console.log('成功:', value), // 42 (error) => console.log('失败:', error) ); // 通常只传第一个参数,用 catch 处理错误 promise .then(value => console.log('成功:', value)) .catch(error => console.log('失败:', error)); ``` - then 的返回值——链式调用的基础: - then 始终返回一个新的 Promise。返回值决定了新 Promise 的状态: - ```js // 1. 返回普通值 → 新 Promise 以该值 resolve Promise.resolve(1) .then(v => v + 1) // 返回 2 .then(v => v * 3) // 返回 6 .then(v => console.log(v)); // 6 // 2. 返回 Promise → 新 Promise 跟随返回的 Promise Promise.resolve(1) .then(v => { return new Promise(resolve => { setTimeout(() => resolve(v + 10), 500); }); }) .then(v => console.log(v)); // 11(等待 500ms 后) // 3. 抛出异常 → 新 Promise 被 reject Promise.resolve(1) .then(v => { throw new Error('出错了'); }) .then(v => console.log('不会执行')) .catch(err => console.log(err.message)); // "出错了" // 4. 不写 return → 返回 undefined Promise.resolve(1) .then(v => { v + 1; }) // 忘了 return! .then(v => console.log(v)); // undefined ``` - catch 错误处理 - ```js // catch 是 .then(null, onRejected) 的语法糖 // 它能捕获前面所有 then 中抛出的错误 Promise.resolve(1) .then(v => { throw new Error('then 中的错误'); }) .then(v => { console.log('跳过'); // 不执行 }) .catch(err => { console.log('捕获:', err.message); // "then 中的错误" return '已恢复'; // catch 也可以返回值,让链继续 }) .then(v => { console.log(v); // "已恢复"(链恢复了) }); // ⚠️ then 的第二个参数 vs catch 的区别 Promise.reject(new Error('初始错误')) .then( v => console.log('成功'), err => { console.log('then 的 onRejected:', err.message); throw new Error('新错误'); } ); // "新错误" 无法被捕获!因为同一个 then 的 onRejected 不能捕获 onFulfilled 的错误 // ✅ 用 catch 更安全 Promise.reject(new Error('初始错误')) .then(v => console.log('成功')) .catch(err => console.log('catch 捕获:', err.message)); ``` - finally — 无论成败都执行: - ```js function fetchData() { showLoading(); return fetch('/api/data') .then(res => res.json()) .then(data => { displayData(data); return data; }) .catch(err => { showError(err.message); }) .finally(() => { hideLoading(); // 无论成功失败都要隐藏 loading }); } // finally 的特殊行为: // 1. 不接收参数(不知道是成功还是失败) // 2. 默认"透传"前一步的值 Promise.resolve(42) .finally(() => { console.log('清理'); // 执行 return 999; // 返回值被忽略(除非抛错) }) .then(v => console.log(v)); // 42(透传,不是 999) // 3. 如果 finally 中抛出错误,会覆盖之前的结果 Promise.resolve(42) .finally(() => { throw new Error('finally 出错'); }) .catch(err => console.log(err.message)); // "finally 出错" ``` - Promise 静态方法 - Promise.resolve / Promise.reject — 快速创建: - ```js // 创建一个立即 resolve 的 Promise const p1 = Promise.resolve(42); p1.then(v => console.log(v)); // 42 // 如果传入 Promise,原样返回 const p2 = Promise.resolve(Promise.resolve('hello')); p2.then(v => console.log(v)); // "hello"(不会嵌套) // 如果传入 thenable(有 then 方法的对象),会展开 const thenable = { then(resolve) { resolve('from thenable'); } }; Promise.resolve(thenable).then(v => console.log(v)); // "from thenable" // 创建一个立即 reject 的 Promise const p3 = Promise.reject(new Error('失败')); p3.catch(err => console.log(err.message)); // "失败" ``` - Promise.all — 全部成功才成功: - ```js const p1 = fetch('/api/users').then(r => r.json()); const p2 = fetch('/api/posts').then(r => r.json()); const p3 = fetch('/api/comments').then(r => r.json()); // 并行执行,全部完成后返回结果数组(顺序与传入一致) Promise.all([p1, p2, p3]) .then(([users, posts, comments]) => { console.log(users, posts, comments); }) .catch(err => { // ⚠️ 任何一个失败,整个 all 就失败 // 其他 Promise 仍然会执行完,但结果被丢弃 console.error('有一个请求失败:', err); }); // 传入空数组立即 resolve Promise.all([]).then(v => console.log(v)); // [] // 非 Promise 值会被自动包装 Promise.all([1, 'hello', Promise.resolve(42)]) .then(v => console.log(v)); // [1, 'hello', 42] // 实际应用:批量操作 const userIds = [1, 2, 3, 4, 5]; const users = await Promise.all( userIds.map(id => getUser(id)) ); ``` - async/await - async 函数基础 - async/await 是 Promise 的语法糖,让异步代码看起来像同步代码一样。 - ```js // async 函数始终返回 Promise async function greet() { return 'Hello'; // 自动包装为 Promise.resolve('Hello') } greet().then(v => console.log(v)); // "Hello" // 等价于 function greet() { return Promise.resolve('Hello'); } // 即使返回普通值,也是 Promise async function getNumber() { return 42; } console.log(getNumber() instanceof Promise); // true // 抛出的错误变成 rejected Promise async function fail() { throw new Error('出错了'); } fail().catch(err => console.log(err.message)); // "出错了" ``` - await 表达式 - await 只能在 async 函数(或模块顶层)内部使用。它暂停函数执行,等待 Promise resolve,然后返回 resolve 的值。 - ```js async function example() { console.log('1. 开始'); // await 暂停,等 Promise resolve const result = await new Promise(resolve => { setTimeout(() => resolve('数据'), 1000); }); console.log('2. 收到:', result); // 1 秒后 console.log('3. 继续'); } example(); console.log('4. async 函数外部'); // 输出: // 1. 开始 // 4. async 函数外部(不等待 async 函数) // (1 秒后) // 2. 收到: 数据 // 3. 继续 ``` - await 的本质——对比 then 链: ```js // Promise 链 function getData() { return getUser(1) .then(user => getOrders(user.id)) .then(orders => getOrderDetail(orders[0].id)) .then(detail => { console.log(detail); return detail; }); } // async/await(完全等价,但可读性好得多) async function getData() { const user = await getUser(1); const orders = await getOrders(user.id); const detail = await getOrderDetail(orders[0].id); console.log(detail); return detail; } // 最大的优势:中间变量全部可用 // 不需要像 Promise 链那样用嵌套或外部变量传递 ``` - 错误处理 - try/catch ```js async function fetchUserData(userId) { try { const user = await getUser(userId); const orders = await getOrders(user.id); const detail = await getOrderDetail(orders[0].id); return { user, orders, detail }; } catch (error) { // 任何一步的 reject 或 throw 都会被捕获 console.error('获取数据失败:', error.message); throw error; // 重新抛出或返回兜底数据 } } // 精细化错误处理:每步单独 try/catch async function fetchWithFallback(userId) { let user; try { user = await getUser(userId); } catch (error) { console.warn('用户服务异常,使用默认数据'); user = { id: userId, name: 'Unknown' }; } let orders; try { orders = await getOrders(user.id); } catch (error) { console.warn('订单服务异常'); orders = []; } return { user, orders }; } ```

CSS 复习

基础核心 - 基础核心 - CSS 基础与选择器 - CSS 的三种引入方式 - ```html <!-- 1. 行内样式:直接写在标签上,优先级最高但不推荐大量使用 --> <p style="color: red; font-size: 16px;">这是行内样式</p> <!-- 2. 内嵌样式:写在 <head> 的 <style> 标签里 --> <head> <style> p { color: blue; } </style> </head> <!-- 3. 外链样式(推荐!):单独的 .css 文件 --> <link rel="stylesheet" href="style.css"> ``` - 一般使用外链样式,它能让 HTML 和 CSS 分离,方便维护和缓存 - 基础选择器 - ```css /* 元素选择器 —— 选中所有该标签 */ p { color: #333; } h1 { font-size: 24px; } /* 类选择器 —— 最常用,可复用 */ .card { background: #fff; padding: 16px; } .highlight { color: orange; } /* ID 选择器 —— 唯一的,一个页面同一个 ID 只用一次 */ #header { height: 60px; } /* 通配符选择器 —— 选中所有元素,常用于重置 */ * { margin: 0; padding: 0; box-sizing: border-box; /* 第 3 天会详细讲 */ } ``` - 优先使用类选择器,ID 选择器优先级太高不好覆盖,元素选择器范围太大容易误伤 - 组合选择器 - ```css /* 后代选择器(空格)—— 选中所有后代,不管嵌套多深 */ .article p { line-height: 1.8; } /* 子代选择器(>)—— 只选直接子元素 */ .nav > li { display: inline-block; } /* 相邻兄弟选择器(+)—— 紧挨着的下一个兄弟 */ h2 + p { font-size: 18px; /* 紧跟在 h2 后面的第一个 p */ } /* 通用兄弟选择器(~)—— 后面所有的兄弟 */ h2 ~ p { color: #666; /* h2 后面的所有 p */ } ``` - ```html <div class="nav"> <li>直接子元素 ✅ 被 .nav > li 选中</li> <li>直接子元素 ✅ <li>孙子元素 ❌ 不会被 .nav > li 选中</li> <li>孙子元素 ❌ 但会被 .nav li 选中</li> </li> </div> ``` - 多选择器组合技巧 - ```css /* 并集选择器(逗号)—— 同时给多个选择器设置相同样式 */ h1, h2, h3 { font-weight: bold; color: #222; } /* 交集选择器(紧挨着写)—— 同时满足多个条件 */ p.highlight { /* 既是 p 标签,又有 highlight 类 */ background: yellow; } /* 链式类选择器 —— 同时拥有多个类 */ .btn.primary.large { /* class="btn primary large" 的元素 */ padding: 12px 24px; } ``` - 伪类选择器 - 伪类用 : 开头,表示元素的某种状态或位置 - 交互状态伪类 ```css /* 鼠标悬停 */ .btn:hover { background: #2563eb; color: white; } /* 获得焦点(键盘 Tab 到或点击输入框时) */ input:focus { border-color: #2563eb; outline: none; } /* 点击瞬间 */ .btn:active { transform: scale(0.98); } /* 已访问的链接 */ a:visited { color: #666; } ``` - 结构位置伪类 ```css /* 第一个 / 最后一个子元素 */ li:first-child { font-weight: bold; } li:last-child { border-bottom: none; } /* 第 n 个子元素 —— 最灵活的选择器 */ tr:nth-child(2n) { background: #f9f9f9; /* 偶数行加背景,实现斑马纹 */ } tr:nth-child(odd) { background: #fff; /* odd = 奇数,even = 偶数 */ } /* 前 3 个 */ li:nth-child(-n+3) { color: red; } /* 每隔 3 个 */ li:nth-child(3n) { font-weight: bold; } /* 同类型的第一个(区分标签类型) */ p:first-of-type { font-size: 18px; } /* 唯一的子元素 */ p:only-child { text-align: center; } ``` - 其他实用伪类 ```css /* 否定伪类 —— 排除某些元素 */ li:not(:last-child) { border-bottom: 1px solid #eee; /* 除了最后一个都加下划线 */ } input:not([type="submit"]) { border: 1px solid #ddd; /* 除了提交按钮外的 input */ } /* 空元素 */ div:empty { display: none; /* 隐藏没有内容的 div */ } ``` - 伪元素选择器 - 用 :: 开头,会创建一个虚拟元素插入到页面中 - ```css /* ::before 在元素内容前面插入 */ .required::before { content: "* "; color: red; } /* ::after 在元素内容后面插入 */ a.external::after { content: " ↗"; font-size: 12px; } /* 经典用法:清除浮动(了解即可) */ .clearfix::after { content: ""; display: block; clear: both; } /* 装饰性下划线 */ .fancy-title::after { content: ""; display: block; width: 60px; height: 3px; background: #2563eb; margin-top: 8px; } /* 选中文本的样式 */ ::selection { background: #2563eb; color: white; } /* 输入框占位符 */ input::placeholder { color: #aaa; font-style: italic; } ``` - ::before 和 ::after 必须有 content 属性,哪怕是空字符串 "",否则不会显示。 - 属性选择器 - 根据 HTML 属性来选择元素,在表单样式中特别好用: - ```css /* 精确匹配 */ input[type="text"] { border: 1px solid #ddd; } input[type="password"] { letter-spacing: 4px; } /* 开头匹配 ^= */ a[href^="https"] { color: green; /* https 开头的链接标绿 */ } /* 结尾匹配 $= */ a[href$=".pdf"] { color: red; /* PDF 链接标红 */ } /* 包含匹配 *= */ img[src*="avatar"] { border-radius: 50%; /* src 中含 avatar 的图片变圆 */ } /* 有这个属性就选中 */ [disabled] { opacity: 0.5; cursor: not-allowed; } ``` - 优先级(权重)计算 - 当多条规则冲突时,浏览器按优先级决定谁生效 - ```css !important → 10000(尽量不用) 行内样式 style="" → 1000 ID 选择器 #id → 100 类/伪类/属性选择器 → 10 元素/伪元素选择器 → 1 通配符 * → 0 ``` - ```css /* 权重:0-0-1(一个元素) */ p { color: black; } /* 权重:0-1-0(一个类) */ .text { color: blue; } /* 权重:0-1-1(一个类 + 一个元素) */ p.text { color: green; } /* 权重:1-0-0(一个 ID)→ 这条赢 */ #intro { color: red; } /* 权重:0-2-1(两个类 + 一个元素) */ .card .content p { color: gray; } ``` - 盒模型深入 - 盒模型四层结构 - ![](https://an-img.oss-cn-hangzhou.aliyuncs.com/2026/04/01/20260401100513772.png,450,380) - ```css .box { /* 内容区 */ width: 200px; height: 100px; /* 内边距 —— 内容到边框的呼吸空间 */ padding: 16px; /* 四边相同 */ padding: 16px 24px; /* 上下 16 左右 24 */ padding: 8px 16px 24px; /* 上 8 左右 16 下 24 */ padding: 8px 16px 24px 32px;/* 上 右 下 左(顺时针) */ /* 边框 */ border: 1px solid #ddd; border-radius: 8px; /* 圆角 */ /* 外边距 —— 盒子与其他元素的间距 */ margin: 20px; margin: 0 auto; /* 水平居中的经典写法 */ } ``` - padding 和 margin 的简写顺序是顺时针:上 → 右 → 下 → 左。两个值时是"上下"和"左右",三个值是"上""左右""下"。 - box-sizing:最重要的一个属性 - ```css /* 默认值:width 只算内容区 */ .content-box { box-sizing: content-box; width: 200px; padding: 20px; border: 2px solid #000; /* 实际占据宽度 = 200 + 20*2 + 2*2 = 244px 😰 */ } /* 推荐值:width 包含 padding 和 border */ .border-box { box-sizing: border-box; width: 200px; padding: 20px; border: 2px solid #000; /* 实际占据宽度 = 200px 🎉 内容区自动缩小为 156px */ } ``` - content-box 的计算方式违反直觉,设了 width: 200px 结果实际更宽。 - 所以现代开发第一行永远是: ```css /* 全局重置 —— 背下来,每个项目都加 */ *, *::before, *::after { box-sizing: border-box; } ``` - margin 合并 - 相邻兄弟的 margin 合并 ```css .box-a { margin-bottom: 30px; } .box-b { margin-top: 20px; } /* 两者之间的间距不是 50px,而是 30px(取较大值) */ ``` - 父子元素的 margin 穿透 ```html <div class="parent"> <div class="child">子元素</div> </div> <style> .parent { background: #f0f0f0; } .child { margin-top: 30px; } /* 这个 margin 会"穿透"到父元素外面!父元素整体下移 30px */ </style> ``` - 解决办法 ```css /* 方法 1:给父元素加 overflow */ .parent { overflow: hidden; } /* 方法 2:给父元素加 padding 代替子元素的 margin */ .parent { padding-top: 30px; } /* 方法 3:给父元素加 border(哪怕是透明的) */ .parent { border-top: 1px solid transparent; } /* 方法 4(最推荐):用 Flexbox,自动避免合并 */ .parent { display: flex; flex-direction: column; } ``` - 为了避免 margin 合并带来的混乱,养成一个好习惯:统一只用一个方向的 margin。推荐只用 margin-bottom,不要上下混用 ```css /* ✅ 好习惯:统一用 margin-bottom */ h2 { margin-bottom: 12px; } p { margin-bottom: 16px; } /* ❌ 坏习惯:上下混用 */ h2 { margin-bottom: 12px; } p { margin-top: 20px; } /* 和 h2 的 bottom 合并了,不直观 */ ``` - display 属性 ```css /* block —— 独占一行,可以设宽高 */ /* 默认就是 block 的标签:div, p, h1~h6, ul, li, section... */ .block-el { display: block; width: 300px; /* ✅ 生效 */ height: 100px; /* ✅ 生效 */ margin: 20px; /* ✅ 四个方向都生效 */ } /* inline —— 和文字一起排列,不能设宽高 */ /* 默认就是 inline 的标签:span, a, strong, em, img... */ .inline-el { display: inline; width: 300px; /* ❌ 无效 */ height: 100px; /* ❌ 无效 */ margin-top: 20px; /* ❌ 上下 margin 无效 */ padding: 10px; /* ⚠️ 左右有效,上下视觉上有但不影响布局 */ } /* inline-block —— 既能和文字一行排列,又能设宽高 */ /* 两全其美,适合按钮、标签等小组件 */ .inline-block-el { display: inline-block; width: 120px; /* ✅ 生效 */ height: 40px; /* ✅ 生效 */ margin: 10px; /* ✅ 四个方向都生效 */ } ``` - 常用技巧:把 <a> 或 <span> 改成 inline-block,就可以给它们设置宽高和完整的 padding/margin 了。 ```css /* 经典用例:inline-block 做按钮 */ .tag { display: inline-block; padding: 4px 12px; background: #e8f4fd; color: #1a73e8; border-radius: 4px; font-size: 13px; } ``` - border 的用法 - ```css /* 基础语法:宽度 样式 颜色 */ .box { border: 1px solid #ddd; } /* 单侧边框 */ .section { border-left: 3px solid #2563eb; padding-left: 12px; } /* 圆角 */ .card { border-radius: 8px; } /* 四角相同 */ .avatar { border-radius: 50%; } /* 正圆(需要宽高相等) */ .pill { border-radius: 9999px; } /* 胶囊形状 */ /* 只圆上面两个角 */ .tab { border-radius: 8px 8px 0 0; } /* 虚线 / 点线 */ .dashed { border: 2px dashed #aaa; } .dotted { border: 1px dotted #aaa; } /* outline —— 不占空间的"边框",常用于焦点指示 */ button:focus { outline: 2px solid #2563eb; outline-offset: 2px; } ``` - 文档流与定位 - 正常文档流 - ![](https://an-img.oss-cn-hangzhou.aliyuncs.com/2026/04/01/20260401154807166.png,524,382) - 正常文档流中的元素互相"知道"彼此的存在,会自动避让 - 接下来要学的定位方式,有些会脱离文档流,让元素"飘起来",不再影响其他元素的排列 - position 五种定位方式 - static 默认值 - ```css .box { position: static; /* 默认就是这个,不用写 */ /* top/left/right/bottom 无效 */ /* z-index 无效 */ } ``` - relative 相对定位 - ```css .box { position: relative; top: 20px; /* 相对原位向下移 20px */ left: 30px; /* 相对原位向右移 30px */ } ``` - ![](https://an-img.oss-cn-hangzhou.aliyuncs.com/2026/04/01/20260401155249189.png,311,255) - relative 单独用的场景不多,它最大的价值是给 absolute 子元素提供定位参考点。 - absolute 绝对定位(重点) - 脱离文档流,相对于最近的有定位属性的祖先元素来定位 - ```css /* 经典搭配:父 relative + 子 absolute */ .parent { position: relative; /* 成为定位参考点 */ width: 400px; height: 300px; } .child { position: absolute; top: 0; right: 0; /* 贴在父元素的右上角 */ /* 这个子元素已经脱离文档流,不占原来的空间 */ } ``` - 如果找不到有定位属性的祖先,就相对于整个页面(<html>)定位。 - 常见用法 ```css /* 右上角的徽标 / 角标 */ .avatar-wrap { position: relative; display: inline-block; } .badge { position: absolute; top: -4px; right: -4px; width: 18px; height: 18px; background: red; color: white; font-size: 12px; text-align: center; line-height: 18px; border-radius: 50%; } /* 绝对定位实现水平垂直居中 */ .centered { position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); } /* 铺满父元素(做遮罩层常用) */ .overlay { position: absolute; top: 0; left: 0; right: 0; bottom: 0; /* 等价于简写:inset: 0; */ background: rgba(0, 0, 0, 0.5); } ``` - fixed - 脱离文档流,相对于浏览器视口定位,滚动页面也不动: - ```css /* 固定顶部导航 */ .navbar { position: fixed; top: 0; left: 0; right: 0; /* left + right = 0 让它撑满整个宽度 */ height: 60px; background: white; z-index: 100; /* 确保在最上层 */ } /* 别忘了给 body 加上 padding,防止内容被导航遮住 */ body { padding-top: 60px; } /* 右下角的回到顶部按钮 */ .back-to-top { position: fixed; bottom: 30px; right: 30px; width: 44px; height: 44px; } ``` - sticky 粘性定位(现代常用) - 结合了 relative 和 fixed 的效果——正常情况下跟着滚动,滚到指定位置后就"粘"住 - 父元素不能设 overflow: hidden 或 overflow: auto,否则 sticky 会失效。 - ```css /* 表格的表头粘在顶部 */ thead th { position: sticky; top: 0; background: white; } /* 侧边栏跟着滚到一定位置后固定 */ .sidebar { position: sticky; top: 80px; /* 距顶部 80px 时粘住 */ } /* 分组列表的标题吸顶 */ .group-title { position: sticky; top: 0; background: #f5f5f5; padding: 8px 16px; font-weight: bold; } ``` - z-index 层叠上下文 - 当元素重叠时,z-index 控制谁在上面 - ```css /* z-index 只对有定位属性的元素生效(非 static) */ .layer-back { position: relative; z-index: 1; } .layer-middle { position: relative; z-index: 10; } .layer-front { position: relative; z-index: 100; } ``` - 分层规范 ```css /* 建议给项目定一套层级规范,避免数字乱飞 */ :root { --z-dropdown: 100; /* 下拉菜单 */ --z-sticky: 200; /* 粘性元素 */ --z-navbar: 300; /* 导航栏 */ --z-overlay: 400; /* 遮罩层 */ --z-modal: 500; /* 弹窗 */ --z-toast: 600; /* 提示消息 */ } .modal { z-index: var(--z-modal); } .overlay { z-index: var(--z-overlay); } .navbar { z-index: var(--z-navbar); } ``` - z-index 不是全局比较的。每个设置了定位 + z-index 的元素会创建一个层叠上下文,子元素的 z-index 只在父元素内部比较: - 浮动 float (了解即可) - 浮动是 CSS2 时代的布局方式,现在已经被 Flexbox 和 Grid 替代 - ```css /* 文字环绕图片 —— float 唯一还在用的场景 */ .article img { float: left; margin-right: 16px; margin-bottom: 8px; } ``` - 如果遇到浮动导致父元素高度塌陷的问题,用学过的 ::after 清除浮动或者直接改用 Flexbox 就好 - 颜色与背景 - 颜色表示法 - HEX(十六进制) ```css /* 6 位写法:#RRGGBB */ .box { color: #2563eb; } /* 3 位简写:当每组两位相同时 */ .box { color: #fff; } /* 等价于 #ffffff */ .box { color: #333; } /* 等价于 #333333 */ /* 8 位写法:带透明度 #RRGGBBAA */ .box { background: #2563eb80; } /* 50% 透明度 */ ``` - HEX 是最常见的写法,设计稿给的颜色值通常就是 HEX。缺点是不直观,看不出颜色长什么样。 - RGB/RGBA - ```css /* rgb(红, 绿, 蓝) 每个值 0~255 */ .box { color: rgb(37, 99, 235); } /* rgba 加一个透明度参数 0~1 */ .box { background: rgba(37, 99, 235, 0.5); } /* 50% 透明 */ /* 现代写法:直接在 rgb 里加透明度,用 / 分隔 */ .box { background: rgb(37 99 235 / 0.5); } ``` - HSL / HSLA(推荐学会) - ```css /* hsl(色相, 饱和度, 亮度) */ /* 色相 H:0~360 的色环角度 0/360 = 红,120 = 绿,240 = 蓝 饱和度 S:0% = 灰色,100% = 纯色 亮度 L:0% = 黑色,50% = 正常,100% = 白色 */ .box { color: hsl(220, 85%, 53%); } /* 蓝色 */ .box { color: hsl(220, 85%, 53%, 0.5); } /* 半透明蓝 */ ``` - ```css /* 同一个蓝色,只改亮度就能得到一组深浅色 */ .blue-50 { background: hsl(220, 85%, 97%); } /* 极浅蓝背景 */ .blue-100 { background: hsl(220, 85%, 90%); } /* 浅蓝 */ .blue-500 { background: hsl(220, 85%, 53%); } /* 主色 */ .blue-700 { background: hsl(220, 85%, 35%); } /* 深蓝 */ .blue-900 { background: hsl(220, 85%, 20%); } /* 极深蓝文字 */ /* 只改色相就能切换颜色 */ .red { color: hsl(0, 85%, 53%); } .green { color: hsl(120, 85%, 35%); } .purple { color: hsl(270, 85%, 53%); } ``` - 命名颜色 - ```css .box { color: red; } .box { background: transparent; } /* 透明,这个经常用 */ .box { color: currentColor; } /* 继承当前元素的 color 值 */ ``` - opacity 与 RGBA 透明的区别 ```css /* opacity:整个元素(包括文字和子元素)都变透明 */ .card { background: #2563eb; color: white; opacity: 0.5; /* 背景、文字、子元素全部变成 50% 透明 😰 */ } /* rgba / hsla:只让颜色本身透明 */ .card { background: rgba(37, 99, 235, 0.5); color: white; /* 只有背景半透明,文字完全清晰 ✅ */ } ``` - 背景属性 - background-color ```css .box { background-color: #f5f5f5; } ``` - 渐变背景(重点) ```css /* 线性渐变 */ .banner { background: linear-gradient(to right, #667eea, #764ba2); } /* 指定角度 */ .banner { background: linear-gradient(135deg, #667eea, #764ba2); } /* 多色渐变 */ .rainbow { background: linear-gradient( 90deg, #ff6b6b, #feca57, #48dbfb, #ff9ff3 ); } /* 控制颜色位置 */ .sharp { background: linear-gradient( 90deg, #2563eb 0%, #2563eb 50%, /* 蓝色占前 50% */ #f59e0b 50%, /* 黄色从 50% 开始,形成硬分界 */ #f59e0b 100% ); } /* 径向渐变(从中心向外) */ .spotlight { background: radial-gradient(circle, #fff, #e2e8f0); } /* 椭圆渐变 + 偏移中心 */ .glow { background: radial-gradient( ellipse at top left, rgba(37, 99, 235, 0.2), transparent 60% ); } ``` - 背景图片 ```css .hero { /* 基础用法 */ background-image: url('hero.jpg'); /* 不重复 */ background-repeat: no-repeat; /* 覆盖整个容器(可能裁切) */ background-size: cover; /* 完全显示(可能留白) */ background-size: contain; /* 居中显示 */ background-position: center; /* 滚动时背景固定(视差效果) */ background-attachment: fixed; /* 简写(推荐记住这个万能组合) */ background: url('hero.jpg') no-repeat center / cover; } ``` - 多重背景叠加 ```css /* 渐变叠加在图片上 —— 做图文卡片的经典手法 */ .card-with-image { background: linear-gradient( to bottom, transparent 40%, rgba(0, 0, 0, 0.7) 100% ), url('photo.jpg') no-repeat center / cover; color: white; } /* 装饰性渐变叠加 */ .fancy-bg { background: radial-gradient(ellipse at top left, rgba(37, 99, 235, 0.15), transparent 50%), radial-gradient(ellipse at bottom right, rgba(245, 158, 11, 0.15), transparent 50%), #fafafa; } ``` - 配色技巧 ```css /* 一个页面通常只需要这几种颜色 */ :root { --color-primary: hsl(220, 85%, 53%); /* 主色:按钮、链接 */ --color-primary-light: hsl(220, 85%, 95%);/* 主色浅版:背景 */ --color-primary-dark: hsl(220, 85%, 40%); /* 主色深版:悬停 */ --color-gray-50: hsl(220, 10%, 98%); /* 页面背景 */ --color-gray-200: hsl(220, 10%, 88%); /* 边框、分隔线 */ --color-gray-500: hsl(220, 10%, 50%); /* 次要文字 */ --color-gray-900: hsl(220, 10%, 12%); /* 主文字 */ --color-success: hsl(145, 65%, 42%); /* 成功 */ --color-warning: hsl(40, 95%, 55%); /* 警告 */ --color-danger: hsl(0, 75%, 55%); /* 错误 */ } /* 使用变量 */ .btn-primary { background: var(--color-primary); color: white; } .btn-primary:hover { background: var(--color-primary-dark); } ``` - 字体与文字排版 - font-family 字体栈 - ```css /* 字体栈:从左到右依次尝试,找到第一个可用的就使用 */ body { font-family: "PingFang SC", "Microsoft YaHei", "Helvetica Neue", Arial, sans-serif; } ``` - ```css font-family: "PingFang SC", /* macOS 上的中文字体 */ "Microsoft YaHei", /* Windows 上的中文字体 */ "Helvetica Neue", /* macOS 上的英文字体 */ Arial, /* Windows 上的英文字体 */ sans-serif; /* 兜底:让系统选一个无衬线字体 */ ``` - 引入 google fonts - ```html <!-- 在 HTML 的 <head> 中引入 --> <link href="https://fonts.googleapis.cn/css2?family=Noto+Sans+SC:wght@400;500;700&display=swap" rel="stylesheet"> ``` - ```css body { font-family: "Noto Sans SC", sans-serif; } ``` -系统字体栈(零加载延迟) ```css /* 直接使用用户系统里的字体,不需要下载 */ body { font-family: system-ui, -apple-system, "Segoe UI", Roboto, "Noto Sans SC", sans-serif; } /* 代码用等宽字体 */ code, pre { font-family: "Fira Code", "JetBrains Mono", "Source Code Pro", Consolas, monospace; } ``` - font-size 与单位 - ```css /* px —— 绝对单位,固定大小 */ h1 { font-size: 32px; } /* em —— 相对于父元素的 font-size */ .parent { font-size: 16px; } .child { font-size: 1.5em; } /* = 16 × 1.5 = 24px */ /* em 的问题:层层嵌套时会复利式叠加 */ .child .grandchild { font-size: 1.5em; } /* = 24 × 1.5 = 36px 😰 */ /* rem —— 相对于根元素(html)的 font-size ⭐ 推荐 */ html { font-size: 16px; } /* 浏览器默认就是 16px */ h1 { font-size: 2rem; } /* = 16 × 2 = 32px */ h2 { font-size: 1.5rem; } /* = 16 × 1.5 = 24px */ p { font-size: 1rem; } /* = 16px */ ``` - ```css /* px —— 绝对单位,固定大小 */ h1 { font-size: 32px; } /* em —— 相对于父元素的 font-size */ .parent { font-size: 16px; } .child { font-size: 1.5em; } /* = 16 × 1.5 = 24px */ /* em 的问题:层层嵌套时会复利式叠加 */ .child .grandchild { font-size: 1.5em; } /* = 24 × 1.5 = 36px 😰 */ /* rem —— 相对于根元素(html)的 font-size ⭐ 推荐 */ html { font-size: 16px; } /* 浏览器默认就是 16px */ h1 { font-size: 2rem; } /* = 16 × 2 = 32px */ h2 { font-size: 1.5rem; } /* = 16 × 1.5 = 24px */ p { font-size: 1rem; } /* = 16px */ ``` - 记住一个原则:font-size 用 rem,padding/margin 等间距可以用 rem 或 px。 - rem 的好处是后面做响应式时,只需要改 html 的 font-size 就能整体缩放。 - font 相关属性大全 - ```css .text { /* 字号 */ font-size: 16px; /* 字重:100~900,或 normal(400) / bold(700) */ font-weight: 400; /* 正常 */ font-weight: 700; /* 加粗 */ /* 风格 */ font-style: normal; /* 正常 */ font-style: italic; /* 斜体 */ /* 简写(顺序固定:style weight size/line-height family) */ font: italic 700 18px/1.6 "Noto Sans SC", sans-serif; } ``` - 一般只用 400(正文)和 700(标题/强调)两种就够了,最多加一个 500(中等) - 行高与文本间距 - ```css .article { /* line-height:行高,控制行与行之间的距离 */ line-height: 1.8; /* 无单位写法(推荐)= font-size × 1.8 */ line-height: 28px; /* 固定值写法 */ /* letter-spacing:字间距 */ letter-spacing: 0.5px; /* 中文正文加一点字间距更透气 */ letter-spacing: 2px; /* 大标题可以拉大 */ letter-spacing: -0.5px; /* 紧凑排列 */ /* word-spacing:词间距(英文用) */ word-spacing: 2px; } ``` - ```css /* 正文:1.6 ~ 1.8,中文偏大更舒适 */ .body-text { line-height: 1.7; } /* 标题:1.2 ~ 1.4,紧凑一些更有力 */ h1, h2, h3 { line-height: 1.3; } /* 按钮/标签:等于 height 实现垂直居中 */ .btn { height: 40px; line-height: 40px; /* 文字垂直居中 */ } /* UI 组件:1.4 ~ 1.5 */ .card p { line-height: 1.5; } ``` - 文本对齐与装饰 - ```css /* 水平对齐 */ .left { text-align: left; } .center { text-align: center; } .right { text-align: right; } .justify { text-align: justify; } /* 两端对齐,中文排版常用 */ /* 文本装饰 */ a { text-decoration: none; } /* 去掉链接下划线 */ .del { text-decoration: line-through; } /* 删除线 */ .underline { text-decoration: underline; text-decoration-color: #2563eb; /* 下划线颜色 */ text-underline-offset: 4px; /* 下划线和文字的距离 */ text-decoration-thickness: 2px; /* 下划线粗细 */ } /* 文本转换 */ .upper { text-transform: uppercase; } /* 全大写 */ .lower { text-transform: lowercase; } /* 全小写 */ .cap { text-transform: capitalize; } /* 首字母大写 */ /* 文本缩进(中文段落首行缩进) */ .cn-paragraph { text-indent: 2em; } /* 两个字的宽度 */ ``` - 文本溢出管理 - ```css /* 单行文本溢出显示省略号 —— 三件套必须一起用 */ .ellipsis { white-space: nowrap; /* 不换行 */ overflow: hidden; /* 超出隐藏 */ text-overflow: ellipsis; /* 显示省略号 */ } /* 多行文本溢出(限制 3 行) */ .line-clamp { display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 3; /* 最多显示 3 行 */ overflow: hidden; } /* white-space 的几个值 */ .nowrap { white-space: nowrap; } /* 强制不换行 */ .pre { white-space: pre; } /* 保留空格和换行(像 <pre>) */ .prewrap { white-space: pre-wrap; } /* 保留空格换行,但允许自动折行 */ /* overflow-wrap:长单词/URL 强制断行 */ .break-word { overflow-wrap: break-word; /* 旧写法:word-break: break-all; 也能用但更暴力 */ } ``` 布局体系 - 布局体系 - Flex 基础与主轴 - Flex 容器与项目 - ```html <!-- 容器(父元素):加 display: flex 的那个 --> <div class="container"> <!-- 项目(子元素):容器的直接子元素自动成为 flex 项目 --> <div class="item">A</div> <div class="item">B</div> <div class="item">C</div> </div> ``` - ```css .container { display: flex; /* 这一行就开启了 Flexbox */ } ``` - ![](https://an-img.oss-cn-hangzhou.aliyuncs.com/2026/04/02/20260402104212118.png,568,241) - inline-flex,和 flex 唯一的区别是容器本身变成行内元素 - ```css /* flex:容器是块级元素,独占一行 */ .container { display: flex; } /* inline-flex:容器是行内元素,可以和其他内容并排 */ .badge-group { display: inline-flex; } ``` - 主轴与交叉轴 - ![](https://an-img.oss-cn-hangzhou.aliyuncs.com/2026/04/02/20260402104703467.png,338,443) - justify-content 控制主轴,align-items 控制交叉轴。主轴方向由 flex-direction 决定。 - flex-direction 主轴方向 - ```css .container { display: flex; /* 四个方向 */ flex-direction: row; /* 默认:从左到右 → */ flex-direction: row-reverse; /* 从右到左 ← */ flex-direction: column; /* 从上到下 ↓ */ flex-direction: column-reverse; /* 从下到上 ↑ */ } ``` - ```css /* 水平导航 */ .nav { display: flex; flex-direction: row; } /* 垂直堆叠的表单 */ .form { display: flex; flex-direction: column; } /* 移动端常见:图标在上文字在下 */ .tab-item { display: flex; flex-direction: column; align-items: center; } ``` - justify-content 主轴对齐(重点) - ```css .container { display: flex; justify-content: flex-start; /* 默认:靠起始端 */ justify-content: flex-end; /* 靠末端 */ justify-content: center; /* 居中 */ justify-content: space-between; /* 两端对齐,中间等分 */ justify-content: space-around; /* 每个项目两侧等间距 */ justify-content: space-evenly; /* 所有间距完全相等 */ } ``` - ![](https://an-img.oss-cn-hangzhou.aliyuncs.com/2026/04/02/20260402105720219.png,615,368) - 常用场景 ```css /* 导航栏:logo 在左,链接在右 */ .navbar { display: flex; justify-content: space-between; } /* 按钮组居中 */ .button-group { display: flex; justify-content: center; } /* 底部 Tab 栏等分 */ .tab-bar { display: flex; justify-content: space-evenly; } /* 表单按钮靠右 */ .form-actions { display: flex; justify-content: flex-end; } ``` - flex-wrap - 默认情况下所有项目挤在一行,即使溢出也不换行 - ```css .container { display: flex; flex-wrap: nowrap; /* 默认:不换行,项目可能被压缩 */ flex-wrap: wrap; /* 放不下就换行 */ flex-wrap: wrap-reverse; /* 换行但方向反转(新行在上面) */ } ``` - flex-wrap: wrap 配合固定宽度的项目可以实现简单的网格效果 ```css /* 简易卡片网格 */ .card-list { display: flex; flex-wrap: wrap; gap: 16px; } .card { width: 300px; /* 固定宽度,放不下自动换行 */ } ``` - flex-flow 简写 - ```css /* 简写:flex-flow: <direction> <wrap> */ .container { flex-flow: row wrap; /* 水平排列 + 允许换行 */ flex-flow: column nowrap; /* 垂直排列 + 不换行 */ } /* 等价于分开写 */ .container { flex-direction: row; flex-wrap: wrap; } ``` - gap 间距 - gap 是控制 Flex 项目之间间距的最佳方式,比给每个项目加 margin 干净得多 - ```css .container { display: flex; gap: 16px; /* 行列间距都是 16px */ gap: 16px 24px; /* 行间距 16px,列间距 24px */ row-gap: 16px; /* 只设行间距 */ column-gap: 24px; /* 只设列间距 */ } ``` - gap 相比 margin 的优势 ```css /* ❌ 用 margin 的痛苦:最后一个元素多出右边距 */ .item { margin-right: 16px; } .item:last-child { margin-right: 0; } /* 还得手动去掉 */ /* ✅ 用 gap:自动只在项目之间加间距,边缘没有 */ .container { display: flex; gap: 16px; } ``` - Flexbox 交叉轴与项目属性 - align-items 交叉轴对齐(重点) - justify-content 管主轴,align-items 管交叉轴。当主轴是水平方向时,交叉轴就是垂直方向 - ```css .container { display: flex; height: 200px; /* 容器要有高度才能看出交叉轴效果 */ align-items: stretch; /* 默认:项目拉伸撑满容器高度 */ align-items: flex-start; /* 顶部对齐 */ align-items: flex-end; /* 底部对齐 */ align-items: center; /* 垂直居中 */ align-items: baseline; /* 按文字基线对齐 */ } ``` - ![](https://an-img.oss-cn-hangzhou.aliyuncs.com/2026/04/02/20260402112324221.png,389,563) - align-self 单个项目的对齐 - align-items 是容器属性,控制所有项目。align-self 写在项目上,让单个项目有不同的对齐方式 - ```css .container { display: flex; align-items: flex-start; /* 所有项目顶部对齐 */ height: 200px; } /* 只让第三个项目底部对齐 */ .item-c { align-self: flex-end; } /* 只让第二个项目居中 */ .item-b { align-self: center; } ``` - align-content 多行对齐 - align-content 只在 flex-wrap: wrap 产生多行时才有效果,它控制的是多行整体在交叉轴上的分布 - ```css .container { display: flex; flex-wrap: wrap; height: 400px; align-content: flex-start; /* 所有行挤在顶部 */ align-content: flex-end; /* 所有行挤在底部 */ align-content: center; /* 所有行居中 */ align-content: space-between; /* 首行贴顶末行贴底,中间等分 */ align-content: space-around; /* 每行上下等间距 */ align-content: stretch; /* 默认:行高拉伸填满容器 */ } ``` - ![](https://an-img.oss-cn-hangzhou.aliyuncs.com/2026/04/02/20260402224434719.png,633,199) - Flex 项目属性(核心) - 写在子元素上的三个属性,控制项目如何分配空间 - flex-basis 基础尺寸 - ```css .item { /* flex-basis 定义项目在分配多余空间之前的初始大小 */ flex-basis: auto; /* 默认:使用 width 或内容宽度 */ flex-basis: 200px; /* 固定初始宽度 200px */ flex-basis: 30%; /* 初始占 30% */ flex-basis: 0; /* 初始宽度为 0,完全由 flex-grow 决定 */ } ``` - flex-basis 和 width 的关系:当两者同时存在时,flex-basis 优先级更高(主轴为水平时) - 用了 Flexbox 后建议统一用 flex-basis 代替 width。 - flex-grow 放大比例 - 当容器有剩余空间时,flex-grow 决定每个项目分到多少: - ```css /* 默认 flex-grow: 0,不放大 */ /* 三个项目各自宽 100px,容器宽 600px,剩余 300px */ .item-a { flex-grow: 1; } /* 分到 300 × 1/3 = 100px → 总宽 200px */ .item-b { flex-grow: 1; } /* 分到 300 × 1/3 = 100px → 总宽 200px */ .item-c { flex-grow: 1; } /* 分到 300 × 1/3 = 100px → 总宽 200px */ ``` - 常见用法 ```css /* 让某个项目占满剩余空间 */ .sidebar { width: 250px; } /* 固定宽度 */ .main { flex-grow: 1; } /* 占满剩余 */ /* 搜索框:按钮固定宽,输入框自适应 */ .search-bar { display: flex; } .search-input { flex-grow: 1; } /* 输入框占满剩余空间 */ .search-btn { width: 80px; } /* 按钮固定宽 */ ``` - flex-shrink 缩小比例 - 当容器空间不足时,flex-shrink 决定每个项目被压缩多少 - ```css /* 默认 flex-shrink: 1,所有项目等比缩小 */ /* 不想被压缩(比如侧边栏固定宽度) */ .sidebar { flex-shrink: 0; /* 我不缩! */ width: 250px; } .main { flex-shrink: 1; /* 空间不够时我来缩 */ } ``` - flex 简写 ⭐ 必须掌握 - ```css /* flex: grow shrink basis */ flex: 0 1 auto; /* 默认值:不放大,可缩小,尺寸由内容决定 */ flex: 1; /* 等价于 flex: 1 1 0% → 等分剩余空间 */ flex: auto; /* 等价于 flex: 1 1 auto → 基于内容的弹性 */ flex: none; /* 等价于 flex: 0 0 auto → 完全不伸缩 */ flex: 0 0 200px; /* 固定 200px,不伸不缩 */ ``` - flex: 1 vs flex: auto 的区别 - ```css /* flex: 1 → flex: 1 1 0% */ /* basis 为 0,忽略内容宽度,纯按 grow 比例分配 */ /* flex: auto → flex: 1 1 auto */ /* basis 为 auto,先考虑内容宽度,再按 grow 比例分配剩余空间 */ ``` - 大多数情况用 flex: 1 就对了,想要项目完全等宽就用它。 - 经典布局模式 - ```css /* 模式 1:水平垂直居中(最经典的 Flex 用法) */ .center-box { display: flex; justify-content: center; align-items: center; } /* 模式 2:左右两端对齐 + 垂直居中(导航栏) */ .navbar { display: flex; justify-content: space-between; align-items: center; } /* 模式 3:固定侧边栏 + 自适应主内容 */ .layout { display: flex; } .sidebar { flex: 0 0 250px; } /* 固定 250px */ .main { flex: 1; } /* 占满剩余 */ /* 模式 4:底部固定 Footer(页面不够高时 Footer 也贴底) */ .page { display: flex; flex-direction: column; min-height: 100vh; } .page-header { /* 自然高度 */ } .page-main { flex: 1; } /* 撑满中间空间 */ .page-footer { /* 自然高度 */ } /* 模式 5:左图右文(或左文右图) */ .media { display: flex; align-items: flex-start; /* 顶部对齐,不拉伸 */ gap: 16px; } .media-image { flex: 0 0 120px; } /* 图片固定宽 */ .media-body { flex: 1; } /* 文字占满 */ ``` - Grid 基础与轨道定义 - Grid 容器与基本概念 - ```css .container { display: grid; /* 开启 Grid 布局 */ } ``` - ![](https://an-img.oss-cn-hangzhou.aliyuncs.com/2026/04/02/20260402225938244.png,349,399) - grid-template-columns 定义列 - ```css /* 固定宽度 */ .grid { display: grid; grid-template-columns: 200px 300px 200px; /* 三列:200 + 300 + 200 */ } /* 百分比 */ .grid { grid-template-columns: 25% 50% 25%; } /* fr 单位 —— 按比例分配剩余空间 ⭐ */ .grid { grid-template-columns: 1fr 2fr 1fr; /* 总共 4fr,第一列 1/4,第二列 2/4,第三列 1/4 */ } /* 混合使用 */ .grid { grid-template-columns: 250px 1fr 200px; /* 两侧固定,中间自适应 —— 比 Flex 更简洁! */ } ``` - `fr` 是 Grid 独有的单位,表示 fraction(份)。它会在固定尺寸分配完之后,按比例分配剩余空间 - grid-template-rows 定义行 - ```css .grid { display: grid; grid-template-columns: 1fr 1fr 1fr; grid-template-rows: 80px 1fr 60px; /* 三行:头 80px + 中间自适应 + 底 60px */ } /* 行通常不需要显式定义,让内容撑开即可 */ .grid { grid-template-columns: 1fr 1fr 1fr; /* 不写 grid-template-rows,行高由内容决定 */ } ``` - repeat() 简化重复定义 - ```css /* 写 12 列等宽 */ /* ❌ 繁琐写法 */ grid-template-columns: 1fr 1fr 1fr 1fr 1fr 1fr 1fr 1fr 1fr 1fr 1fr 1fr; /* ✅ repeat 写法 */ grid-template-columns: repeat(12, 1fr); /* 重复模式 */ grid-template-columns: repeat(3, 1fr 2fr); /* 等价于:1fr 2fr 1fr 2fr 1fr 2fr(6 列) */ /* 和固定列混合 */ grid-template-columns: 200px repeat(3, 1fr) 200px; /* 等价于:200px 1fr 1fr 1fr 200px(5 列) */ ``` - minmax() 弹性范围 - minmax(最小值, 最大值) 让列宽在一个范围内弹性变化: - ```css .grid { grid-template-columns: minmax(200px, 300px) 1fr 1fr; /* 第一列:最小 200px,最大 300px */ /* 后两列等分剩余空间 */ } /* 行高最小 100px,内容多时自动撑高 */ .grid { grid-template-rows: minmax(100px, auto); } ``` - gap 行列间距 - 和 Flexbox 的 gap 一样,但 Grid 里可以分别控制行间距和列间距 - ```css .grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 20px; /* 行列间距都是 20px */ gap: 16px 24px; /* 行间距 16px,列间距 24px */ row-gap: 16px; /* 只设行间距 */ column-gap: 24px; /* 只设列间距 */ } ``` - 隐式网格与 grid-auto-rows - 你定义了 2 行 3 列 = 6 个格子,但如果有 9 个项目,多出来的 3 个怎么办? - Grid 会自动创建新行来容纳它们,这就是隐式网格: - ```css .grid { display: grid; grid-template-columns: repeat(3, 1fr); grid-template-rows: 150px 150px; /* 只定义了 2 行 */ /* 隐式创建的行默认高度由内容决定 */ /* 用 grid-auto-rows 控制隐式行的高度 */ grid-auto-rows: 150px; /* 隐式行也是 150px */ grid-auto-rows: minmax(100px, auto); /* 最少 100px,内容多就撑高 */ } ``` - Grid 项目放置与区域 - grid-column / grid-row 放置项目 - 用网格线编号来精确控制项目的位置和大小 - ```css .item { /* 完整写法 */ grid-column-start: 1; grid-column-end: 3; /* 从列线 1 到列线 3 → 跨 2 列 */ grid-row-start: 1; grid-row-end: 2; /* 从行线 1 到行线 2 → 占 1 行 */ /* 简写(推荐) */ grid-column: 1 / 3; /* 起始线 / 结束线 */ grid-row: 1 / 2; } ``` - ![](https://an-img.oss-cn-hangzhou.aliyuncs.com/2026/04/02/20260402230753263.png,333,233) - span 关键字 - 用 span 表示跨几格 - ```css /* 以下三种写法效果相同 */ .item { grid-column: 1 / 3; } /* 从线 1 到线 3 */ .item { grid-column: 1 / span 2; } /* 从线 1 起,跨 2 列 */ .item { grid-column: span 2; } /* 从自然位置起,跨 2 列 */ ``` - span 在实际开发中更常用,因为你不需要关心具体的线编号 - ```css /* 图片画廊:让某张特色图跨 2 列 2 行 */ .photo-featured { grid-column: span 2; grid-row: span 2; } /* 跨满整行 */ .full-width { grid-column: 1 / -1; /* 从第一条线到最后一条线 */ } ``` - 实现各种跨列跨行布局 - ```css .grid { display: grid; grid-template-columns: repeat(4, 1fr); grid-auto-rows: 120px; gap: 12px; } /* 横幅:跨满一整行 */ .banner { grid-column: 1 / -1; /* 第 1 条线到最后一条线 */ } /* 大卡片:跨 2 列 2 行 */ .large-card { grid-column: span 2; grid-row: span 2; } /* 侧边栏:固定在右侧,跨 3 行 */ .sidebar { grid-column: 4; /* 只写一个值 = 起始线,占 1 列 */ grid-row: 1 / span 3; /* 从第 1 行起跨 3 行 */ } /* 自由放置到任意位置 */ .special { grid-column: 2 / 4; /* 第 2~3 列 */ grid-row: 2 / 4; /* 第 2~3 行 */ } /* ┌──────────────────────────────┐ │ banner (跨 4 列) │ ├───────────┬────────┬─────────┤ │ │ C │ │ │ large ├────────┤ sidebar │ │ (2×2) │ D │ (1×3) │ ├─────┬─────┼────────┤ │ │ E │ F │ G │ │ └─────┴─────┴────────┴─────────┘ */ ``` - grid-template-areas 命名区域 ⭐ 重点 - 这是 Grid 最直观的布局方式——用 ASCII 字符画的方式"画"出你的布局: - ```css .page { display: grid; grid-template-columns: 220px 1fr; grid-template-rows: 60px 1fr 50px; grid-template-areas: "header header" "sidebar main" "footer footer"; min-height: 100vh; } /* 每个子元素用 grid-area 对号入座 */ .page-header { grid-area: header; } .page-sidebar { grid-area: sidebar; } .page-main { grid-area: main; } .page-footer { grid-area: footer; } ``` - 规则 ```css grid-template-areas: "header header header" /* 同名区域必须是矩形 */ "sidebar main main" /* 每行的区域数 = 列数 */ "sidebar footer footer"; /* 用 . 表示空白格 */ /* ❌ 非矩形会报错 */ grid-template-areas: "header sidebar" "main main" "footer sidebar"; /* sidebar 呈 L 形,不合法! */ /* ✅ 用 . 留空 */ grid-template-areas: "header header header" "sidebar main ." /* 右下角留空 */ "footer footer footer"; ``` - Grid 中的对齐 - 容器级对齐(控制所有项目) - ```css .grid { display: grid; grid-template-columns: repeat(3, 100px); /* 网格总宽 300px,可能比容器窄 */ height: 400px; /* justify-items:项目在单元格内的水平对齐 */ justify-items: stretch; /* 默认:撑满单元格宽度 */ justify-items: start; /* 靠左 */ justify-items: end; /* 靠右 */ justify-items: center; /* 水平居中 */ /* align-items:项目在单元格内的垂直对齐 */ align-items: stretch; /* 默认:撑满单元格高度 */ align-items: start; /* 靠上 */ align-items: end; /* 靠下 */ align-items: center; /* 垂直居中 */ /* place-items:简写(垂直 水平) */ place-items: center; /* 水平垂直都居中 */ place-items: start end; /* 垂直靠上,水平靠右 */ } /* start + start center + center end + end ┌────────────┐ ┌────────────┐ ┌────────────┐ │[item] │ │ │ │ │ │ │ │ [item] │ │ │ │ │ │ │ │ [item]│ └────────────┘ └────────────┘ └────────────┘ */ ``` - 整个网格在容器内的对齐 - 当网格的总尺寸小于容器时,可以控制整个网格的位置: - ```css .grid { display: grid; grid-template-columns: repeat(3, 100px); /* 总宽 300px */ width: 600px; /* 容器宽 600px,有 300px 空余 */ /* justify-content:整个网格水平方向的对齐 */ justify-content: center; /* 网格整体水平居中 */ justify-content: space-between; /* 列之间等分空间 */ /* align-content:整个网格垂直方向的对齐 */ align-content: center; /* place-content 简写 */ place-content: center; } ``` - 单个项目对齐 - ```css /* 某个项目单独设置 */ .special-item { justify-self: center; /* 这个项目在格子里水平居中 */ align-self: end; /* 这个项目在格子里垂直靠底 */ /* 简写 */ place-self: end center; } ``` - Grid 自适应与 Flex vs Grid 选型 - auto-fill 与 auto-fit - auto-fill:能塞多少列就塞多少列 - ```css .grid { display: grid; grid-template-columns: repeat(auto-fill, 200px); gap: 16px; } ``` - auto-fit:同上,但空轨道会被折叠 - ```css .grid { display: grid; grid-template-columns: repeat(auto-fit, 200px); gap: 16px; } ``` - auto-fit + minmax 万能自适应 - ```css .grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 20px; } ``` - 这一行做了什么:每列最小 280px,有多余空间时等分放大到 1fr。容器变宽就自动多放一列,变窄就自动减少,每列永远在 280px ~ 等分之间弹性变化。 - auto-fill vs auto-fit + minmax 的区别 - 当项目数量少于能放下的列数时,差别才显现 - ```css /* 容器 1200px,3 个项目 */ /* auto-fill:空轨道保留,项目不会撑到 1fr */ repeat(auto-fill, minmax(280px, 1fr)) | [280px] [280px] [280px] [空280px] | 项目大小固定在 280px,因为还有空轨道分 fr /* auto-fit:空轨道折叠,项目撑满 */ repeat(auto-fit, minmax(280px, 1fr)) | [ 400px ] [ 400px ] [ 400px ] | 空轨道折叠了,3 个项目等分 1200px ``` 响应式与进阶 - 响应式与进阶 - 响应式基础与媒体查询 - viewport meta 标签 - ```html <meta name="viewport" content="width=device-width, initial-scale=1.0"> ``` - 手机浏览器会把页面当做 980px 宽的桌面页面来渲染,然后整体缩小 - 媒体查询 @media 基础用法 - ```css /* 基础语法 */ @media (条件) { /* 条件满足时才生效的样式 */ } /* 最大宽度:屏幕 ≤ 768px 时生效 */ @media (max-width: 768px) { .sidebar { display: none; } } /* 最小宽度:屏幕 ≥ 1024px 时生效 */ @media (min-width: 1024px) { .container { max-width: 1200px; } } /* 范围:屏幕在 768px ~ 1024px 之间 */ @media (min-width: 768px) and (max-width: 1024px) { .card-grid { grid-template-columns: repeat(2, 1fr); } } ``` - 现代范围语法 ```css /* 新语法:用比较运算符,更易读 */ @media (width <= 768px) { .sidebar { display: none; } } @media (width >= 1024px) { .container { max-width: 1200px; } } @media (768px <= width <= 1024px) { .card-grid { grid-template-columns: repeat(2, 1fr); } } ``` - 移动优先 vs 桌面优先 - 桌面优先 - ```css /* 默认样式 = 桌面 */ .grid { grid-template-columns: repeat(3, 1fr); } /* 平板:≤ 1024px */ @media (max-width: 1024px) { .grid { grid-template-columns: repeat(2, 1fr); } } /* 手机:≤ 768px */ @media (max-width: 768px) { .grid { grid-template-columns: 1fr; } } ``` - 移动优先 - ```css /* 默认样式 = 手机 */ .grid { grid-template-columns: 1fr; } /* 平板:≥ 768px */ @media (min-width: 768px) { .grid { grid-template-columns: repeat(2, 1fr); } } /* 桌面:≥ 1024px */ @media (min-width: 768px) { .grid { grid-template-columns: repeat(3, 1fr); } } ``` - 断点设计 - 断点就是布局发生变化的屏幕宽度 - 需要按布局需要记 - ```css /* 推荐的断点体系 */ /* 小手机 */ /* 默认样式覆盖,不需要媒体查询 */ /* 大手机 / 小平板 */ @media (min-width: 640px) { } /* 平板 */ @media (min-width: 768px) { } /* 小桌面 / 大平板横屏 */ @media (min-width: 1024px) { } /* 桌面 */ @media (min-width: 1280px) { } /* 大桌面 */ @media (min-width: 1536px) { } ``` - ```css /* 大多数项目只需要这三个 */ /* 手机:默认 */ /* 平板:768px */ /* 桌面:1024px */ ``` - 媒体查询的组织方式 - 集中在底部 ```css /* 所有默认样式 */ .header { ... } .hero { ... } .card { ... } /* 所有平板适配 */ @media (min-width: 768px) { .header { ... } .hero { ... } .card { ... } } /* 所有桌面适配 */ @media (min-width: 1024px) { .header { ... } .hero { ... } .card { ... } } ``` - 跟在组件后面(按组件分组) ```css /* 一个组件的所有状态都在一起,好维护。这也是大多数 CSS 框架和组件库的做法。*/ /* Header */ .header { ... } @media (min-width: 768px) { .header { ... } } @media (min-width: 1024px) { .header { ... } } /* Hero */ .hero { ... } @media (min-width: 768px) { .hero { ... } } @media (min-width: 1024px) { .hero { ... } } /* Card */ .card { ... } @media (min-width: 768px) { .card { ... } } ``` - 常见响应式模式 - 导航栏折叠 ```css /* 移动端:隐藏链接,显示菜单按钮 */ .nav { display: none; } .menu-toggle { display: block; } /* 桌面端:显示链接,隐藏按钮 */ @media (min-width: 768px) { .nav { display: flex; } .menu-toggle { display: none; } } ``` - 侧边栏变底部 ```css /* 移动端:单列,侧边栏在下面 */ .layout { display: grid; grid-template-columns: 1fr; } /* 桌面端:双列 */ @media (min-width: 1024px) { .layout { grid-template-columns: 1fr 280px; } } ``` - Grid areas 重排 ```css /* 移动端布局 */ .page { display: grid; grid-template-areas: "header" "main" "sidebar" "footer"; grid-template-columns: 1fr; } /* 桌面端布局 */ @media (min-width: 1024px) { .page { grid-template-areas: "header header" "sidebar main" "footer footer"; grid-template-columns: 240px 1fr; } } ``` - 表格在移动端变卡片 ```css /* 移动端:隐藏表头,每行变成卡片 */ @media (max-width: 768px) { table thead { display: none; } table tr { display: block; margin-bottom: 16px; border: 1px solid #eee; border-radius: 8px; padding: 12px; } table td { display: flex; justify-content: space-between; padding: 6px 0; } /* 用 data 属性模拟表头 */ table td::before { content: attr(data-label); font-weight: 700; color: #333; } } ``` - 间距随屏幕缩放 ```css /* 用 CSS 变量集中管理间距 */ :root { --page-padding: 16px; --section-gap: 24px; } @media (min-width: 768px) { :root { --page-padding: 32px; --section-gap: 40px; } } @media (min-width: 1024px) { :root { --page-padding: 48px; --section-gap: 56px; } } /* 全局使用变量,不用每个组件单独写媒体查询 */ .header { padding: 0 var(--page-padding); } .hero { padding: var(--section-gap) var(--page-padding); } .content { padding: var(--section-gap) var(--page-padding); } ``` - 响应式进阶内容 - 图片是响应式布局中最容易出问题的元素——要么溢出容器,要么被拉伸变形。 - max-width 防溢出 - ```css /* 所有项目都应该全局加上这条 */ img { max-width: 100%; /* 永远不超过容器宽度 */ height: auto; /* 保持原始比例 */ display: block; /* 去掉图片底部的空隙 */ } ``` - object-fit:控制图片填充方式 - 当图片容器有固定的宽高比时,object-fit 控制图片如何适应 - ```css /* 原图 800×400,容器 300×300 */ .img-cover { width: 300px; height: 300px; object-fit: cover; /* 填满容器,多余裁掉(保持比例)✅ 最常用 */ } .img-contain { width: 300px; height: 300px; object-fit: contain; /* 完整显示,可能留白(保持比例) */ } .img-fill { width: 300px; height: 300px; object-fit: fill; /* 拉伸填满(变形)❌ 默认值 */ } .img-none { width: 300px; height: 300px; object-fit: none; /* 不缩放,只显示原图中心部分 */ } ``` - 配合 object-position 控制裁切焦点: ```css .avatar { width: 100px; height: 100px; border-radius: 50%; object-fit: cover; object-position: center top; /* 头像聚焦在上半部分 */ } /* 常用值 */ object-position: center; /* 默认:居中裁切 */ object-position: top; /* 从顶部裁切 */ object-position: left top; /* 从左上角裁切 */ object-position: 20% 30%; /* 自定义焦点 */ ``` - aspect-ratio:固定宽高比 ```css /* 不用 padding hack 了,直接声明宽高比 */ .card-cover { width: 100%; aspect-ratio: 16 / 9; /* 16:9 视频比例 */ object-fit: cover; } .avatar { width: 80px; aspect-ratio: 1; /* 正方形 = 1/1 */ border-radius: 50%; object-fit: cover; } .photo-portrait { aspect-ratio: 3 / 4; /* 竖版照片 */ } /* 配合 Grid 让所有卡片封面等高 */ .article-grid .card-cover { aspect-ratio: 16 / 10; object-fit: cover; } ``` - 响应式字体 - vw 单位:随视口缩放 - ```css /* vw = viewport width 的百分比 */ /* 1vw = 视口宽度的 1% */ h1 { font-size: 5vw; /* 手机 375px → 18.75px */ /* 平板 768px → 38.4px */ /* 桌面 1440px → 72px */ } ``` - 问题很明显:手机上太小,桌面上太大,没有上下限。 - clamp() 有上下限的响应式值 - clamp(最小值, 首选值, 最大值) 完美解决了这个问题 - ```css h1 { font-size: clamp(1.75rem, 4vw, 3rem); /* 最小 1.75rem (28px) 首选 4vw(随视口变化) 最大 3rem (48px) 三者取中间值 */ } ``` - 常用 clamp 配方 ```css /* 标题 */ h1 { font-size: clamp(1.75rem, 4vw, 3rem); } h2 { font-size: clamp(1.25rem, 3vw, 2rem); } h3 { font-size: clamp(1.1rem, 2.5vw, 1.5rem); } /* 正文 */ body { font-size: clamp(0.875rem, 1.5vw, 1.125rem); } /* 间距也可以用 clamp */ .section { padding: clamp(24px, 5vw, 64px) clamp(16px, 4vw, 48px); } /* 容器最大宽度 */ .container { width: clamp(320px, 90vw, 1200px); margin: 0 auto; } ``` - 用了 clamp 后,很多字号和间距就不需要写媒体查询了——它自动在范围内平滑缩放。 - 其他响应式单位 - ```css /* vh = viewport height */ .hero { min-height: 100vh; } /* 占满整个视口高度 */ /* svh / dvh = 小/动态视口高度(解决手机地址栏问题) */ .hero { min-height: 100dvh; } /* 推荐用 dvh */ /* vmin / vmax = 视口较小/较大边 */ .square { width: 50vmin; height: 50vmin; } /* 始终是正方形 */ ``` - 手机浏览器的地址栏会收起/展开,导致 100vh 的高度有时候会偏大。dvh(dynamic viewport height)会自动适应地址栏的变化 - ```css .full-screen { min-height: 100vh; /* 兜底 */ min-height: 100dvh; /* 现代浏览器用这个 */ } ``` - 容器查询 - 媒体查询根据视口宽度变化,但一个组件可能在不同位置出现 - 主内容区很宽,侧边栏很窄 - 容器查询根据父容器的宽度变化,让组件真正做到"放在哪里就适配哪里" - ```css /* 第一步:声明容器 */ .card-wrapper { container-type: inline-size; /* 启用容器查询 */ container-name: card; /* 可选:给容器命名 */ } /* 第二步:根据容器宽度写样式 */ @container card (min-width: 400px) { .card { display: flex; /* 容器宽时横排 */ gap: 16px; } .card-cover { flex: 0 0 200px; } } @container card (max-width: 399px) { .card { display: block; /* 容器窄时竖排 */ } .card-cover { width: 100%; aspect-ratio: 16 / 9; } } ``` - 容器查询单位 ```css .card-wrapper { container-type: inline-size; } .card-title { font-size: clamp(14px, 3cqi, 20px); /* cqi = 容器 inline-size 的 1% */ /* 类似于 vw,但基于容器宽度而不是视口 */ } ``` - 常用容器单位:cqi(容器宽度%)、cqb(容器高度%)、cqmin(较小边%)。 - 不需要媒体查询的响应式技巧 - 很多响应式效果可以用纯 CSS 实现,不写一行 @media - ```css /* 技巧 1:Grid auto-fit + minmax(Day 13 学过) */ .grid { grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); /* 自动调整列数,完全不需要媒体查询 */ } /* 技巧 2:clamp 响应式字号 */ h1 { font-size: clamp(1.5rem, 4vw, 3rem); } /* 技巧 3:clamp 响应式间距 */ .section { padding: clamp(24px, 5vw, 64px); } /* 技巧 4:flex-wrap 自动换行 */ .button-group { display: flex; flex-wrap: wrap; gap: 12px; } /* 技巧 5:min() / max() 控制宽度 */ .container { width: min(90vw, 1200px); /* 取小值:窄屏时 90vw,宽屏时 1200px */ margin: 0 auto; } .card { width: max(280px, 30%); /* 取大值:至少 280px */ } /* 技巧 6:auto-fit 让不够时换行 */ .stats { display: flex; flex-wrap: wrap; gap: 16px; } .stat-item { flex: 1 1 150px; /* 至少 150px,不够就换行 */ } ``` - translation 过渡动画 - translation 基础语法 - ```css /* 没有 transition:悬停时背景色瞬间变化 */ .btn { background: #2563eb; } .btn:hover { background: #1d4ed8; } /* 加上 transition:背景色用 0.3 秒平滑过渡 */ .btn { background: #2563eb; transition: background 0.3s; } .btn:hover { background: #1d4ed8; } ``` - 完整语法 ```css transition: 属性 时长 缓动函数 延迟; /* 示例 */ transition: background 0.3s ease 0s; transition: transform 0.2s ease-in-out; transition: opacity 0.5s linear 0.1s; ``` - 分开写的写法 ```css .box { transition-property: background; /* 哪个属性要过渡 */ transition-duration: 0.3s; /* 过渡多长时间 */ transition-timing-function: ease; /* 缓动曲线 */ transition-delay: 0s; /* 延迟多久开始 */ } ``` - transition-property 过渡哪些属性 - ```css /* 指定单个属性 */ transition: background 0.3s; /* 指定多个属性 */ transition: background 0.3s, color 0.3s, transform 0.2s; /* 所有可过渡属性一起 */ transition: all 0.3s; ``` - all 方便但有两个问题 ```css /* ❌ all 的隐患 */ .card { transition: all 0.3s; /* 问题 1:可能过渡你不想过渡的属性(比如 width、height 导致布局抖动)*/ /* 问题 2:性能比指定属性差 */ } /* ✅ 推荐:明确列出要过渡的属性 */ .card { transition: transform 0.3s, box-shadow 0.3s; } ``` - 哪些属性可以过渡 ```css /* 能过渡的属性必须有中间值(浏览器知道怎么从 A 插值到 B)*/ /* ✅ 可以过渡 */ color, background-color /* 颜色有中间值 */ opacity /* 0 到 1 之间连续 */ transform /* 位移、缩放、旋转都有中间值 */ width, height, padding, margin /* 数值类都可以 */ border-color, border-radius box-shadow font-size, letter-spacing, line-height /* ❌ 不能过渡 */ display /* none → block 没有中间状态 */ font-family /* 字体之间没法渐变 */ background-image /* 图片之间不能插值 */ position /* static → absolute 没有中间值 */ ``` - display: none 不能过渡是一个经典痛点。要做显隐动画,用 opacity + visibility 代替 ```css /* 用 opacity + visibility 替代 display 的显隐过渡 */ .dropdown { opacity: 0; visibility: hidden; transform: translateY(-8px); transition: opacity 0.2s, visibility 0.2s, transform 0.2s; } .trigger:hover .dropdown { opacity: 1; visibility: visible; transform: translateY(0); } ``` - transition-duration 时长 - ```css transition-duration: 0.3s; /* 300 毫秒 */ transition-duration: 300ms; /* 同上,用 ms 写法 */ transition-duration: 0.15s; /* 150 毫秒,很快 */ transition-duration: 0.5s; /* 500 毫秒,中速 */ transition-duration: 1s; /* 1 秒,比较慢 */ ``` - 不同场景的推荐时长 ```css /* 微交互(按钮、链接、图标)→ 快速 */ .btn { transition: background 0.15s, transform 0.15s; } /* 悬停效果(卡片、图片)→ 中速 */ .card { transition: transform 0.3s, box-shadow 0.3s; } /* 展开/收起(下拉菜单、折叠面板)→ 稍慢 */ .panel { transition: max-height 0.35s, opacity 0.35s; } /* 页面级动画(模态框、页面切换)→ 慢 */ .modal { transition: opacity 0.4s, transform 0.4s; } ``` - 越小的元素过渡越快,越大的元素过渡可以稍慢。超过 0.5s 用户会觉得卡顿。 - transition-timing-function 缓动函数(重点) - 缓动函数决定过渡的"节奏感"——匀速、先快后慢、先慢后快 - ```css /* 预设曲线 */ transition-timing-function: linear; /* 匀速 */ transition-timing-function: ease; /* 默认:慢→快→慢,最常用 */ transition-timing-function: ease-in; /* 慢→快(加速) */ transition-timing-function: ease-out; /* 快→慢(减速)*/ transition-timing-function: ease-in-out; /* 慢→快→慢,比 ease 对称 */ ``` - 适合的场景 ```css /* ease-out(减速)→ 元素进入画面 */ .card-enter { transition: transform 0.3s ease-out; } /* ease-in(加速)→ 元素离开画面 */ .card-leave { transition: transform 0.3s ease-in; } /* ease-in-out → 来回移动、展开收起 */ .accordion { transition: max-height 0.35s ease-in-out; } /* linear → 颜色渐变、进度条 */ .progress-bar { transition: width 0.5s linear; } ``` - cubic-bezier 自定义曲线 - 所有预设曲线都是 cubic-bezier 的简写 ```css /* 预设值的真身 */ ease: cubic-bezier(0.25, 0.1, 0.25, 1.0) ease-in: cubic-bezier(0.42, 0, 1.0, 1.0) ease-out: cubic-bezier(0, 0, 0.58, 1.0) ease-in-out: cubic-bezier(0.42, 0, 0.58, 1.0) /* 自定义:弹性回弹效果 */ .bounce { transition: transform 0.4s cubic-bezier(0.34, 1.56, 0.64, 1); /* y 值超过 1 就会产生"过冲"回弹效果 */ } ``` - 推荐网站 cubic-bezier.com 可以预览和对比各种曲线 - transition-delay 延迟 - ```css /* 鼠标悬停 0.1s 后才开始变化 */ .tooltip { transition: opacity 0.3s ease 0.1s; } /* 多个属性不同延迟 → 顺序动画 */ .card:hover .card-title { transform: translateY(0); transition: transform 0.3s ease 0s; /* 标题先动 */ } .card:hover .card-desc { transform: translateY(0); transition: transform 0.3s ease 0.1s; /* 描述延迟 0.1s */ } .card:hover .card-btn { transform: translateY(0); transition: transform 0.3s ease 0.2s; /* 按钮延迟 0.2s */ } ``` - transform 变化 - 2D 变化基础 - transform 不会影响文档流——元素在页面中的原始位置仍然保留,只是视觉上发生了变化 - translate 位移 - ```css /* 水平和垂直移动 */ transform: translateX(50px); /* 右移 50px */ transform: translateY(-20px); /* 上移 20px */ transform: translate(50px, -20px); /* 合写:右移 50 + 上移 20 */ /* 百分比相对于元素自身尺寸 */ transform: translateX(100%); /* 右移自身宽度的距离 */ transform: translateY(-50%); /* 上移自身高度的一半 */ ``` - translate 最经典的用途——绝对定位居中 ```css .centered { position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); /* top/left 把左上角移到容器中心 */ /* translate 再把元素自身偏移回去 */ } ``` - scale 缩放 - ```css transform: scale(1.5); /* 等比放大 1.5 倍 */ transform: scale(0.8); /* 等比缩小到 80% */ transform: scale(1); /* 原始大小 */ transform: scale(0); /* 缩小到消失 */ transform: scaleX(1.5); /* 只水平拉宽 */ transform: scaleY(0.5); /* 只垂直压扁 */ transform: scale(1.5, 0.8); /* 水平 1.5 倍,垂直 0.8 倍 */ ``` - 常见用法 ```css /* 按钮点击缩小反馈 */ .btn:active { transform: scale(0.96); } /* 图片悬停放大 */ .img-wrapper { overflow: hidden; } .img-wrapper img { transition: transform 0.4s ease; } .img-wrapper:hover img { transform: scale(1.1); } /* 从 0 到 1 的弹出效果 */ .popup { transform: scale(0); transition: transform 0.3s ease; } .popup.active { transform: scale(1); } ``` - rotate 旋转 - ```css transform: rotate(45deg); /* 顺时针旋转 45 度 */ transform: rotate(-90deg); /* 逆时针旋转 90 度 */ transform: rotate(180deg); /* 翻转 */ transform: rotate(360deg); /* 转一圈(用于 loading 动画) */ transform: rotate(0.5turn); /* 半圈 = 180deg */ ``` - ```css transform: rotate(45deg); /* 顺时针旋转 45 度 */ transform: rotate(-90deg); /* 逆时针旋转 90 度 */ transform: rotate(180deg); /* 翻转 */ transform: rotate(360deg); /* 转一圈(用于 loading 动画) */ transform: rotate(0.5turn); /* 半圈 = 180deg */ ``` - ```css transform: rotate(45deg); /* 顺时针旋转 45 度 */ transform: rotate(-90deg); /* 逆时针旋转 90 度 */ transform: rotate(180deg); /* 翻转 */ transform: rotate(360deg); /* 转一圈(用于 loading 动画) */ transform: rotate(0.5turn); /* 半圈 = 180deg */ ``` - ```css /* 箭头图标旋转表示展开/收起 */ .arrow { display: inline-block; transition: transform 0.3s ease; } .expanded .arrow { transform: rotate(180deg); } /* 悬停微旋转 */ .icon:hover { transform: rotate(15deg); } ``` - skew 倾斜 - ```css transform: skewX(10deg); /* 水平方向倾斜 */ transform: skewY(5deg); /* 垂直方向倾斜 */ transform: skew(10deg, 5deg); /* 合写 */ ``` - ```css /* 平行四边形按钮 */ .skew-btn { transform: skewX(-10deg); } /* 文字反向 skew 保持正常 */ .skew-btn span { display: inline-block; transform: skewX(10deg); } /* 斜切背景装饰 */ .section-divider { height: 80px; background: hsl(220, 85%, 55%); transform: skewY(-3deg); } ``` - 组合变化 ```css /* 先旋转 45 度,再右移 100px */ transform: translateX(100px) rotate(45deg); /* 实际执行顺序:先 rotate → 再 translate */ /* 注意:旋转后坐标轴也跟着转了,所以 translate 方向会变 */ /* 先移动再旋转 vs 先旋转再移动,效果完全不同! */ ``` - ```css transform: translateX(100px) rotate(45deg); 原始 → 先旋转 45° → 再沿旋转后的 X 轴移动 100px 结果:元素在右上方 transform: rotate(45deg) translateX(100px); 原始 → 先沿原始 X 轴移动 100px → 再旋转 45° 结果:元素在正右方但旋转了 ``` - 先写 translate,再写其他变化 ```css /* ✅ 推荐顺序:translate → scale → rotate */ .card:hover { transform: translateY(-8px) scale(1.02) rotate(1deg); } ``` - translate-origin 变换原点 - 默认情况下所有变换都以元素中心为原点。transform-origin 可以改变这个原点: - ```css /* 默认:中心 */ transform-origin: center; /* 50% 50% */ /* 关键字 */ transform-origin: top left; /* 左上角 */ transform-origin: bottom right; /* 右下角 */ transform-origin: center top; /* 顶部中间 */ /* 百分比 */ transform-origin: 0% 0%; /* 左上角 */ transform-origin: 100% 100%; /* 右下角 */ /* 像素值 */ transform-origin: 20px 30px; ``` - 改变原点最直观的效果在旋转和缩放上: - ```css /* 旋转:绕中心转 vs 绕左上角转 */ .spin-center { transform-origin: center; transform: rotate(45deg); } .spin-corner { transform-origin: top left; transform: rotate(45deg); } /* spin-center: spin-corner: ╱╲ ┌╲ ╱ ╲ │ ╲ ╱ ◉ ╲ ←原点在中心 │ ╲ ← 原点在左上角 ╲ ╱ │ ╱ ╲╱ │╱ */ ``` - 实用场景 ```css /* 下拉菜单从顶部展开 */ .dropdown { transform-origin: top center; transform: scaleY(0); /* 从顶部收起 */ transition: transform 0.2s ease; } .trigger:hover .dropdown { transform: scaleY(1); /* 从顶部展开 */ } /* 导航下划线从左侧展开 */ .nav-link::after { transform-origin: left; transform: scaleX(0); transition: transform 0.3s ease; } .nav-link:hover::after { transform: scaleX(1); } /* 图片从左上角放大 */ .zoom-corner { transform-origin: top left; } .zoom-corner:hover { transform: scale(1.5); } ``` - 3D 变化 - ```css /* 给父元素设透视 */ .scene { perspective: 800px; /* 观察者距离屏幕 800px */ /* 值越小 → 透视感越强(像凑近看)*/ /* 值越大 → 透视感越弱(像远处看)*/ } ``` - @keyframes 关键帧动画 - 基础语法 - 动画分两步:先用 @keyframes 定义动画,再用 animation 应用到元素上 - ```css /* 第一步:定义关键帧 */ @keyframes fadeIn { from { opacity: 0; /* 起始状态 */ } to { opacity: 1; /* 结束状态 */ } } /* 第二步:应用动画 */ .element { animation: fadeIn 0.5s ease; } ``` - 用百分比定义关键帧 - ```css @keyframes bounce { 0% { transform: translateY(0); } 30% { transform: translateY(-30px); } 50% { transform: translateY(0); } 70% { transform: translateY(-15px); } 100% { transform: translateY(0); } } .ball { animation: bounce 1s ease; } ``` - animation 属性详解 - animation 的完整简写 - ```css animation: 名称 时长 缓动 延迟 次数 方向 填充 播放状态; /* 示例 */ animation: fadeIn 0.5s ease 0s 1 normal forwards running; ``` - animation-name 动画名称 - ```css animation-name: fadeIn; /* 对应 @keyframes fadeIn */ animation-name: none; /* 不应用任何动画 */ ``` - animation-duration 持续时长 - ```css animation-duration: 0.5s; animation-duration: 300ms; animation-duration: 2s; ``` - animation-timing-function 缓动 - ```css animation-timing-function: ease; animation-timing-function: linear; animation-timing-function: ease-in-out; animation-timing-function: cubic-bezier(0.34, 1.56, 0.64, 1); /* steps() —— 逐帧动画,不做插值 */ animation-timing-function: steps(4); /* 分 4 步跳变 */ animation-timing-function: steps(1, end); /* 打字机效果 */ ``` - ```css /* 精灵图逐帧动画 */ .sprite { width: 64px; height: 64px; background: url('sprite.png'); animation: walk 0.6s steps(8) infinite; } @keyframes walk { to { background-position: -512px 0; } /* 8 帧 × 64px */ } /* 打字机效果 */ .typing { width: 0; overflow: hidden; white-space: nowrap; border-right: 2px solid; animation: typing 2s steps(15) forwards, /* 15 个字逐个显示 */ blink 0.8s step-end infinite; /* 光标闪烁 */ } @keyframes typing { to { width: 15ch; } /* ch = 一个字符的宽度 */ } @keyframes blink { 50% { border-color: transparent; } } ``` - animation-delay 延迟 - ```css animation-delay: 0.3s; /* 等 0.3 秒后开始 */ animation-delay: -0.5s; /* 负值:动画从 0.5s 处开始播放(跳过开头) */ ``` - ```css /* loading 动画:3 个点交错起步 */ .dot:nth-child(1) { animation-delay: 0s; } .dot:nth-child(2) { animation-delay: 0.15s; } .dot:nth-child(3) { animation-delay: 0.3s; } ``` - animation-iteration-count 播放次数 - ```css animation-iteration-count: 1; /* 默认:播放 1 次 */ animation-iteration-count: 3; /* 播放 3 次 */ animation-iteration-count: infinite; /* 无限循环 */ ``` - animation-direction 方向 - ```css animation-direction: normal; /* 默认:0% → 100% */ animation-direction: reverse; /* 反向:100% → 0% */ animation-direction: alternate; /* 交替:去 → 回 → 去 → 回 */ animation-direction: alternate-reverse; /* 反向交替:回 → 去 → 回 → 去 */ ``` - animation-fill-mode 填充模式 (重要) - ```css animation-fill-mode: none; /* 默认:动画结束后回到初始状态 */ animation-fill-mode: forwards; /* 保持在最后一帧 */ animation-fill-mode: backwards; /* 延迟期间就应用第一帧 */ animation-fill-mode: both; /* forwards + backwards */ ``` - ```css @keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } } none: 延迟中透明度=1 → 动画0→1 → 结束后回到1(跳了一下再回来) forwards: 延迟中透明度=1 → 动画0→1 → 结束后保持1 ✅ backwards: 延迟中透明度=0 → 动画0→1 → 结束后回到1 both: 延迟中透明度=0 → 动画0→1 → 结束后保持1 ✅✅ 最完整 ``` - animation-play-state 播放状态 - ```css animation-play-state: running; /* 默认:播放中 */ animation-play-state: paused; /* 暂停 */ /* 悬停暂停动画 */ .marquee:hover { animation-play-state: paused; } ``` - animation 简写 - 记住简写的顺序规则:第一个时间值是 duration,第二个是 delay - ```css /* animation: name duration timing delay count direction fill state */ animation: fadeIn 0.5s ease 0s 1 normal forwards running; /* 常用的简写 */ animation: fadeIn 0.5s ease forwards; /* 渐入并保持 */ animation: spin 1s linear infinite; /* 无限匀速旋转 */ animation: bounce 0.6s ease-in-out 3; /* 弹跳 3 次 */ animation: float 3s ease-in-out infinite alternate; /* 无限来回浮动 */ ``` - 多动画同时应用 - ```css .element { animation: fadeIn 0.5s ease forwards, slideUp 0.5s ease forwards; } @keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } } @keyframes slideUp { from { transform: translateY(30px); } to { transform: translateY(0); } } ``` 工程化与实战 - 标题

07

成长 · 业务

17 posts 查看全部 →

思维导图新功能测试

- 项目规划 - 前端 - 示例代码 ```js let a = 3 console.log(a) ``` - <h1 style="color: blue"> 你好 </h1> - ![](https://an-img.oss-cn-hangzhou.aliyuncs.com/2026/03/30/20260330150145688.png,148,38) - java 代码 ```java public class TimestampWatermarkDemo { public static class Event { public String user; public long eventTime; public int value; public Event(){} public Event(String user, long eventTime, int value) { this.user = user; this.eventTime = eventTime; this.value = value; } public long getEventTime() { return eventTime; } @Override public String toString() { return "Event{" + "user='" + user + '\'' + ", eventTime=" + eventTime + ", value=" + value + '}'; } } public static void main(String[] args) throws Exception { StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment(); DataStream<Event> source = env.fromElements( new Event("alice", 1000L, 1), new Event("alice", 4000L, 1), new Event("alice", 3000L, 1), new Event("bob", 8000L, 1), new Event("alice", 12000L, 1) ); WatermarkStrategy<Event> wmStrategy = WatermarkStrategy.<Event>forBoundedOutOfOrderness(Duration.ofSeconds(5)) .withTimestampAssigner((event, recordTimestamp) -> event.getEventTime()); DataStream<Event> withWaterMark = source.assignTimestampsAndWatermarks(wmStrategy); withWaterMark.print(); env.execute("timestamp-water-mark-demo"); } } ``` - 1 let a = 3

晋升与影响力

1. 学习主题 我要学习的内容: 各个职级的要求,如何晋升以及输出影响力。 …

业务开发的技术成长 15 业务编程如何进行通用性抽象

业务开发的技术成长-15-业务编程如何进行通用性抽象

业务开发的技术成长 14 源码分享以及如何通过本项目学习

源码分享以及如何通过本项目学习

08

数据库

11 posts 查看全部 →

HBase 复习

基础认知 - 基础认知 - HBase 和 MySQL、Redis、Elasticsearch、Hive 的区别 - HBase - 一个建立在 Hadoop/HDFS 上的、支持联机、实时读写的分布式数据库,适合承载超大表 - 面向宽表/稀疏表 - 以 RowKey 为核心访问路径 - 强项是海量数据下的主键访问和范围扫描 - MySQL - 关系型数据库 - 强 SQL 能力 - 强事务 - 适合 OLTP 业务系统 - Redis - 内存型数据存储 - 超低延迟 - 丰富数据结构 - 更像高性能数据结构引擎/缓存平台 - ElasticSerach - 分布式搜索引擎 - 文档模型 - 擅长全文检索、相关性排序、聚合分析 - 不是传统事务数据库 - Hive - 数据仓库 - 面向离线分析 - 适合大规模批处理、报表、数仓 - 不适合在线高并发事务场景 - 列式/列族模型是什么 - 列族模型 - 看起来像一张表 - 但是每一行并不是必须拥有同样的列 - HBase 把列分成若干个 Column Family (列族) - 同一个列族里面的数据会被一起管理和物理存储 - 每一条具体的数据单元是一个 Cell,由行键、列族、列限定符、事件戳和值共同确定 - HBase 是一个按 RowKey 组织、按列族管理、按单元格存值、支持多版本的稀疏宽表结构 - 模型示例 - 层次结构 - Table -> Row -> Column Family -> Column Qualifier -> Cell(Value + TimeStamp) - 一张用户表 user_profile,定义了两个列族 info + stat - 一行数据 - RowKey = user_1001 - info:name = "Alice" - info:city = "Tokyo" - stat:login_cnt = 25 - info、stat 是列族 - name、city、login_cnt 是列限定符 - 完整列名是 family:qualifier - 每个值都带 timestamp,可以保留多个版本 - 列族、列、Cell - RowKey - 是一行数据的主键,也是 HBase 最核心的访问入口 - 查 HBase,本质是在查某个 RowKey,或者扫描一段 RowKey 的范围 - 决定这一行是谁 - 决定数据在字典序上的排序方式 - 决定大部分查询的性能上限 - Column Family - 是 HBase 中必须定义的逻辑分组 - 列族在创建表时就确定好 - HBase shell 和 API 文档都以先创建表,定义 family,再在 family 下写具体列的方式使用 HBase - 列族是一组经常一起出现,一起管理,一起存储策略配置的数据 - Column Qualifier(列限定符) - 列限定符就是列族下面具体的列名 - 列限定符不需要像 MySQL 字段在建表时全部固定列出来 - 列族通常固定,但是 qualifier 可以按需动态增加。 - HBase 适合列很多,而且不同记录拥有的列不一样的场景 - Cell - Cell 是最小存储单元 - 一个 Cell 由这些部分唯一定位 - row - family - qualifier - timestamp - value - TimeStamp/Version - HBase 天然支持多版本 - 同一个 row + family + qualifier,可以在不同 timestamp 下保存多个值 - Get/Scan API 也支持按照版本范围取数据 - HBase 的默认思维是:一个单元格可以有历史版本 - 为什么要有列族 - HBase 不只是逻辑上分组,还会影响物理管理和存储策略 - 列族可以把访问模式相近的数据放在一起 - 列族可以让不同数据有不同的存储策略 - 列族可以让稀疏表更自然 - 稀疏宽表 - 稀疏 - 不是每一行都有同样的列 - 举例 - 商品 A:颜色、尺寸、品牌 - 商品 B:品牌、功率、电压 - 商品 C:材质、长度、颜色、重量 - 如果用 MySQL,固定列会导致很多字段为空 - HBase 只保存实际存在的 family:qualifier->value 即可 - HBase 在列族下使用动态 qualifier - 宽表 - 列非常多,甚至 qualifier 的数量可以远大于传统关系表的字段数 - HBase 为什么是 Bigtable 风格数据库 - 什么是 Bigtable - Bigtable 是 Google 提出的一个分布式存储系统,用来存放结构化数据,典型特点 - 面向超大规模数据 - 稀疏,可扩展的表 - 以 row key 为核心组织数据 - 支持列族 - 支持多版本 - 支持按键范围扫描 - 以 RowKey 为中心,而不是以 SQL 为中心 - Bigtable 风格数据库核心访问方式 - 按 RowKey 精准查 - 按 RowKey 前缀或范围扫描 - 设计 schema 时优先围绕查询路径设计 row key - 设计数据库时,优先设计 RowKey 和访问路径,而不是优先设计复杂 SQL 数据模型与表设计 - 数据模型与表设计 - RowKey 设计原则 - 总纲 - RowKey 不是随便选一个主键,而是 HBase 里面最重要的访问索引、排序依据和分布依据。 - 每行只有一个被索引的值,就是 row key - 本质 - 数据如何定位 - 数据怎么按字典序排序 - 数据怎么在集群中分布,会不会热点 - RowKey 为什么重要 - 最快、最强、最自然的访问路径就是按照 RowKey 去查和按范围扫 - RowKey 设计不好 - 查询路径不顺 - scan 范围过大 - 热点集中 - 读写吞吐不均衡 - 很难靠 SQL 或者二级索引补救 - 核心原则 - 先按查询方式设计,不要按照字段含义设计 - 最常见的查询是什么 - 查询是点查还是范围查 - 查询维度谁排第一 - 结果取最近一条、最近 N 条还是一个时间段 - 让最常用的过滤维度出现在 RowKey 前部 - Row Key 前缀直接决定 - 哪些数据会排在一起 - 哪些数据可以用 prefix/range 高效扫描 - Row Key 要支持高频查询的自然范围扫描 - HBase 擅长的不是随意条件查询,而是连续键空间扫描 - 建议围绕 row key prefix 设计 schema - 例如查询设备时序数据 - 按设备查最近数据 - 按设备查某段时间数据 - device_id#reverse_ts - 避免单 key 递增直接写入,防止热点 - 不断递增的 rowKey 最新写入通常会不断打到键空间尾部,把写压力集中到少数 region - 在可扫描性和均匀分布之间做平衡 - 分桶:bucket(user_id)#user_id#reverse_ts - 时间类场景通常需要显式设计时间顺序 - rowKey 要短、稳定、可解析 - 常见设计模式 - 单主键模型 - user_id - 一行就是一个主键 - 点查为主 - 不需要时间维度 - 主维度 + 时间 - user_id#ts - 主维度 + 倒序时间 - user_id#reverse_ts - 桶 + 主维度 + 时间 - bucket(user_id)%16#user_id#reverse_ts - 数据集前缀 + 业务键 - U#1001 - 列族应该怎么拆 - 为什么列族拆分重要 - 列族会把同一族的一组列物理共置 - 列族带有独立的存储属性 - 每一行都拥有相同的列族集合 - 未写入的单元格不占用实际空间 - 列族拆分总原则 - 同查:经常一起读的字段,放在一起 - 同配:需要相同存储策略的字段,适合放在一起 - 同寿命:生命周期相近的字段,适合放在一起 - 同频率:更新频率相近的字段,适合放在一起 - 拆分口诀 - 基础资料一族、频繁指标一族、短期日志一族、打对象单独一族 - 版本机制 - 什么是版本 - 同一个字段可能保存多份历史值 - 每一份历史值靠 timestamp 区分 - HBase 官方文档把 Cell 展示为包含 row、column、timestamp 和 value - timestamp 和 version 的关系 - timestamp 是版本标识 - 在 HBase 里,每写入一个 Cell,都要有对应的 timestamp - timestamp 由系统自动生成,也可以由自己指定 - version 是字段保留的历史份数 - 按列族配置,说明最多留多少份历史数据 - TTL、压缩、Bloom Filter - TTL 是什么 - Time To Live,生存时间 - 是 cell contents 的生存时间,单位是 s - 是列族级的数据过期策略 - TTL 解决的问题 - 控制历史数据无限膨胀 - 降低存储成本 - 让冷热数据边界更为清晰 - 压缩是什么 - 压缩就是把 HBase 存到 StoreFile/HFile 里面的数据,用某种压缩算法压小 - 可以在 ColumnFamily 上启用压缩,且不需要重建表 - 用 CPU 换磁盘空间、换 I/O 带宽 - Bloom Filter 是什么 - 是一种概率型存在性判断结构 - 帮助你快速判断这个 StoreFile 里面大概率没有你想要的数据,就不去做无效读取了 - 热点问题 - 什么是热点 - HBase/Bigtable 这类系统的数据是按照 rowKey 的字段序排列的 - 相邻的 Key 也会落在相邻的 key range 上,这些 key range 被分配给特定的 Region/RegionServer 来服务 - 如果请求集中在某一个 RowKey/某一小段连续 RowKey/某个不断增长的最新 key 区间,负载就不会均匀摊开,造成热点 架构原理 - 架构原理 - HMaster 做什么 - HMaster 的定位 - HMaster 负责协调和管理,真正承担读写请求的是 RegionServer - HMaster 类似调度中心和元数据/运维控制器 - HMaster 的核心职责 - 管理整个集群的 Region 分配 - HBase 的表会被切分成很多 Region - 集群启动时给 Region 找归属的 RegionServer - RegionServer 宕机后重新分配它原来负责 Region - 负载均衡主动迁移 Region - 监控 RegionServer 的存活状态 - 处理 RegionServer 故障后的恢复 - 做负载均衡 - 处理 DDL 和 schema 管理 - 参与主备切换和集群高可用 - RegionServer 做什么 - RegionServer 的定位 - 是数据面 - 真正保存并服务 Region - 真正执行 Put/Get/Scan/Delete - 真正把数据从内存刷到 File - 真正做 compaction 和 split - RegionServer 的核心职责 - 持有并管理 Region - HMaster 决定 Region 给谁 - RegionServer 真正把 Region 跑起来 - 处理客户端读请求 - 处理客户端写请求 - 管理每个 Region 内部的 Store/MemStore/StoreFile - 运行后台维护线程:flush、compaction、WAL 滚动、split - 管理 BlockCache - 执行部分 Region 级维护操作 - Put 流程 - 客户端根据元数据找到 Region 所在的 Region Server - 请求发到该 Region Server - RegionServer 找到这个 Region 对应 family 的 Store - 先写 WAL - 在把数据写到 Store 里面的 MemStore - 后台达到条件后,MemStoreFlusher 把数据 flush 到 StoreFile - 之后 CompactSplitThread/MajorCompactionChecker 再持续维护这些文件 - Get/Scan 流程 - 客户端根据元数据找到 Region 所在的 Region Server - RegionServer 在对应 Region 的 Store 中查找 - 优先利用 BlockCache - 必要时再读 StoreFile/HFile - 利用索引和 BloomFilter 缩小无效读取 - 返回结果给客户端 - Region 是什么 - Region 定义 - Region 是一段连续的、按 RowKey 排序的行范围 - 表的层级结构 Table->Region->Store->MemStore/StoreFile->Block - 为什么要有 Region - 做分布式切片 - 支持扩展 - 支持负载均衡 - Region 结构 - 一个 Region 内部,每个 ColumFile 对应一个 Store,每个 Store 有自己的 MemStore 和若干 StoreFile - Region 如何分配 - 同一时刻,一个 Region 只由一个 RegionServer 提供服务 - 一个 RegionServer 可以持有很多 Region - HMaster 负责 Region 给谁、是否迁移、是否均衡 - RegionServer 负责真正持有 Region 并处理这个 Region 的读写 - Region 为什么需要切分 - 单个 Region 太大,迁移和恢复成本高 - 该 Region 所在 RegionServer 负载可能过重 - 并行度不够,一大段数据只能由一个 Region 服务 - 热点更集中,难以分散 - split 的目的,是把一个过大的 key range 拆分成两个更小的 key range,来提升可扩展性和分布性 - 什么时候会 split - 由 RegionSplitPolicy 决定 - 当 Region 增长到某个阈值/满足当前 split policy 的条件 - WAL 是什么 - 为什么需要 WAL - HBase 不是收到请求就立即写成 HFile - 写请求先进入内存里面的 MemStore - MemStore 是内存结构,掉电或进程崩溃会丢 - WAL 为还没落成 HFile 的持久化阶段兜底 - 写入请求里的 cells 会一直保留,直到成功持久化到 WAL 和 MemStore - MemStore 负责写入速度,WAL 负责稳定性 - 为什么叫 Write-Ahead - 先把将要发生的修改记录到日志中,再依赖后续流程把它整理进正式数据文件 - 这条写操作在正式长期存储结构整理完成之前 - 已经被写入到一个可恢复的日志里 - WAL 处在的位置/Put 操作链路 - 客户端把 Put 请求发送到目标 RegionServer - RegionServer 把这次修改追加到 WAL - 同时把数据写入目标 Store 的 MemStore - 之后后台线程再把 MemStore Flush 成 StoreFile/HFile - 最后通过 compaction 整理文件 - WAL 记录的内容 - WAL 记录的是这次写操作的增量编辑记录 - WAL 写入的内容加做 WALEdit,有 WAL.Entry、WALKey 类型来表示一条日志条目及其键 - MemStore、StoreFile/HFile、BlockCache - 概述 - MemStore:写入时的内存缓冲区 - StoreFile/HFile:Flush 之后落盘的正式数据文件 - BlockCache:读路径上的数据块缓存 - 在一个 Region 里,每个列族对应一个 Store,每个 Store 有自己的 MemStore 和若干 StoreFile - MemStore 是什么 - MemStore 是 Store 在内存中的缓冲区 - 客户端写入数据, RegionServer 会先把修改写入 WAL,并把数据存放在对应的 Store 的 MemStore - 后续达到条件后,再 flush 到磁盘形成 StoreFile - 主要解决写的快的问题,如果每次 Put/Delete 都直接改磁盘主文件,开销会很大 - 先写入内存,可以把随机小写聚合起来,再批量 flush 到磁盘 - MemStore 只是内存态,不是最终查询文件,内容会在后续 flush 后变为 StoreFile/HFile - StoreFile 是什么 - 是 Store 在磁盘上的数据文件 - MemStore 不会只 flush 一次,每次 flush 会生成新的 StoreFile - 当 BlockCache miss 且 MemStore 没有目标数据,RegionStore 会去 HFile/StoreFile 中查 - HFile 是什么 - 是底层文件格式,StoreFile 是 HBase 在 RegionServer/Store 这一层使用的数据文件抽象 - 一个 StoreFile 对应一个 HFile - BlockCache 是什么 - 是 HBase 读路径上的数据库缓存 - 缓存的是从 HFile 里读出来的 block - 解决的是读的快的问题 - Flush、Compaction、Split 的流程 - Flush 是什么 - 定义 - 把某个 Store 的 MemStore 内容写到磁盘,生成新的 StoreFile - 为什么必须 Flush - MemStore 在内存中,不能无限增长 - flush 后发生什么 - flush 完成后,这批数据会变成新的 StoreFile,被纳入该 Store 的文件集合。 - Flush 触发条件 - 内存压力触发 - 时间触发 - 运维/内部流程触发 - Compaction 是什么 - Compaction 是把一个 Store 多个 StoreFile 文件合并整理成更少的新文件 - Minor Compaction 和 Major Compaction 的区别 - Minor Compaction - 把若干个较小的 StoreFile 合并成更少的较大文件,但通常不是把该 Store 的所有文件一次性全部重写 - Major Compaction - 更彻底地重写该 Store 的文件集合 - Split 是什么 - Split 是把一个 Region 沿 RowKey 边界切成两个子 Region - Zookeeper 的作用 - 核心作用 - 主节点选举:决定谁是 active HMaster - 服务发现:让客户端知道该连谁 - 节点状态感知:知道哪些服务还活着 - 协调关键元数据入口 - 为什么 HBase 需要 ZooKeeper - HBase 把协调问题外包给 ZooKeeper,把自己更多精力放在存储和读写路径上 - Zookeeper 不做什么 - 不存储业务明细数据 - 不承担高吞吐读写 - 不等于 HMaster 实际操作 - 实操 - 启动单机 HBase - 单机模式是什么 - 单机模式是 HBase 最基础的步数形态 - 在 standalone 模式下,所有 HBase 守护进程都运行在一个 JVM 里 - Docker 启动 - ![](https://an-img.oss-cn-hangzhou.aliyuncs.com/2026/03/24/20260324134824394.png,252,116) - 进入容器进行最小验证命令 - ![](https://an-img.oss-cn-hangzhou.aliyuncs.com/2026/03/24/20260324135239051.png,625,612) - 用 Shell 建表、删表、put/get/scan - 进入 shell - hbase shell - 命令基本结构 - 表名:test - row key:row1 - 列:cf:a(列族:列限定符) - create 建表 - create 'test','cf' - 创建表 test,在表中预定义列族 cf - put 写入数据 - put 'test', 'row1', 'cf:a', 'value1' - 'test':表名 - 'row1':row key - 'cf:a':列 - 'value1':值 - 本质:往某个表的某一行、不同列写值 - scan 扫描数据 - scan 'test' - 按 RowKey 顺序把表里面的一批数据扫出来 - get 读一行数据 - get 'test', 'row1' - 按 Rowkey 精确读取某一行 - get 是点查,scan 是范围扫 - disable 和 drop - disable 的作用:先把表停用,把表从可服务状态切到不可服务状态 - drop 的作用:删除表 - 创建 namespace - 什么是 namespace - 如果创建表时未指定 namespace,则表存放在 default namespace 下 - create 'test','cf' 本质等价于 'default:test' - 为什么要有 namespace - 表分组管理 - 避免表名冲突 - 便于做配额/约束 - namespace 是 HBase 里按业务域组织表的基本单位 - hbase 存在哪些 namespace - default - 用户表默认所在 namespace - hbase - 系统 namespace,保留给 hbase 内部表 - 创建 namespace - create_namespace 'demo' - 查看 namespace - list_namespace - 在 namespace 下建表 - create 'demo:test', 'cf' - 写入和读取 - put 'demo:test', 'row1', 'cf:a', 'value1' - get 'demo:test', 'row1' - scan 'demo:test' - 删除 namespace - drop_namespace 'demo' - 设置版本数、TTL、压缩 - 列族级配置 - family 是物理和策略边界 - VERSIONS、TTL、COMPRESSION 这类配置,默认思维都是这个 family 怎么存,不是这一列怎么存 - alter 是什么命令 - alter 用来修改已有表的 schema 或表/列族相关配置 - VERSIONS - 控制什么 - 同一个 row+family+qualifier 最多保存多少个历史版本 - 怎么改 - alter 'test', NAME => 'cf', VERSIONS => 5 - TTL - 控制什么 - 这个列族里面的数据默认能活多久 - 怎么改 - alter 'test', NAME => 'cf', TTL => 2592000 - COMPRESSION - 控制什么 - 这个列族的 StoreFile/HFile 落盘时用什么压缩算法 - 怎么改 - alter 'test', NAME => 'cf', COMPRESSION => 'SNAPPY' - 验证修改 - describe 'test' - 查看 Region 分布 - web ui - localhost:16010 - ![](https://an-img.oss-cn-hangzhou.aliyuncs.com/2026/03/24/20260324154801170.png,600,400) - 简单 filter 查询 - 为什么要用 filter - 不想整表扫出来自己筛 - 只看某些 row key 前缀/只看某些列名前缀/只取前 N 行/只保留某个列值满足条件的行 - 让 RegionServer 在服务端先过滤(server-side filtering) - filter 的基本写法 - scan '表明', { FILTER => "过滤器表达式" } - 准备练习数据 - ![](https://an-img.oss-cn-hangzhou.aliyuncs.com/2026/03/24/20260324155538852.png,475,206) - 5 类简单 filter - PrefixFilter:按 row key 前缀过滤 - ![](https://an-img.oss-cn-hangzhou.aliyuncs.com/2026/03/24/20260324155813044.png,801,131) - PageFilter:限制返回多少行 - scan 'demo:filter_test', { FILTER => "PageFilter(2)" } - ![](https://an-img.oss-cn-hangzhou.aliyuncs.com/2026/03/24/20260324160025865.png,799,138) - ColumnPrefixFilter:按列限定符前缀过滤 - scan 'demo:filter_test', { FILTER => "ColumnPrefixFilter('na')" } - ![](https://an-img.oss-cn-hangzhou.aliyuncs.com/2026/03/24/20260324160109778.png,801,134) - SingleColumnValueFilter:按某一列的值筛行 - scan 'demo:filter_test', { FILTER => "SingleColumnValueFilter('cf', 'city', =, 'binary:Tokyo')" } - ![](https://an-img.oss-cn-hangzhou.aliyuncs.com/2026/03/24/20260324160214742.png,941,100) - ValueFilter:按值过滤列 - 如果只是做简单的 family:qualifier:value 等值判断,推荐限制后再配 ValueFilter,这样能避免扫描无关的 family/column - 组合使用 filter - "PrefixFilter ('Row') AND PageFilter (1) AND FirstKeyOnlyFilter ()" Java 客户端开发 - Java 客户端开发 - Connection/Admin/Table - 职责边界 - Connection:集群级入口 - 封装了到实际服务器和 ZooKeeper 的连接 - Admin:管理面接口 - Table:单表数据面接口 - 代码关系 - ![](https://an-img.oss-cn-hangzhou.aliyuncs.com/2026/03/24/20260324161657372.png,584,113) - 最小 Java 示例

Redis List 底层数据结构实验

实验元数据 (Meta Data) 实验编号/标题:Redis List 底层数据结构实验 …

Redis Set 底层数据结构实验

实验元数据 (Meta Data) 实验编号/标题:Redis set 底层数据结构实验 …

Redis String 底层数据结构实验

实验元数据 (Meta Data) 实验编号/标题:Redis String 底层数据结构实验 …

09

算法

83 posts 查看全部 →

面试冲刺

算法 - 算法 - 通用操作技巧 - 数组常用操作 - ![](https://an-img.oss-cn-hangzhou.aliyuncs.com/2026/02/28/20260228105324869.png,414,460) - 列表常见操作 - ![](https://an-img.oss-cn-hangzhou.aliyuncs.com/2026/02/28/20260228111309641.png,508,732) - Set 常见操作 - ![](https://an-img.oss-cn-hangzhou.aliyuncs.com/2026/02/28/20260228111651357.png,295,241) - 字符串常见操作 - ![](https://an-img.oss-cn-hangzhou.aliyuncs.com/2026/02/28/20260228112522793.png,348,758) - 哈希 - 基本语法 - ![](https://an-img.oss-cn-hangzhou.aliyuncs.com/2026/02/28/20260228103647800.png,490,622) - LeetCode 1. 两数之和 - 将每个数存入 Map,统计 target - nums[i] 是否在 Map 中 - LeetCode 49. 字母异位词分组 - 统计每个字符数量,用 char[26] 存储 - 将 char[26] 转换成字符串或 hashCode 放入 map - map 的 value 是分组的列表 - LeetCode 128. 最长连续序列 - 以最小值为起点,当前值 - 1 不在 map 中 - 查看 当前值 + 1 是否在 map 中 - 双指针 - 常用模板 - 两边向中间移动 - 问题 - 两数之和等于第三数 - 每次排除掉 l/r 指针的一边 - ![](https://an-img.oss-cn-hangzhou.aliyuncs.com/2026/02/28/20260228120355590.png,339,335) - ![](https://an-img.oss-cn-hangzhou.aliyuncs.com/2026/02/28/20260228120240384.png,469,400) - 两边向中间移动 - LeetCode 283 移动零 - slow 指针在左边,维护 0 的位置 - fast 指针在右边,找非零元素和 slow 交换 - LeetCode 11 盛最多水的容器 - l 指针和 r 指针每次向中间移动 - 比较 slow/fast 大小,将小的往中间移动 - LeetCode 15 三数之和 - 排序 - 固定左边界 left,剪枝 > 0 - l 和 r 在左边界两边往中间移动,找到目标值 - 滑动窗口 - 常用模板 - l,r 统一向一侧,每次增加 r - 增加一次 r,如果不满足条件,则不断移动 l - ![](https://an-img.oss-cn-hangzhou.aliyuncs.com/2026/02/28/20260228135319330.png,418,277) - LeetCode 3 无重复字符的最长子串 - 每次向右移动一次 r - 如果增加的 r 导致出现了重复字符,不断移动 l 直到符合条件 - 将满足条件的长度和答案取最大值 - LeetCode 438 找到字符串中所有的字母异位词 - 维护 diff 差值 - 固定窗口向右移动 l 和 r - 每次移动更新 diff 值,diff 值根据 cnt 统计得出 - in 和 out 维护进入窗口和出窗口的值 - LeetCode 76 最小覆盖子串 - 同 LeetCode 438 滑动窗口维护 diff 值 - 不一样的是随着 l 的更新,需要不停更新最终答案 - 前缀和/单调队列 - 常用模板 - 队列 - ![](https://an-img.oss-cn-hangzhou.aliyuncs.com/2026/02/28/20260228143049720.png,328,242) - 单调队列模板 - ![](https://an-img.oss-cn-hangzhou.aliyuncs.com/2026/02/28/20260228142727925.png,500,395) - LeetCode 560 和为 K 的子数组 - 用 Map 维护前缀和 - 连续序列的和基本都是前缀和问题 - LeetCode 239 滑动窗口最大值 - 维护一个单调递减的单调队列 - 将左边过期的弹出队列 - 关键操作:过期、维护单调性、入队 - LeetCode 53 最大子数组和 - 维护前缀和即可 - 考虑前缀是否从当前元素开始 - 普通数组 - LeetCode 56 合并区间 - 将数组按第一个元素从小到大排序 - 每次更新右边界,同时左边界与之前的右边界比较 - LeetCode 189 轮转数组 - 轮转数取模并交换 - 轮转数取模并完成三次反转 - LeetCode 283 除了自身以外数组的乘积 - 维护前缀乘积和后缀乘积即可 - LeetCode 41 缺失的第一个正数 - 每次将元素交换到他原本的位置上 - 矩阵 - LeetCode 73 矩阵置0 - 复用第一行和第一列,将要置为 0 的行列记录在这里 - LeetCode 54 螺旋矩阵 - 按圈数模拟,维护 left、right、top、bottom - 原地修改已访问标签 - LeetCode 48 旋转图像 - 将旋转转换为两次折叠(纵轴 + 斜折叠) - LeetCode 240 搜索二维矩阵 - 从右上角搜索,比值大就往下,比值小就往左 - 链表 - 常用模板 - 伪头节点 dummy - 反转链表 - ![](https://an-img.oss-cn-hangzhou.aliyuncs.com/2026/02/28/20260228160308314.png,308,160) - LeetCode 160 相交链表 - 走到头时,回到另外一个链表的头结点 - LeetCode 206 反转链表 - 反转链表模板,核心是记录前一个链表节点 - LeetCode 234 回文链表 - 快慢指针中点断开 - 反转后面一段 - 进行比较 - LeetCode 141 环形链表 - 快慢指针 - 有交点说明有环 - LeetCode 142 环形链表 2 - 入环的这一段距离是 m - 走的步数是 k - 从环到交点的记录是 n - 环长是 c - k = m + n - 2 * k = m + n + t * c - 2m + 2n = m + n + t * c - n = t * c - m - c - n = m + (1 - t) c - 从相遇点,再走 m 步就好了,也就是从把一个指针放到起点,二者同时开始跑 - LeetCode 21 合并两个有序链表 - 给出新头节点,依次拼接即可 - LeetCode 2 两数相加 - 维护进位和当前位即可 - LeetCode 19 删除链表的倒数第 N 个节点 - 快慢指针,快指针先走 k 个 - LeetCode 24 两两交换链表中的节点 - 维护 pA pB 两个指针,分别指向第一个值和第二个值 - 注意使用伪头节点 dummy - LeetCode 25 K 个一组翻转链表 - 维护 pre、groupHead、tail - 将 groupHead 和 tail 中间的链表反转 - LeetCode 138 随机链表的复制 - 维护原链表节点到新链表节点的映射即可 - LeetCode 148 排序链表 - 归并排序,每次排序一段 - 先拆中点 - 然后 merge - LeetCode 23 合并 K 个升序链表 - 归并排序思路 - 将 K 个升序链表的排序转换为两个升序链表的排序 - LeetCode 146 LRU 缓存 - 维护一个链表,代表时间 - 维护一个 map,映射到链表的节点 - 二叉树 - 常用模板 - 层序遍历 - ![](https://an-img.oss-cn-hangzhou.aliyuncs.com/2026/02/28/20260228165114598.png,419,315) - LeetCode 94 二叉树的中序遍历 - 递归,迭代都要会写 - LeetCode 104 二叉树的最大深度 - dfs 一次,更新答案 - LeetCode 226 翻转二叉树 - 递归,交换左右子树 - LeetCode 101 对称二叉树 - 递归判断左子树与右子树对称 - LeetCode 543 二叉树的直径 - 求左右子树的最大深度相加即可 - LeetCode 102 二叉树的层序遍历 - 层序遍历模板题 - LeetCode 108 将有序数组转换为二叉搜索树 - 找中点 - 两边递归构建二叉搜索树 - LeetCode 98 验证二叉搜索树 - 中序遍历结果有序即可 - 确定左右子树上线界递归 - LeetCode 230 二叉搜索数中第 K 小的元素 - 中序遍历 - LeetCode 199 二叉树的右视图 - 层序遍历 - dfs 后续遍历,输出每层的第一个被遍历到的节点 - LeetCode 114 二叉树展开为链表(hard) - 不停将右子树接到左子树上 - LeetCode 105 从前序与中序遍历序列构造二叉树 - 由前序遍历找根节点 - 中序遍历找左右子树 - 可以通过 map 直接定位到中序遍历中根节点的位置 - LeetCode 437 路径总和 3 - 维护前缀和即可 - LeetCode 236 二叉树的最近公共祖先 - 递归左右子树,如果发现两课子树都包含 p/q 那么就是当前节点 - 如果不横跨,就是左右子树非空节点 - LeetCode 124 二叉树中的最大路径和 - dfs 左右两个子树,取最大值 八股 数据库 - 数据库 - 基础认识 - InnoDB 和 MyISAM 的区别是什么? - InnoDB 支持事务,MyISAM 不支持事务,所以像转账、订单这类需要回滚和一致性保证的业务,一般只能用 InnoDB - InnoDB 支持行级锁,MyISAM 主要是表级锁。因此在高并发写入场景下,InnoDB 的并发能力更强,而 MyISAM 更容易因为表锁导致阻塞 - InnoDB 支持崩溃恢复,MyISAM 恢复能力较弱。InnoDB 通过 redo log、undo log 等机制保证数据更安全,所以更适合线上核心业务 - 它们的索引组织方式也不同。InnoDB 是聚簇索引,主键索引叶子节点存整行数据;MyISAM 的索引和数据分开存储,索引叶子节点保存的是数据地址 - int(11) 中的 11 表示什么? - int(11) 中的 11 表示显示宽度,不表示存储长度 - INT 在 MySQL 中固定是 4 字节,取值范围由类型本身决定,和这个 11 没关系 - 现在已经不重要,而且相关能力已经被官方弃用 - MySQL 的逻辑架构是什么 - 连接层:负责连接管理、认证和权限校验 - SQL 层/Server 层:负责解析 SQL、优化执行计划并调用执行器 - 一条 SQL 进来之后,通常会先经过 Parser 做词法和语法解析,生成语法树;然后由 Optimizer 选择执行计划,比如决定走哪个索引、表连接顺序是什么;最后由 Executor 调用存储引擎接口去真正读写数据 - 存储引擎层:通过统一接口访问不同引擎,比如 InnoDB - 存储层:负责真正的数据文件、索引文件和日志落盘 - char 和 varchar 的区别是什么? - char 是定长字符串,varchar 是变长字符串 - char(n) 不管实际存多少字符,都会按固定长度 n 来存;而 varchar(n) 只会按实际内容长度来存,再额外加少量长度信息 - drop、delete、truncate 的区别是什么? - delete 是 DML 语句,用来删除表中的数据行,但不会删除表结构 - truncate 是 DDL 语句,用来快速清空整张表的数据,但保留表结构 - 不能带条件,执行速度通常比 delete 更快,并且一般会重置自增值,属于 DDL - drop 也是 DDL 语句,它删除的不只是数据,还包括表结构本身 - 索引 - MySQL 为什么不用红黑树 - 红黑树是二叉平衡树,节点分叉少,数据量大时树会很高 - 数据库索引主要瓶颈是磁盘 I/O,树越高,查一次要访问的磁盘页就越多 - B+Tree 是多叉树,一个节点能存很多 key,可以显著降低树高,从而减少磁盘 I/O - 并且 B+Tree 叶子节点有序且相连,更适合范围查询和排序 - 索引下推是什么? - 索引下推就是在使用索引查询时,把能够利用索引字段判断的条件,下推到存储引擎层先过滤,只有满足条件的记录才回表 - 核心价值就是减少回表次数,提升查询性能 - 什么是回表 - 先查二级索引,再根据主键去聚簇索引查整行 - 因为 InnoDB 的二级索引叶子节点不存整行数据,只存主键值,所以当查询字段不全在索引里时,就必须回表 - 什么是覆盖索引 - 如果一个查询走的是二级索引,并且查询条件里的字段、返回结果里的字段,都已经包含在这个索引里 - 那么 MySQL 只需要扫描这个索引就能拿到结果,不需要再根据主键去聚簇索引里查整行数据,这种情况就叫覆盖索引 - 避免回表,提高查询性能 - 什么是聚簇索引和二级索引 - InnoDB 的聚簇索引和二级索引,核心区别就在于叶子节点存的内容不同 - 聚簇索引 的叶子节点存的是整行数据 - 二级索引 的叶子节点存的不是整行数据,而是主键值 - 由于数据只能按一种方式组织,所以一张表只能有一个聚簇索引 - 什么情况下索引会失效? - 索引无法用于查找 - 没有遵守最左前缀原则 - 对索引列做函数、运算、表达式操作 - 发生隐式类型转换 - 使用前导模糊查询 - 使用 !=、<>、not in、not like - 使用 or,且 or 两边条件不是都能走索引 - 优化器主动放弃使用索引 - 索引列区分度太低 - 查询结果数据量过大 - 联合索引遇到范围查询,范围列后面的列索引利用会受影响 - 为什么不建议建太多索引? - 增加存储成本,每个索引都要额外占用磁盘空间 - 降低写性能,insert、update、delete 不仅要改数据,还要维护所有相关索引,索引越多,写入越慢 - 增加维护复杂度,包括页分裂、缓存占用、优化器选择成本,以及容易出现冗余索引和重复索引 - SQL 执行优化 - where、group by、having、order by、limit 的执行顺序是什么? - SQL 里这几个子句的逻辑执行顺序是:where、group by、having、order by、limit - where:先对原始数据做行过滤 - group by:对过滤后的结果进行分组 - having:对分组后的结果再做过滤 - order by:对最终结果排序 - limit:最后再截取指定条数的数据 - explain 的各字段是什么意思? - EXPLAIN 的核心作用是分析 SQL 执行计划 - 看执行层次和表结构 - id 表示查询中每个 SELECT 的标识,看执行顺序 - select_type 表示这条 SELECT 是什么类型 - table 表示当前这一行执行计划正在访问哪张表 - 看索引使用情况 - possible_keys 表示优化器认为“可能可以使用”的索引 - key 表示最终实际使用的索引 - key_len 表示 MySQL 决定使用的索引长度,单位是字节 - ref 表示当前表和索引做匹配时,拿什么值去和索引列比较 - 看扫描成本 - type 表示访问类型,也就是这张表是怎么被访问的。这个字段非常重要 - rows 表示优化器预估为了找到结果,需要扫描多少行 - filtered 表示经过当前表条件过滤后,大概还有多少百分比的行会继续参与后续处理 - 额外性能风险 - Extra 表示额外执行信息,这一列也非常重要 - type 各个值是什么意思 - 性能从好到差可以大致理解为:system > const > eq_ref > ref > range > index > ALL - system 是最优情况之一,表示表中只有一行数据 - const 表示通过主键或者唯一索引,并且是等值匹配,一次就能把记录确定下来 - eq_ref 一般出现在多表 join 中,表示对被驱动表使用主键或唯一索引进行等值匹配,并且每次匹配最多返回一条记录 - ref 表示使用普通索引做等值查询,返回的结果可能有多条 - range 表示索引范围扫描 - index 表示扫描了整个索引树 - ALL 表示全表扫描,是最差的一种常见情况 - 一条 SELECT 的执行过程是什么 - 客户端连接 MySQL - SQL 发送到 MySQL Server - 解析器进行词法分析和语法分析 - 词法分析:把 SQL 拆成一个个关键字和标记,比如 select、表名、字段名、where 条件等 - 语法分析:判断 SQL 是否符合 MySQL 语法规则,并生成语法树 - 预处理:检查表和字段是否存在,语义是否正确 - 优化器生成执行计划 - 优化器会根据 SQL 生成一个最优或较优的执行计划 - 执行器调用存储引擎读取数据 - join 的执行原理是什么? - 先由优化器确定表的连接顺序、访问方式和是否使用相关优化,再由执行器按这个计划去逐层匹配数据 - 最经典的 join 算法是 Nested-Loop Join,也就是先从驱动表取一行或一批数据,再拿这些数据的关联字段去被驱动表中查找匹配记录 - 如果是多表 join,就继续向下一层嵌套 - join 性能的关键在于外层循环次数和内层表的访问成本,所以通常会强调小表驱动大表,以及让被驱动表的 join 字段尽量命中索引 - 怎么定位慢 SQL? - 先发现慢 SQL - 慢查询日志,MySQL 可以开启 slow query log - APM / 监控系统,比如接口监控、数据库监控、链路追踪 - 再分析执行计划,有没有走索引,扫描的数据多不多,是否有额外排序或临时表 - 验证优化效果 - 深分页为什么慢? - 深分页慢,主要是因为 MySQL 不是“直接跳到第 100000 条”开始取数据,而是通常要先找到前面的记录,再把它们跳过 - 怎么优化深分页? - 使用基于上次游标的分页,也叫 keyset pagination - 先用覆盖索引查主键,再回表 - 限制最大分页深度 - 结合搜索条件缩小数据范围 - 大表加字段为什么有风险? - ALTER TABLE ADD COLUMN 这类操作在某些情况下并不是简单改一下表结构元数据,而可能触发表重建、数据拷贝、元数据锁竞争和大量日志写入 - 对于数据量很大的表,这些成本会被放大,容易导致业务抖动甚至阻塞 - 在线 DDL - 在执行 ALTER TABLE 这类表结构变更时,尽量不阻塞业务的读写,允许 DDL 和 DML 在较大程度上并发执行的一种机制 - MySQL 的 ALTER TABLE 可能有三种方式:ALGORITHM=INSTANT、ALGORITHM=INPLACE、ALGORITHM=COPY - INSTANT 最轻量,通常只改元数据,速度最快 - INPLACE 通常不需要整表拷贝,但可能仍会重建表或持有一定锁 - COPY 代价最大,需要复制整张表,风险也最高 - 如何给大表加索引? - 先评估这条索引是否真的必要,确认它能解决实际慢 SQL,而不是盲目加索引; - EXPLAIN 和业务 SQL 验证索引设计,尽量避免建错索引或冗余索引; - 确认 MySQL 版本、表引擎、索引类型和 DDL 算法,优先选择支持在线变更的方式,比如添加普通二级索引时尽量走 InnoDB Online DDL; - 在正式执行前评估主从延迟、磁盘空间、IO 峰值和 metadata lock 风险,避开业务高峰,并提前处理长事务和长查询; - 执行时尽量显式指定合适的算法和锁级别,例如 ALTER TABLE ... ADD INDEX ..., ALGORITHM=INPLACE, LOCK=NONE - 执行过程中持续监控 DDL 进度、主从延迟、CPU、IO 和锁等待 - 生产上更稳妥的方式通常是使用 gh-ost、pt-online-schema-change 这类在线变更工具,以影子表加索引、分批回放、最后切换的方式降低影响 - 事务 - 什么是事务 - 事务就是一组操作,这组操作要么全部成功,要么全部失败,不会只执行一部分 - 它的作用是保证数据从一个一致状态,变到另一个一致状态 - 事务的 ACID 是什么 - 原子性指的是事务中的操作要么全部成功,要么全部失败回滚,不能只执行一部分 - 一致性指的是事务执行前后,数据库都必须处于一致状态 - 隔离性指的是多个事务并发执行时,彼此之间应该尽量互不干扰 - 持久性指的是事务一旦提交,对数据的修改就应该永久保存下来,即使数据库宕机,也不能丢失 - 脏读、不可重复读、幻读分别是什么? - 脏读指的是:一个事务读到了另一个事务“还没有提交”的数据 - 不可重复读指的是:在同一个事务里,多次读取同一行数据,结果不一样 - 幻读指的是:在同一个事务里,按照相同条件多次查询,前后查询到的“记录条数”不一样,像是突然多出来或少了一些行 - MySQL 的事务隔离级别有哪些 - 读未提交(Read Uncommitted):一个事务可以读到另一个事务还没有提交的数据 - 读已提交(Read Committed):一个事务只能读到另一个事务已经提交的数据 - 可重复读(Repeatable Read):同一个事务中,多次读取同一条记录,结果会保持一致 - 串行化(Serializable):它会让事务串行执行,一个一个来,避免并发冲突 - 当前读和快照读有什么区别? - 快照读读取的是事务可见范围内的历史版本数据,本质上是基于 MVCC 的一致性读,通常不加锁,普通 select 一般就是快照读 - 当前读读取的是当前最新版本的数据,并且通常会对读到的记录加锁,以保证并发修改时的数据正确性,常见于 select ... for update、select ... lock in share mode,以及 update、delete 这类语句 - ReadView 是什么 - Read View 可以理解为 InnoDB 在做 MVCC 一致性读时生成的一张“可见性快照” - 因为一行记录在并发事务下可能存在多个版本,所以当前事务读取数据时,并不是直接读最新版本,而是要结合 Read View 来判断哪个版本对自己可见 - 如果当前版本不可见,就沿着 undo log 构成的版本链继续向前找,直到找到一个可见版本 - MVCC 是怎么实现的? - 核心思想是:一行数据在并发事务下,不是只保留一个版本,而是通过保留多个版本,让读操作尽量不加锁,也能读到对自己可见的数据,从而提升并发性能 - 隐藏字段:InnoDB 的聚簇索引记录里会带一些隐藏信息 - DB_TRX_ID:最后一次修改这行数据的事务 ID - DB_ROLL_PTR:指向这行记录旧版本的回滚指针 - undolog:当事务修改一行数据时,InnoDB 不会简单把旧值直接覆盖掉就算了,而是会把旧版本信息写到 undo log 里 - 版本链:一行数据可能被多次更新,所以每次更新都会生成一份旧版本。这些旧版本不会直接丢掉,而是通过 DB_ROLL_PTR 串起来,形成一条版本链 - Read View:当事务做快照读时,InnoDB 会生成一个 Read View。它本质上是一套可见性规则,用来判断版本链上的哪个版本对当前事务可见 - 查询看到的是某个时间点的数据库快照,只能看到在那个时间点之前已提交事务的修改,以及当前事务自己之前语句的修改 - 长事务有什么危害 - 容易长时间占用锁,影响并发 - 会导致 undo log 积压 - 会影响 MVCC 和 purge 清理 - 回滚成本高,出问题影响更大 - 更容易引发锁等待和死锁问题 - 会影响主从复制和系统稳定性 - 分布式事务 - 一个业务操作需要跨多个服务、多个数据库,甚至多个消息系统共同完成,但这些资源不在同一个本地事务里,所以不能只靠单机数据库事务来保证一致性 - 2PC / XA - 两阶段提交,属于强一致方案 - 协调者先问所有参与者能不能提交,大家都可以才真正提交 - TCC - Try、Confirm、Cancel - 优点是控制力强,缺点是业务侵入性很高,每个服务都要实现三套接口 - Saga - 把长事务拆成多个本地事务,每一步成功后继续下一步,如果失败就反向执行补偿操作 - 本地消息表 / 事务消息 - 先保证本地事务成功,再通过消息异步驱动其他系统完成后续操作 - 锁机制 - MySQL 有哪些锁 - 按粒度分,有表锁和行锁 - 表锁锁整张表,开销小但并发差;行锁锁单条记录,并发更好,InnoDB 主要依赖行锁 - 按性质分,有共享锁和排他锁 - 共享锁允许多个事务同时读;排他锁用于写操作,持有后别人不能再读写对应数据 - 意向锁、记录锁、间隙锁、临键锁 - 意向锁是表级锁,用来协调表锁和行锁;记录锁锁单条索引记录;间隙锁锁记录之间的区间,防止插入;临键锁是记录锁和间隙锁的组合,主要用于在可重复读下避免幻读 - 表锁和行锁的区别是什么? - 表锁是对整张表加锁,加锁开销小、实现简单,但锁冲突范围大,并发能力较差 - 行锁是对单条记录加锁,锁粒度更细,并发能力更强,但加锁和维护成本也更高 - 行锁是加在行上的吗,还是加在索引上? - InnoDB 的行锁不是直接加在物理数据行上的,而是加在索引记录上的 - InnoDB 是通过锁住索引项来间接锁住对应的数据行 - 没有走索引,数据库就可能扫描并锁住大量记录,效果上看起来就像锁了很多行 - 什么是共享锁和排他锁? - 共享锁也叫读锁,多个事务可以同时对同一份数据加共享锁,也就是说大家都可以读,但不能修改 - 排他锁也叫写锁,一旦某个事务对数据加了排他锁,其他事务就不能再对这份数据加共享锁或排他锁,也就是别人既不能读,也不能写 - 共享锁适合并发读场景,排他锁适合需要修改数据、保证独占访问的场景 - 意向锁的作用是什么? - 让 InnoDB 在表级锁和行级锁并存的情况下,能够快速判断一张表里是否已经有某些行被加锁,而不需要每次都去逐行检查 - 意向锁本质上是一种“加锁声明”,表示某个事务准备在这张表的某些行上加共享锁或排他锁 - 比如事务如果要对某些行加行级共享锁,就会先在表上加意向共享锁(IS);如果要对某些行加行级排他锁,就会先在表上加意向排他锁(IX) - 当另一个事务想对整张表加表锁时,只需要先判断这张表上是否存在冲突的意向锁,就能快速决定能不能加锁,而不需要扫描整张表里的每一行 - 事务加锁 - 事务里并不是一开始就把所有锁一次性加好,而是 SQL 在执行过程中,随着访问到具体记录再逐步加锁。不同语句、不同隔离级别、不同索引命中情况,加的锁也不一样 - 如果是普通 select,在 InnoDB 里通常属于快照读,一般不加行锁 - 如果是 select ... for update、select ... for share 这种锁定读,就会对扫描到的索引记录加锁,锁会一直持有到事务提交或回滚 - 如果是 update、delete 这类写操作,InnoDB 会在执行过程中对访问到的索引记录加排他锁 - 死锁如何排查 - 先通过 SHOW ENGINE INNODB STATUS 查看最近一次死锁的详细信息,因为这里能直接看到死锁涉及的两个事务、它们各自执行的 SQL、已经持有的锁、正在等待的锁,以及 InnoDB 最后回滚了哪个事务 - 根据死锁日志里的事务 ID、SQL 语句、锁类型和表索引信息,去定位到底是哪两条业务 SQL 在互相等待,重点看它们是不是以不同顺序访问了相同资源 - 再结合执行计划和索引设计分析根因,比如是否没命中索引导致锁范围扩大、是否范围条件触发了间隙锁或临键锁、是否事务过长导致持锁时间太久 - 统一事务中的访问顺序、补充合适索引、缩小事务范围、尽快提交事务,并在应用层做好 deadlock 重试机制 - update 一定会加行锁吗? - UPDATE 一定会加锁,但不一定只是精确的单行锁;如果命中了唯一索引等值条件,锁范围通常比较小,如果是范围更新或没有用到合适索引,锁范围就可能很大 - MVCC 与日志 - redo log 和 undo log 的区别是什么? - redo log 主要用于保证事务的持久性,记录的是“数据页做了什么修改”,核心是为了数据库宕机后能够把已提交事务的数据恢复出来 - undo log 主要用于保证事务的原子性和支持 MVCC,记录的是“怎么把这次修改撤销掉”,核心是为了事务回滚和一致性读 - redo log 是“向前恢复”的日志,undo log 是“向后回滚”的日志 - 事务提交后,如果数据库突然宕机,InnoDB 可以通过 redo log 把已经提交但还没来得及刷盘的数据重新恢复出来 - binlog 是什么?和 redo log 有什么区别? - binlog 是 MySQL Server 层的归档日志,记录的是数据库变更事件,主要用于主从复制和基于时间点恢复 - redo log 是 InnoDB 存储引擎层的重做日志,记录的是对数据页的物理修改意图,主要用于崩溃恢复,保证已提交事务在宕机后不丢失 - binlog 记录的是逻辑上的变更事件,可以是 statement、row、mixed 三种格式;redo log 记录的是更底层的数据页修改信息 - binlog 会持续追加写入二进制日志文件,redo log 则是固定容量、循环写的日志结构 - 两阶段提交是什么?为什么需要两阶段提交? - MySQL 为了保证 redo log 和 binlog 的一致性 而采用的一种提交机制 - 提交事务时,先写 redo log 并标记 prepare,再写 binlog,最后把 redo log 标记为 commit - 这样即使宕机,也能根据 redo log 状态和 binlog 是否完整,判断事务到底该提交还是回滚,避免数据恢复错误和主从不一致 - binlog 有哪几种格式?STATEMENT / ROW / MIXED 的区别是什么? - STATEMENT 模式记录的是 原始 SQL 语句,也就是把执行过的 SQL 直接写到 binlog 里 - ROW 模式记录的不是 SQL,而是 每一行数据被修改前后是什么样子 - MIX 能兼顾日志大小和复制安全性,本质上是 MySQL 自动帮你在 STATEMENT 和 ROW 之间选择 - 因为主从复制最重要的是一致性,虽然 ROW 日志更大,但更稳定、更安全,所以很多公司默认都会选 ROW - InnoDB 存储引擎 - 什么是页、区、段? - 在 InnoDB 里,数据不是一行一行零散存储的,而是按 页、区、段 这样的层次来管理的 - 页 是最小的数据存储和读写单位,InnoDB 默认页大小一般是 16KB - 数据库不可能为了查一行数据就只从磁盘读这一行,它通常是 把这一行所在的整个页一起读进内存 - 区 是一组连续页的集合,主要是为了提高磁盘分配效率 - 段 是更上层的逻辑概念,用来管理某一类数据对应的多个区 - 数据页内部结构了解吗? - 一个页里面既要存数据,也要存管理信息,还要支持页内快速查找和页之间的链式组织 - File Header 让很多数据页能够串起来,形成双向链表 - Page Header 负责描述“这个页现在装了多少数据、还剩多少空间、内部组织状态如何” - Infimum 可以理解为“页内最小记录”,Supremum 可以理解为“页内最大记录”,它们的作用是帮助 InnoDB 统一维护页内记录的链表结构 - InnoDB 的聚簇索引叶子节点,存放的其实就是完整的行记录,这些记录就是放在数据页里的 User Records 区域 - Free Space 表示当前页还能不能继续插入数据 - 页目录提高页内查找效率,避免单纯链表遍历太慢。先通过 页目录 做快速定位,再在局部范围内顺着记录链表查找 - File Trailer 主要用于页级别的校验 - 页分裂和页合并 - 页分裂:目标页空间不够了,但又必须保持记录按主键顺序存放 - 页合并:删除数据后,页利用率太低,空间浪费严重 - InnoDB Buffer Pool 是什么? - InnoDB Buffer Pool 是 InnoDB 在内存里开辟的一块缓存区,主要用来缓存磁盘中的数据页和索引页 - 把经常访问的数据先放到内存里,减少磁盘 IO,提高数据库的查询和更新性能 - 把“磁盘随机读”尽量变成“内存读” - 什么是脏页 - 内存中的页已经被修改了,但磁盘中的对应页还没来得及更新 - Change Buffer 是什么 - 当修改的是 普通二级索引,而且对应索引页 当前不在 Buffer Pool 中 - InnoDB 不会立刻把这个索引页从磁盘读到内存再修改,而是先把这次变更缓存到 Change Buffer 里 - 等以后这个页被读入内存时,再把这些变更合并进去 - 减少二级索引页的随机磁盘 IO,提升插入、删除、更新的性能 - 唯一索引需要立刻检查唯一性,所以通常 不能使用 Change Buffer - Double Write - Double Write 是 InnoDB 为了防止页刷盘过程中发生“部分写入”而设计的一种保护机制 - 如果数据库在把一个页写回磁盘时突然宕机,就可能出现 一个页只写了一半 的情况,这种问题叫 partial write,也就是页损坏 - InnoDB 在真正把脏页写回数据文件之前,会先把这些页顺序写到一块专门的区域,这块区域就叫 Double Write Buffer; - 只有这一步写成功后,才再把这些页写到真正的数据页位置 - 主从复制与高可用 - 主从复制的原理 - 主库执行事务提交后,会把这次数据变更记录到 binlog 里 - 从库会有一个 I/O 线程,去主库读取 binlog,并把读到的内容写入本地的 relay log(中继日志) - 从库再用 SQL 线程或者 worker 线程去执行 relay log 里的内容,把主库上的变更在从库再执行一遍,这样主从数据就一致了 - 因为 binlog 记录的是逻辑上的数据变更,适合跨机器同步;而 redo log 主要用于主库自身的崩溃恢复 - 主从延迟的原因有哪些? - 主库的变更已经提交了,但从库还没有来得及同步并执行完成 - 主库写入压力太大 - 如果主库短时间内产生了大量 binlog,比如高并发写入、批量 update、批量 delete,这些日志从库来不及消费,就会出现延迟 - 从库回放能力不足 - 大事务 - 如果主库上有大事务,比如一次改很多行、删很多数据,从库执行这类事务也会很慢,容易造成明显延迟 - 锁冲突 - 从库在回放 binlog 时,如果遇到锁等待,比如查询占用了资源,或者回放语句之间有冲突,也会拖慢复制速度 - 单线程复制的限制 - 网络问题 - 主从复制模式 - 异步复制 - 主库事务提交时,只要自己写成功就直接返回,不需要等待从库确认 - 如果主库刚提交就宕机,而 binlog 还没来得及传到从库,就可能发生 数据丢失 - 半同步复制 - 主库提交事务后,不是立刻返回,而是 至少要等一个从库收到 binlog 并返回确认,主库才认为提交完成 - 通常只是表示 从库收到了日志,不一定表示已经真正执行完成 - 组复制 - 多个节点组成一个复制组,事务提交时需要经过组内协调和一致性校验,然后再在多个节点之间复制 - 读写分离实现 - 把写请求走主库,读请求走从库,从而分摊数据库压力 - 最大的问题是主从延迟,容易导致读到旧数据,同时也会增加系统路由和运维复杂度 - 读写分离如何读到最新数据 - 让“有强一致性要求的读”不要去可能延迟的从库,而是优先走主库 - 用户刚完成写操作后,接下来一段时间内的查询直接走主库,这样就能保证一定能读到最新数据 - 如果一个业务逻辑本身就在同一个事务里,那么这个事务中的读和写通常都应该放在主库执行 - 系统可以监控主从延迟,如果发现从库延迟过大,就临时把请求切到主库 - 给关键业务做读主策略 - 主库宕机后如何切换? - 确认主库确实不可用 - 从多个从库里选择一个 数据最新、延迟最小、状态最健康 的从库作为新主库 - 把新主库提升上来 - 让其他从库重新挂到新主库 计算机网络 - 计算机网络 - TCP/UDP - TCP/UDP 区别 - TCP 是面向连接、可靠传输的协议;UDP 是无连接、尽最大努力交付的协议 - TCP 在通信前需要先建立连接,并且通过 ACK、序号、超时重传、滑动窗口、流量控制、拥塞控制 等机制保证数据可靠到达 - TCP 适合 文件传输、网页访问、数据库通信 这类对可靠性要求高的场景 - UDP 不需要建立连接,首部开销小,传输效率高,延迟低 - 不保证数据一定到达,也不保证顺序,所以更适合 音视频、直播、游戏、DNS 这类对实时性要求更高的场景 - TCP 为什么可靠 - TCP 给每个字节编号,接收方收到后会回复 ACK,发送方就知道哪些数据已经成功到达 - 如果发送后迟迟收不到 ACK,TCP 会认为数据可能丢了,然后重新发送 - 如果数据乱序到达,TCP 会先缓存,等前面的数据到了再按顺序交给应用层;如果重复到达,会直接丢弃 - TCP 不需要每发一个包就停下来等确认,可以连续发送多个包,提高传输效率 - TCP 会根据接收方的处理能力调整发送速度,避免接收方来不及处理 - TCP 会根据网络拥塞情况动态调整发送速率,避免把网络压垮 - TCP 三次握手 - 作用,是让通信双方都确认一件事:自己能发、自己能收、对方也能发、对方也能收 - 客户端向服务端发送 SYN,表示客户端想建立连接,同时告诉服务端自己的初始序列号,说明:客户端有发送能力 - 服务端收到后,返回 SYN + ACK - ACK 表示服务端收到了客户端的请求,SYN 表示服务端也想建立连接,并告诉客户端自己的初始序列号 - 说明:服务端有接收能力,也有发送能力 - 客户端收到服务端的 SYN + ACK 后,再发送一个 ACK 给服务端 - 客户端已经收到了服务端的响应,同时让服务端知道客户端也具备接收能力 - 为什么握手不是两次 - 两次握手只能保证客户端知道服务端能收、能发,但服务端并不能确认客户端是否收到了自己的响应 - 没有第三次握手,服务端就可能误以为连接已经建立,从而浪费资源 - TCP 四次挥手过程是什么 - 客户端发送 FIN,表示客户端这边已经没有数据要发了,想关闭连接 - 客户端进入半关闭状态,表示自己不能再发了,但还能接收 - 服务端收到 FIN 后,先回复一个 ACK,表示我知道你这边想关闭了 - 这时只是说明服务端知道客户端不发了,但服务端自己可能还有数据没发完,所以还不能立刻关闭 - 等服务端把剩余数据发送完后,再发送一个 FIN,表示服务端这边也没有数据要发了,也要关闭连接 - 客户端收到服务端的 FIN 后,回复一个 ACK,表示我知道你也发完了 - 为什么需要四次 - TCP 是全双工的,客户端关闭发送和服务端关闭发送是两件事 - 服务端收到客户端的 FIN 时,只能先回一个 ACK,表示“我知道你要关了” - 但这时服务端可能还有数据没发完,所以不能把自己的 FIN 和这个 ACK 合并发送 - TIME_WAIT 是什么?为什么需要 TIME_WAIT? - TCP 连接断开时,主动关闭连接的一方 在发送完最后一个 ACK 后进入的一个等待状态 - 保证最后一个 ACK 能被对方收到 - 如果主动关闭方发出的最后一个 ACK 丢失了,被动关闭方会重发 FIN - 主动关闭方还保留在 TIME_WAIT 状态,就还能再次发送 ACK,保证连接正常关闭 - 防止旧连接中的延迟报文影响新的连接 - 为什么是 2MSL? - MSL 是报文在网络中的最大生存时间 - 等待 2MSL,本质上是留出一个“报文来回”的最长时间: - 所以等待 2MSL 后,可以认为这次连接里的所有报文都已经从网络中消失了,连接才算真正安全结束 - CLOSE_WAIT 过多说明什么问题? - 对方已经把连接关闭了,但是本端应用程序没有及时调用 close 关闭连接 - 连接一直停留在 CLOSE_WAIT 状态 - 对方发送 FIN - 本端内核回复 ACK - 这时 TCP 连接就进入 CLOSE_WAIT - 所以如果 CLOSE_WAIT 特别多,一般不是网络问题,也不是内核参数问题,而是应用代码问题。常见原因有 - 没有正确调用 close - 异常分支没有释放连接 - 线程泄露 - 线程阻塞 - 业务处理太慢 - SYN Flood - 是一种针对 TCP 三次握手的 DoS / DDoS 攻击 - 利用 TCP 三次握手中服务端会先分配资源的特点,大量占满半连接队列,耗尽服务端资源 - 防御方式 - 在还没完成握手前,不急着分配连接资源,而是把状态编码到序列号里,等客户端真正回 ACK 后再分配资源 - 增大半连接队列 - 缩短半连接超时时间,减少重试次数 - 限流和防火墙过滤 - 源地址校验和流量清洗 - TCP 粘包/拆包 - TCP 是面向字节流的协议,没有消息边界 - 发送方连续发送的多个数据包,接收方一次可能一起读出来,这叫 粘包 - 发送方一个完整数据包,接收方可能分多次才读完整,这叫 拆包 - TCP 只保证字节流可靠、有序到达,但不保证应用层消息是一条一条交付的 - 滑动窗口 - 发送方不用每发一个数据包就停下来等 ACK,而是可以在窗口范围内连续发送多个数据包 - 随着 ACK 不断返回,已经确认的数据会从窗口左边移出去,新的可发送数据会从右边进入 - 窗口左边:已经发送并且已经确认的数据 - 窗口中间:已经发送但还没有确认的数据 - 窗口右边:暂时还不能发送的数据 - 当 ACK 到来后,窗口右移,发送方就能继续发送新的数据 - 接收方能处理多少,发送方就最多发多少,这就体现了流量控制 - TCP 拥塞控制有哪些机制? - 避免发送方一下子发太多数据,把网络压垮 - 慢启动 - 不会一上来就发很多数据,而是先从较小的拥塞窗口开始,收到 ACK 后再快速增大 - 拥塞避免 - 当拥塞窗口增长到一定阈值后,就不再指数增长,而改为线性增长 - 快重传 - 如果发送方连续收到三个重复 ACK,就认为某个报文很可能丢了,不必等超时,立刻重传对应报文 - 快恢复 - 先把窗口缩小一部分,再进入拥塞避免阶段 - 什么是零窗口?什么是窗口探测? - 接收方的接收缓冲区已经满了,于是告诉发送方自己的接收窗口大小为 0,让发送方先不要再发数据 - 体现的是 TCP 的流量控制,也就是接收方处理不过来了,先把发送方“刹住” - 窗口探测 - 当发送方发现接收方通告窗口为 0 后,会周期性发送很小的探测报文,询问接收方现在窗口是不是已经恢复了 - HTTP - 常用状态码 - 1xx 表示请求已收到,继续处理 - 2xx 表示请求成功 - 200 OK:请求成功,最常见 - 3xx 表示需要重定向 - 301 Moved Permanently:永久重定向 - 302 Found:临时重定向 - 304 Not Modified:资源没修改,走缓存 - 4xx 表示客户端请求有问题 - 400 Bad Request:请求参数有问题 - 401 Unauthorized:未认证,通常是没登录或 token 无效 - 403 Forbidden:已认证,但没有权限 - 404 Not Found:资源不存在 - 405 Method Not Allowed:请求方法不允许 - 408 Request Timeout:请求超时 - 5xx 表示服务器处理有问题 - 500 Internal Server Error:服务器内部错误 - 502 Bad Gateway:网关收到上游错误响应 - 503 Service Unavailable:服务暂时不可用 - 504 Gateway Timeout:网关等待上游响应超时 - HTTP 长连接 - 一次 TCP 连接建立后,不是只收发一个请求就立刻关闭,而是可以在这个连接上继续发送多个 HTTP 请求和响应 - 复用同一条 TCP 连接,减少频繁建立和关闭连接的开销 - 在 HTTP/1.1 中,长连接默认就是开启的,通常通过 Connection: keep-alive 来体现 - HTTP 队头阻塞 - 前面的请求或数据没有处理完,后面的就只能等 - 虽然可以在一个 TCP 连接上发送多个请求,但响应必须按顺序返回 - 前一个请求如果处理很慢,后面的请求即使已经到了,也不能先返回,只能排队等待,这就是 HTTP 层的队头阻塞 - 如果前面的某个 TCP 报文丢了,即使后面的报文已经到了,应用层也不能先拿到,必须等前面的报文重传成功后,后面的数据才能一起交付 - HTTP/2 引入了 多路复用,把多个请求拆成不同的流并发传输,解决了 HTTP/1.1 中“请求和响应必须严格排队”的问题 - HTTP 1/1.1/2/3 - 不断解决 性能低、连接利用率低、队头阻塞严重 - HTTP/1.0 的特点是:默认短连接 - HTTP/1.1 的改进是:默认长连接,支持连接复用,支持 Host 字段、缓存控制等能力 - HTTP/2 的核心改进是:二进制分帧、多路复用、头部压缩 - 把原来文本格式改成二进制帧,解析效率更高; - 多个请求可以在同一个连接里并发传输,不再像 HTTP/1.1 那样严格排队 - 通过头部压缩减少了重复头信息带来的开销 - HTTP/3 的核心改进是:基于 QUIC,也就是基于 UDP 来实现可靠传输 - 不再直接使用 TCP,而是把连接管理、重传、流控这些能力放到 QUIC 里做 - Cookie 属性 - Name 和 Value:就是 Cookie 的名字和值,用来保存会话信息或业务数据 - Expires 和 Max-Age:用来控制 Cookie 的过期时间 - Domain:指定 Cookie 对哪个域名生效 - Path:请求路径匹配时,浏览器才会带上这个 Cookie - Secure:这个 Cookie 只能在 HTTPS 连接中传输,不能通过 HTTP 明文传输 - HttpOnly:这个 Cookie 不能被 JavaScript 读取,主要是为了降低 XSS 窃取 Cookie 的风险 - SameSite:用来限制跨站请求时是否携带 Cookie,主要是为了防 CSRF - HTTP 缓存 - 主要分为两类:强缓存 和 协商缓存 - 强缓存 - 浏览器先看本地缓存是否还有效,如果没过期,就直接使用本地缓存,不会向服务器发请求 - Expires:绝对过期时间 - Cache-Control:相对过期时间,优先级更高,实际开发更常用 - 协商缓存 - 本地虽然有缓存,但浏览器还是会先向服务器确认一下这个资源有没有变化 - 如果没变,服务器返回 304 - Last-Modified 和 If-Modified-Since:基于资源最后修改时间判断 - ETag 和 If-None-Match:基于资源唯一标识判断,通常更准确 - HTTPS - TLS 握手过程是什么 - 客户端发起请求 - 客户端先发送 ClientHello,里面会带上自己支持的 TLS 版本、加密套件、随机数 等信息,告诉服务端:我支持哪些加密方式 - 服务端返回协商结果 - 服务端收到后,返回 ServerHello,告诉客户端:我最终选用哪个 TLS 版本、加密套件,同时也会返回服务端自己的随机数 - 服务端发送证书 - 服务端会把自己的 数字证书 发给客户端,客户端会校验证书是否合法,比如是不是受信任的 CA 签发、证书是否过期、域名是否匹配 - 双方协商出会话密钥 - 客户端和服务端会通过密钥交换算法,结合前面的随机数等信息,生成本次通信使用的 会话密钥 - 握手完成,开始加密通信 - CA 和证书链 - CA 就是 证书颁发机构,作用是给网站签发数字证书 - 证书链 - 一个网站的证书,往往不是直接由浏览器内置信任的根 CA 签发的,而是由 中间 CA 来签发 - 中间 CA 又是被更上层的 CA 签发,最后一直追溯到 根 CA - 根 CA 数量有限,而且非常重要,不会直接大量给网站签证书 - IP/路由/ARP/ICMP - ARP - ARP 全称是 地址解析协议,作用是:根据 IP 地址找到对应的 MAC 地址 - 先查 ARP 缓存 - 如果没有,就发送 ARP 请求,在局域网内发一个 广播包 - 目标主机回复 ARP 响应,返回一个 单播响应,把自己的 MAC 地址告诉发送方 - 发送方拿到目标 MAC 后,会把这条 IP 和 MAC 的映射 存到本地 ARP 缓存里 - 为什么有了 IP 还需要 MAC - IP 负责跨网络定位目标主机 - MAC 负责在局域网内把数据交给具体网卡 - IP 是端到端的逻辑地址,MAC 是每一跳局域网内的物理地址 - IP 管“到哪里”,MAC 管“怎么到下一跳” - ICMP - ICMP 全称是 互联网控制报文协议,它是 网络层 的辅助协议,主要用来做 差错报告、网络诊断和连通性检测 - ping 的原理,本质上就是利用 ICMP 的 Echo Request 和 Echo Reply 报文来测试两台主机之间是否连通,以及大概的往返时延 - 发送方发送 ICMP Echo Request - 目标主机收到后返回 ICMP Echo Reply - 发送方收到回复后,就知道网络是通的 - traceroute 原理 - 查看数据包从源主机到目标主机,中间经过了哪些路由器 - TTL 表示数据包还能经过多少跳,减到0,路由器就会把这个包丢弃并返回一个 ICMP Time Exceeded - 利用 IP 头里的 TTL 字段 和路由器返回的 ICMP 超时报文 来实现逐跳探测 - 先发一个 TTL=1 的包 - 再发一个 TTL=2 的包 - 不断把 TTL 加 1,直到到达目标主机 - Socket/ 服务端编程 - Socket 是什么 - 本质上就是:操作系统提供给应用程序的网络通信接口 - 应用程序并不是直接去操作 TCP 或 UDP,而是通过 Socket 来完成网络数据的收发 - Socket 是应用层和传输层之间的一层编程抽象 - listen/accept/connect - listen:把一个 socket 变成 监听 socket,表示服务端开始监听某个端口,准备接收客户端连接 - accept:用于从监听队列里取出一个已经建立好的连接,返回一个新的 已连接 socket - connect:用于客户端主动向服务端发起连接请求 - 流程 - 服务端先 socket,再 bind - 然后 listen,表示开始监听端口 - 服务端调用 accept 取出这个连接 - 阻塞 I/O、非阻塞 I/O、I/O 多路复用 - 阻塞 I/O:当程序调用 read 或 recv 时,如果数据还没准备好,线程就会一直阻塞住,什么都干不了,只能等数据到了再继续执行 - 非阻塞 I/O:当程序调用 read 或 recv 时,如果数据还没准备好,不会阻塞线程,而是立刻返回,告诉你现在还不能读 - I/O 多路复用:通过 select、poll、epoll 这类机制,一次监听多个 socket,先等内核告诉我“哪些连接已经就绪了”,然后再去读写这些就绪的连接 - select/poll/epoll - select、poll、epoll 本质上都是 I/O 多路复用机制,目的都是让一个线程同时监听多个 socket,谁就绪就处理谁 - select - 用 位图 保存要监听的 fd,每次调用都要把 fd 集合从用户态拷贝到内核态 - 内核返回后,应用程序还要 遍历整个 fd 集合,找出哪些 fd 就绪了 - select 一般还有 最大 fd 数量限制,常见是 1024 - poll - 把位图改成了 链表或数组结构 来保存 fd - 没有 select 那种固定 1024 的数量限制 - 本质上还是要把 fd 集合拷贝到内核,也还是要 线性遍历所有 fd 找就绪事件,所以性能问题本质没有解决 - epoll - 是 Linux 下更高效的实现 - 它会在内核里维护监听的 fd,不需要每次都把整个 fd 集合重新拷贝进去 - 不是每次遍历所有 fd,而是通过 就绪链表 直接返回已经就绪的 fd - 连接数很大、但真正活跃的连接不多时,epoll 效率会高很多 - LT 和 ET 模式 - LT 是 水平触发 - 只要一个 fd 里还有数据没读完,或者还有空间可以写,epoll 每次都会继续通知你 - 条件只要一直满足,就会反复触发 - ET 是 边缘触发 - 当 fd 的状态发生变化的那一刻,epoll 才会通知一次 - 这次没有把数据一次性读完,只要状态没再变化,epoll 不一定再次提醒你 - Reactor 模型 - 事件驱动的网络编程模型 - 不是一个连接一个线程去阻塞处理,而是由一个或多个线程统一监听 I/O 事件,谁就绪就分发给对应的处理器 - 监听事件:通过 select、poll、epoll 这类 I/O 多路复用机制,统一监听很多连接上的事件,比如连接到来、可读、可写 - 分发事件:一旦某个连接就绪了,Reactor 就把这个事件分发给对应的 Handler 去处理,比如读取数据、业务处理、发送响应 - I/O 事件来了,先由 Reactor 感知,再分发给处理器执行 - 零拷贝 - 在数据传输过程中,尽量减少用户态和内核态之间的数据拷贝,以及减少 CPU 参与搬运数据的次数 - 传统方式 - 磁盘把数据读到内核缓冲区 - 再从内核拷贝到用户缓冲区 - 用户缓冲区再拷贝回内核的 socket 缓冲区 - 最后再发给网卡 - 发生多次数据拷贝和多次用户态、内核态切换,开销比较大 - 核心思想:数据尽量留在内核里直接转发,避免来回拷贝到用户态 - sendfile - 应用程序不再把文件先读到用户态,再写回 socket - 让内核直接把文件数据从磁盘缓冲区传到 socket,提高传输效率 - 最典型的零拷贝方式 - mmap - 把文件映射到用户进程的地址空间,减少一次数据拷贝 - 应用层访问起来像读内存一样,但底层仍然是内核在管理 - 拥塞、性能、优化 - 带宽和吞吐量有什么区别? - 带宽 指的是:链路理论上的最大传输能力 - 吞吐量 指的是:单位时间内实际传输了多少数据 - 带宽是理论值、上限值 - 吞吐量是实际值、结果值 - 什么是心跳机制?为什么需要心跳? - 通信双方按照固定时间间隔,周期性发送一个很小的探测消息,用来确认对方是否还活着、连接是否还正常 - 作用:检测连接是否存活;及时发现异常;维持长连接 - 网络安全 - XSS - XSS 是 跨站脚本攻击,本质上是:攻击者把恶意脚本注入到网页里,让受害者浏览器去执行 - 脚本执行成功,攻击者就可能窃取 Cookie、伪造页面、冒充用户操作,甚至获取用户信息 - 网站把用户输入的内容当成代码执行了,而不是当普通数据处理 - 防御方式 - 对用户输入做转义和过滤 - 输出到页面时做 HTML 编码 - 使用 CSP - 给 Cookie 加 HttpOnly - CSRF - 跨站请求伪造,本质上是:攻击者诱导用户在已登录状态下访问恶意页面,借助浏览器自动携带 Cookie 的特性,冒充用户向目标网站发请求 - 防御方式 - 加 CSRF Token - 校验 Origin / Referer - 使用 SameSite Cookie - 对关键操作做二次确认 - 场景题 - WebSocket 是什么? - WebSocket 是一种 全双工、长连接的网络通信协议, - 不只是客户端发请求、服务端被动响应,服务端也可以主动向客户端推送消息 - 先通过 HTTP 发起一次升级请求 - 服务端同意,就返回 101 Switching Protocols,表示协议切换成功 Java - Java - Java 集合 - HashMap 为什么线程不安全? - put、resize、get 等操作都没有加锁,多线程并发读写时,多个线程会同时修改同一份底层数据结构,导致数据错乱 - 多个线程同时往同一个桶位置插入数据,都会先判断当前位置为空,或都基于旧值进行修改,其中一个线程的结果会覆盖另一个线程的结果,数据丢失 - 如果多个线程同时扩容,或者一个线程在扩容、另一个线程在插入,会同时修改桶数组和节点指针 - ConcurrentHashMap 为什么线程安全? - 在并发读写时,对共享数据的访问做了专门的同步控制 - 插入时,如果桶位置为空,会先通过 CAS 尝试把新节点放进去,CAS 成功就直接插入,不需要加锁 - 如果对应桶已经有元素,说明发生哈希冲突,这时会对桶头节点加锁 - 在这个桶内部进行链表或红黑树的插入操作,也就是说它锁的是 单个桶,不是整张表 - 扩容时,也支持多线程协助扩容,降低单线程扩容带来的性能问题 - 为什么负载因子默认是 0.75? - 负载因子表示:哈希表中元素个数达到数组长度的多少时开始扩容 - 如果负载因子太小,会浪费空间 - 如果负载因子太大,会增加哈希冲突 - 0.75 是时间和空间的经验平衡点 - 为什么数组长度总是 2 的幂? - (n - 1) & hash 的效果就等价于 对 n 取模,也就是等价于 hash % n,但是 位运算比取模运算更高效 - 数组长度是 2 的幂时,n - 1 的低位全是 1,做 & 运算时,可以利用 hash 的低位信息,使元素更均匀地分布到各个桶中 - Java 并发 - 线程状态 - NEW:新建状态,对象已经被创建出来了,但是还没有调用 start() 方法 - RUNNABLE:可运行状态,调用了 start() 之后,就进入 RUNNABLE 状态 - BLOCKED:阻塞状态,在等待获取 synchronized 锁 时,如果锁被别的线程占用,就会进入 BLOCKED 状态 - WAITING:无限期等待状态,等待其他线程显式唤醒 的状态,没有超时时间 - TIMED_WAITING:限时等待状态,带超时时间,时间到了会自动恢复 - TERMINATED:终止状态,线程执行完毕,或者因为异常退出,生命周期结束 - synchronized 原理 - 依赖的是 对象头里的监视器锁,也就是 Monitor - Java 中每个对象都可以关联一个 Monitor,线程进入 synchronized 修饰的代码时,本质上就是在竞争这个对象的 Monitor 锁 - synchronized 修饰的是 代码块 - 编译后会生成两个字节码指令:monitorenter/monitorexit - 同步代码块的底层就是通过 字节码指令 + Monitor 机制 来实现加锁和解锁的 - synchronized 修饰同步方法时 - 在方法的访问标志上加一个 ACC_SYNCHRONIZED 标记 - 调用这个方法时,看到有这个标记,就会自动先去获取对应对象的 Monitor 锁,执行完方法后再释放锁 - synchronized 锁的是谁? - 基于 对象监视器 Monitor 实现的,所以它锁住的一定是某个对象 - AQS 是什么 - AQS 的全称是 AbstractQueuedSynchronizer,抽象队列同步器 - 用来构建锁和同步器的底层框架 - 如果线程竞争资源失败,就把线程封装成节点,加入一个双向队列中排队 - 当前面的线程释放资源后,再唤醒队列中的后继线程继续竞争 - 核心构成 - 用一个 state 变量表示同步状态 - AQS 内部有一个 volatile int state 变量,用来表示当前同步状态 - AQS 只提供这个状态字段和管理机制,具体这个状态代表什么,由子类自己定义 - 用一个 FIFO 双向队列管理等待线程 - 线程获取同步状态失败时,AQS 不会让所有线程一直自旋,而是会把获取失败的线程包装成一个 Node 节点,加入到一个 FIFO 双向链表队列里等待 - 工作流程 - 尝试获取资源 - 入队等待 - 阻塞线程:入队之后,线程会被挂起,避免一直空转浪费 CPU - 释放资源并唤醒后继节点 - ThreadLocal 是什么 - 给每个线程都提供一份独立的变量副本,从而做到线程之间互不干扰 - 同一个 ThreadLocal 对象,在不同线程中读取到的值,实际上是各自线程单独保存的,不是共享的 - 每个线程 Thread 对象内部,都有一个 ThreadLocalMap,真正的数据是存放在线程自己的 ThreadLocalMap - JMM - 是一套关于多线程环境下共享变量如何读写、以及线程之间如何保证可见性、有序性和原子性的规范 - 为了规范这些问题,JMM 定义了一系列规则,并通过 volatile、synchronized、Lock、happens-before 等机制来实现 - happens-before - 一个操作的结果,对另一个操作是否可见,以及这两个操作之间是否存在有序关系 - happens-before 是 JMM 中定义的一种 内存可见性和有序性规则 - 前一个操作对共享变量的修改,后一个操作一定能够看到 - 前一个操作的执行顺序,排在后一个操作之前 - 常见规则 - 程序次序规则:在同一个线程内,按照代码顺序,前面的操作 happens-before 后面的操作 - 监视器锁规则:对同一把锁,unlock 操作 happens-before 后续对这把锁的 lock 操作 - volatile 变量规则:对一个 volatile 变量的写操作 happens-before 后续对这个 volatile 变量的读操作 - 线程启动规则:线程对象的 start() 方法 happens-before 此线程中的每一个操作 - 线程终止规则:线程中的所有操作 happens-before 其他线程检测到该线程已经终止 - 传递性 - 线程池核心参数 - corePoolSize:核心线程数 - maximumPoolSize:最大线程数 - keepAliveTime:空闲线程存活时间 - unit:时间单位 - workQueue:任务阻塞队列,用来缓存等待执行任务的阻塞队列 - threadFactory:线程工厂 - handler:拒绝策略 - 拒绝策略 - AbortPolicy:直接抛异常 - CallerRunsPolicy:由提交任务的线程自己执行 - DiscardPolicy:直接丢弃任务 - DiscardOldestPolicy:丢弃队列中最老的任务,再尝试提交当前任务 - JVM - JVM 内存区域 - 程序计数器 - Java 虚拟机栈 - 本地方法栈 - 堆 - 方法区 - 方法区存放什么 - 方法区主要存放的是 类的元信息,以及一些和类加载后相关的公共数据 - 类的元信息 - 运行时常量池 - 静态变量 - 即时编译后的代码缓存相关信息 - 类加载过程 - 加载 - 通过类的全限定名获取定义这个类的二进制字节流 - 将这个字节流所代表的静态存储结构,转换成方法区中的运行时数据结构 - 在内存中生成一个对应的 Class 对象,作为这个类的访问入口 - 验证 - 确保加载进来的类文件是合法的,不会危害 JVM 安全 - 文件格式是否正确 - 元数据是否合理 - 准备 - 为类变量,也就是 static 变量分配内存,并设置默认初始值 - 赋的是默认值,不是代码里写的真实值 - 解析 - 把常量池中的符号引用,替换成直接引用 - 符号引用:用字符串、类名、方法名这种“符号形式”来描述目标 - 直接引用:真正能定位到内存地址、偏移量或者句柄的引用 - 初始化 - 初始化阶段是真正开始执行类中 Java 代码的阶段 - 这一步主要就是执行类构造器 <clinit>() 方法 - 对象可回收判断 - 可达性分析 - 从一组 GC Roots 作为根节点出发,沿着对象之间的引用关系向下搜索 - 一个对象能够被 GC Roots 直接或间接访问到,说明这个对象仍然存活 - 如果一个对象从 GC Roots 出发无法到达,也就是不可达,那么这个对象就会被判定为可回收对象 - GC Roots - 虚拟机栈中引用的对象,也就是方法中的局部变量引用的对象 - 方法区中类静态属性引用的对象 - 方法区中常量引用的对象 - 被同步锁持有的对象等 - 一组一定处于活跃状态、能够作为起点向下搜索对象引用链的根节点 - Minor GC/Major GC/Full GC - Minor GC 指的是 发生在新生代的垃圾回收 - Major GC 一般指的是 针对老年代的垃圾回收 - Full GC 指的是 对整个 Java 堆,通常还包括方法区在内的全面垃圾回收 - STW - 在 JVM 执行某些操作时,会让所有用户线程暂时停下来,只让 JVM 自己的后台线程工作 - 为了保证在垃圾回收或其他关键操作过程中,内存中的对象引用关系是相对稳定的 - G1 的回收原理是什么? - 是一种面向大堆、可预测停顿时间的垃圾回收器 - 不再把整个堆简单划分为固定的新生代和老年代连续空间 - 把整个 Java 堆切分成很多大小相等的 Region - 每个 Region 在某个时刻可以扮演 Eden、Survivor、Old 甚至 Humongous 的角色 - 不必每次都处理整片内存,而是可以优先回收那些“垃圾最多、收益最高”的 Region - G1 的回收过程 - Young GC,也就是只回收年轻代相关的 Region - 这个过程会暂停用户线程,把 Eden 和部分 Survivor 中还活着的对象复制到新的 Survivor 或 Old Region - 并发标记周期 - 当老年代占用达到一定阈值时,G1 会启动一次全堆并发标记 - 先会有一个初始标记,这个阶段需要短暂停顿,用来标记 GC Roots 直接可达的对象 - 然后进入并发标记阶段,和用户线程一起执行,去找出整个堆里哪些对象仍然存活 - 之后还有重新标记阶段,这一步也需要 Stop-The-World - 最后会进入清理和筛选阶段,统计出哪些 Region 存活对象少、回收价值高,形成后续的回收候选集 - 采用“标记整理 + 局部复制”的思路 - 会把选中的 Region 中存活对象复制到别的空 Region,再把原来的 Region 整体回收掉 - G1/CMS - CMS 和 G1 都是 面向低停顿 的垃圾回收器 - CMS 的核心思路是:针对老年代,尽量和用户线程并发执行,减少停顿时间 - G1 的核心思路是:把整个堆划分成很多 Region,按收益优先回收,尽量在可控停顿时间内完成垃圾回收 - G1 把整个堆划分成很多大小相等的 Region - CMS 在老年代主要使用的是 标记-清除算法,回收后会产生 内存碎片 - G1 从整体上看采用的是 标记-整理 思想,在局部 Region 回收时,也会涉及 复制算法 - G1 会把存活对象整理到新的 Region 中,所以能够有效减少甚至避免碎片问题 - 反射/动态代理/注解 - 注解的本质是什么? - 写在代码里的元数据 - 不会直接改变程序的执行逻辑,而是给编译器、JVM 或框架提供一些额外信息,让它们在编译期、类加载期或运行期决定要做什么处理 Spring - Spring - Spring 核心基础 - 什么是 Spring?它解决了什么问题? - 解决的是企业级应用开发里对象管理复杂、模块耦合高,以及很多通用能力需要重复造轮子的问题 - Spring 通过 IOC,也就是控制反转,把对象的创建、依赖装配和生命周期管理交给容器 - 开发者不需要再手动 new 各种对象,这样能显著降低模块之间的耦合,提高可维护性和可测试性 - Spring 通过 AOP,也就是面向切面编程,把事务、日志、权限、监控这些横切逻辑从业务代码里抽离出来 - Spring 提供了统一的生态整合能力,比如整合数据库访问、Web 开发、缓存、消息队列、安全框架等 - Spring 中循环依赖是怎么解决的? - 把 Bean 的创建过程拆成了“实例化、属性填充、初始化”几个阶段 - 在 Bean 还没完全初始化完成时,就提前把一个“早期引用”暴露出去 - 另一个 Bean 在依赖它的时候,就能先拿到这个早期对象,从而打破相互等待 - 第一级缓存放的是已经完全初始化好的单例 Bean - 第二级缓存放的是提前暴露出来的早期 Bean 对象 - 第三级缓存放的是一个对象工厂,用来在必要的时候生成早期引用 - 创建 A 的时候,发现它依赖 B,于是先实例化 A,但还没填充属性,就把 A 的 ObjectFactory 放进三级缓存 - 接着去创建 B,B 又依赖 A,这时 Spring 就会从三级缓存中拿到 A 的早期引用注入给 B - B 创建完成后,再回过头完成 A 的属性注入和初始化 - Spring 不仅要解决“拿到一个半成品对象”的问题,还要解决“这个半成品对象将来可能需要被代理”的问题 - 所以要多一层工厂来延迟决定最终暴露的是原始对象还是代理对象 - `@ComponentScan` 的原理是什么? - 告诉 Spring 去哪些包路径下扫描组件类,然后把符合条件的类注册成 Bean,交给 IOC 容器统一管理 - 确定扫描路径 - 默认情况下如果我们在启动类或者配置类上加了 @ComponentScan,Spring 会以这个类所在的包作为根路径向下扫描 - 扫描 class 文件并解析元数据 - Spring 不会一上来就把所有类都加载进 JVM,而是先基于资源路径去查找包下面的 .class 文件 - 然后通过 ASM 这类字节码读取方式解析类的注解信息 - 是否标了 @Component、@Service、@Controller、@Repository - 把符合条件的类封装成 BeanDefinition,注册到 BeanDefinitionRegistry,也就是容器的 Bean 定义注册表里 - 后续在容器 refresh 过程中再完成实例化、依赖注入和初始化 - 负责的并不是直接创建对象,而是先把“哪些类应该交给 Spring 管理”这件事识别出来并注册进去 - Spring Bean 的生命周期是什么? - Spring 容器启动时会先根据 BeanDefinition 去创建 Bean 实例,也就是实例化对象 - 这一步本质上就是通过构造方法或者工厂方法把对象创建出来 - Spring 会把这个 Bean 依赖的其他 Bean 注入进来,比如通过 @Autowired、@Resource 或者配置文件完成依赖注入 - 如果这个 Bean 实现了某些 Aware 接口,比如 BeanNameAware、BeanFactoryAware、ApplicationContextAware,Spring 会在这一阶段把对应的容器能力注入给它 - 往后会进入初始化前置处理阶段,这时会执行所有 BeanPostProcessor 的 postProcessBeforeInitialization 方法 - 如果 Bean 实现了 InitializingBean 接口,就会调用它的 afterPropertiesSet 方法 - 如果在配置里指定了 init-method,也会执行自定义初始化方法 - 初始化完成后,还会再执行一次 BeanPostProcessor 的 postProcessAfterInitialization 方法 - 很多 Spring AOP 的代理对象就是在这里生成的 - 到这里,一个 Bean 才算真正完成初始化,可以被业务代码正常使用 - 等到容器关闭时,如果 Bean 实现了 DisposableBean,Spring 会调用它的 destroy 方法 - 如果配置了 destroy-method,也会执行自定义销毁方法,完成资源释放,比如关闭连接、释放线程池之类的 - Spring 是如何管理 Bean 的? - Spring 会先读取配置来源,把类的信息解析成 BeanDefinition,也就是 Bean 的定义元数据 - 里面会描述这个 Bean 的类型、作用域、依赖关系、初始化方法等 - Spring 容器在启动过程中会把这些 BeanDefinition 注册到容器里,之后根据作用域和依赖关系来创建 Bean - Spring 会把创建好的 Bean 放到容器中统一维护,比如单例 Bean 会缓存到单例池里,后续再获取时直接返回 - ApplicationContext 刷新的过程是什 - 首先,Spring 会做刷新前的准备工作,也就是 prepareRefresh() - 设置容器的启动状态,记录启动时间,初始化一些上下文环境属性,并校验必要的配置 - 接着会进入 obtainFreshBeanFactory(),这一步的核心是拿到一个全新的 BeanFactory,并把配置源里的 BeanDefinition 加载进去 - 然后是 prepareBeanFactory(beanFactory),这一步是在对 BeanFactory 做一些标准初始化,比如设置类加载器、表达式解析器、属性编辑器 - 如果当前容器是子类实现,比如 WebApplicationContext,还会执行 postProcessBeanFactory(beanFactory) - 留给具体子类去扩展自己的 BeanFactory 处理逻辑 - invokeBeanFactoryPostProcessors(beanFactory)。这里会执行所有 BeanFactoryPostProcessor 和 BeanDefinitionRegistryPostProcessor - 在 Bean 真正实例化之前,Spring 允许开发者对 Bean 定义做进一步修改和增强 - 比如常见的 ConfigurationClassPostProcessor,就是在这里解析 @Configuration、@ComponentScan、@Import、@Bean 等注解配置 - 很多注解驱动能力本质上都是在这里完成的 - registerBeanPostProcessors(beanFactory),也就是注册所有 BeanPostProcessor - 像 AutowiredAnnotationBeanPostProcessor 负责处理依赖注入 - CommonAnnotationBeanPostProcessor 负责处理 @PostConstruct - 之后 Spring 会执行 initMessageSource(),初始化国际化消息源 - 执行 initApplicationEventMulticaster(),初始化事件广播器 - 执行 onRefresh(),给具体子类一个扩展点,比如 Web 容器可以在这里初始化特殊的 Web 基础设施 - 接着执行 registerListeners(),把事件监听器注册到容器中 - finishBeanFactoryInitialization(beanFactory),这一步会完成剩余 BeanFactory 的初始化,并且实例化所有非懒加载的单例 Bean - 包括实例化、属性填充、执行 Aware 接口回调、执行 BeanPostProcessor、调用初始化方法、必要时创建 AOP 代理对象等 - finishRefresh(),这一步表示整个容器刷新完成 - Spring 会初始化生命周期处理器,回调实现了 Lifecycle 接口的组件 - AOP - 什么是切面、连接点、切点、通知、织入? - AOP 的目标是把日志、事务、权限校验这类和主业务无关但很多地方都会重复出现的横切逻辑,从业务代码里抽离出来统一管理 - 连接点,连接点指的是程序执行过程中那些可以被增强的点,在 Spring AOP 里通常就是方法执行这个层面 - 切点可以理解为对连接点的筛选规则,也就是到底拦截哪些方法,不是所有连接点都会被增强,只有匹配切点表达式的那些连接点才会生效 - 通知是指在切点匹配到的方法前后要执行的增强逻辑,比如方法执行前记录日志、执行后统计耗时、抛异常时做告警 - 切面则是切点和通知的组合体,你可以把它理解成一个完整的增强模块,它定义了“对哪些方法”在“什么时机”做“什么增强“ - 织入,织入就是把切面应用到目标对象并生成代理对象的过程,也就是把原本独立的横切逻辑真正嵌入到业务执行链路里的过程 - AOP 实现原理是什么 - Spring 在容器启动、Bean 创建完成之后,会判断这个 Bean 是否需要被增强 - 某个 Bean 匹配了切面定义的切点表达式,Spring 就不会直接把原始 Bean 放进容器,而是为它创建一个代理对象 - 业务代码从容器中拿到的其实是这个代理对象,而不是目标对象本身 - AOP 代理对象通常是在 Bean 初始化后创建的,核心扩展点是 BeanPostProcessor - AOP 这样的自动代理能力通常由 AbstractAutoProxyCreator 这类后置处理器参与完成 - Spring 在 Bean 创建完成后,会检查这个 Bean 是否匹配 Advisor,也就是通知加切点的组合,如果匹配,就用代理对象替换原始 Bean 放进容器 - 拦截 Bean 的创建过程 - 判断当前 Bean 是否匹配某些 Advisor,也就是切点加通知的组合 - 如果匹配,就为这个 Bean 创建代理对象并返回,用代理对象替换原始 Bean - 后续从容器里拿到的就不是目标对象本身,而是增强后的代理对象 - 为什么类内部自调用会导致 AOP 失效? - Spring AOP 基于代理实现,而自调用绕过了代理对象,直接调用了目标对象本身,导致增强逻辑无法织入 - 事务 - Spring 事务是如何实现的? - Spring 事务的本质是基于 AOP 和数据库事务机制做的一层统一封装 - Spring 在容器启动过程中会解析 @Transactional,为目标 Bean 创建代理对象 - 当外部调用这个代理对象的方法时,请求不会直接进入目标方法,而是先进入事务拦截器,比如 TransactionInterceptor - 这个拦截器会先根据事务注解解析出事务属性,比如传播行为、隔离级别、超时时间、是否只读、遇到什么异常回滚等 - 事务管理器底层会把数据库连接和当前线程绑定起来,通常是通过 ThreadLocal 实现的,这样同一个线程里的多个数据库操作就可以共享同一个事务上下文 - MVC - Spring MVC 的核心组件有哪些? - Spring MVC 本质上是一个基于前端控制器模式实现的 Web 框架 - 它把一次 HTTP 请求的处理流程拆分成多个职责清晰的组件协同完成 - 最核心的组件首先是 DispatcherServlet,它可以理解为 Spring MVC 的总入口,也就是前端控制器,所有请求都会先到它这里,再由它统一分发给后续组件处理 - HandlerMapping,它的作用是根据请求的 URL、请求方法这些信息,找到当前请求应该由哪个处理器,也就是哪个 Controller 方法来处理 - HandlerAdapter,因为不同类型的处理器调用方式可能不一样,所以 Spring MVC 不会直接由 DispatcherServlet 去调用 Controller,而是通过 HandlerAdapter 做一层适配 - 处理器本身,也就是我们常说的 Controller,它负责接收请求参数、执行业务逻辑,并返回 ModelAndView 或者直接返回 JSON 数据 - ViewResolver,它主要用于传统 MVC 场景,把 Controller 返回的逻辑视图名解析成真正的视图对象 - View,它负责最终把模型数据渲染成页面返回给客户端 - HandlerExceptionResolver,它负责统一处理请求过程中的异常; - SpringBoot - Spring Boot 自动配置是如何生效的? - Spring Boot 在启动时批量导入一组自动配置类,这些配置类再基于条件注解按需生效 - 启动类上一般会加 @SpringBootApplication - @EnableAutoConfiguration - @EnableAutoConfiguration 再通过 @Import 机制,导入一个非常核心的组件,也就是 AutoConfigurationImportSelector - 这个类的职责就是在容器启动阶段,把所有候选的自动配置类找出来并导入到 Spring 容器中 - AutoConfigurationImportSelector 会去读取 Spring Boot 约定好的自动配置清单 - 这个文件里列出了一批自动配置类,比如数据源自动配置、MVC 自动配置、Redis 自动配置、事务自动配置等 - Spring Boot 启动时会把这些类批量加载进来,作为候选配置类参与后续容器处理 - 每个自动配置类上通常都会带很多条件注解,比如 @ConditionalOnClass,表示类路径下存在某个类时才生效 系统设计 - 系统设计 - RPC 框架 - 第一层是服务定义与代理层 - 服务提供方暴露接口,启动时把接口名、方法签名、版本号、分组等元数据注册到注册中心 - 服务消费方本地通过动态代理,比如 JDK 动态代理或者字节码增强,拦截接口方法调用,把本地调用转换成一个 RPC Request - 第二层是注册中心和服务发现层 - 引入注册中心,比如基于 ZooKeeper、Nacos 或者 Etcd - 服务提供者启动时注册自己的地址,并定期续约或者通过心跳保持存活 - 消费者会订阅服务列表变更,本地维护一个服务节点缓存 - 第三层是网络通信层 - 我一般会基于 Netty 来做,因为它性能高、事件驱动模型成熟,适合高并发场景 - 底层通信可以基于 TCP 长连接,这样可以减少频繁建连的成本 - 为了避免粘包拆包问题,协议上我会设计固定的消息头,比如魔数、协议版本、消息类型、序列化方式、压缩方式、请求长度,再加上消息体 - 消息头解决协议识别和扩展性,消息体承载真正的请求参数 - 第四层是编解码与序列化层 - 会给框架留一个 SPI 扩展点,支持 JSON、Hessian、Kryo、Protobuf 这类序列化方式 - 第五层是调用容错与治理层 - 首先要支持超时控制,因为网络调用最怕无限等待 - 其次要支持重试机制,但我会强调重试不是默认无脑开启,因为非幂等接口可能导致重复写,所以一般只对读操作或者明确幂等的接口开启 - 再往上是负载均衡,常见策略有随机、轮询、一致性哈希、加权随机等; - 如果是有状态服务或者缓存类场景,一致性哈希会比较合适 - 再就是熔断、限流、降级,比如某个下游实例失败率持续升高,就暂时摘除,避免雪崩扩散 - Redis - Redis - 基础认知 - Redis 为什么快 - Redis 是基于内存的 - Redis 的数据结构设计非常高效 - Redis 采用单线程模型处理命令,避免了线程切换和锁竞争 - Redis 使用 IO 多路复用,提高了网络处理效率 - Redis 6.0 之后引入了多线程优化网络读写 - Redis 为什么常被当作缓存,而不是数据库主存储 - Redis 的核心优势是高性能访问,更适合做缓存 - Redis 的数据可靠性通常不如传统主数据库 - Redis 在复杂查询和事务能力上不如数据库 - Redis 基于内存,存储成本更高 - Redis 线程模型是什么样的 - Redis 早期采用的是单线程事件循环模型,也就是由一个主线程结合 I/O 多路复用来完成连接处理、请求读取、命令解析、命令执行和结果返回 - 单线程主要是指命令处理主路径单线程,而不是整个 Redis 进程只有一个线程,因为它内部还会有一些后台线程处理持久化、异步任务等工作 - Redis 6.0 之后,又引入了 I/O 多线程,可以把 socket 读写等网络 I/O 工作交给多个线程处理 - 主线程负责命令执行,配合 I/O 多路复用;在新版本中,再用多个 I/O 线程来分摊网络处理开销 - Redis 的事件模型 / IO 多路复用是什么? - IO 多路复用,就是通过 select、poll、epoll 这类机制,让一个线程能够同时监听多个 socket 连接 - Redis 的事件主要分成两类,一类是文件事件,也就是 socket 的读写事件,负责客户端请求和响应 - 另一类是时间事件,也就是定时任务,比如过期键检查等 - Redis 主线程会不断在事件循环中监听事件、分发事件、处理事件,然后再进入下一轮循环 - 数据类型类 - Redis 的五种基础数据类型是什么? - String 是最基础的数据类型,适合存普通字符串、数字、JSON 以及做缓存和计数器 - List 是有序可重复的列表,适合做消息队列和动态列表 - Hash 是 field-value 结构,适合存对象的多个属性,比如用户信息、商品信息 - Set 是无序且元素唯一的集合,适合做去重、标签、共同好友这类场景 - ZSet 是带分数的有序集合,元素唯一并且可以按 score 排序,所以特别适合做排行榜、热度榜这类业务 - String 的底层实现是什么? - Redis 的 String 底层本质上是 SDS,也就是简单动态字符串 - Redis 没有直接使用 C 原生字符串,因为 SDS 可以 O(1) 获取长度、支持二进制安全、减少字符串修改时的扩容开销,并且更安全 - 如果值能表示成 64 位整数,就会用 int 编码 - 如果是较短的字符串,通常用 embstr 编码,也就是把对象头和 SDS 分配在同一块内存中 - 如果是较长的字符串,则使用 raw 编码 - List 的底层实现是什么? - 底层实现核心是 quicklist - quicklist 可以理解为一个双向链表,但它的每个链表节点里并不是只放一个元素,而是放一个紧凑存储结构 listpack - 如果直接用普通双向链表,每个元素都要额外付出很多指针开销,内存利用率比较低 - Hash 的底层实现是什么? - 小 Hash 使用 listpack,大一点的 Hash 使用 hashtable - listpack 是一种紧凑型连续内存结构,适合字段数量不多、field 和 value 都比较短的小对象,这样可以明显节省内存 - 当 Hash 的元素数量变多,或者字段和值超过阈值时,Redis 会自动把它转换成 hashtable 编码 - Set 的底层实现是什么 - 如果一个 Set 里的元素全都是整数,而且数量比较少,Redis 会优先使用 intset,因为这种结构更紧凑、更省内存 - 如果是小型集合,在 Redis 7.2 及以上也可能使用 listpack 编码,本质上也是为了节省空间 - 当集合规模变大,或者元素类型不再适合紧凑编码时,Redis 就会自动转换成 hashtable,这样查找、插入和删除性能会更稳定 - ZSet 的底层实现是什么? - 小型 ZSet 使用 listpack,普通 ZSet 使用 skiplist - 普通 ZSet 虽然对象编码上叫 skiplist,但实际实现并不是只有一个跳表,而是“跳表 + 哈希表”的组合 - 是因为跳表适合维护按 score 排序、做排名和范围查询,而哈希表适合按 member 快速定位元素 - List 和 Stream 的区别是什么? - Redis 的 List 和 Stream 都可以用来做消息队列 - List 本质上是一个有序列表,更适合做简单队列,通常就是一端写入、一端弹出,消息被消费后就从队列里移除了 - Stream 本质上是一个追加式日志,每条消息都有唯一 ID,消费者是按 ID 往后读取,而不是简单 pop 元素 - 支持消息回放、断点续读和多消费者读取 - Stream 原生支持消费组、待确认消息和 ack 机制,适合需要失败重试、消息追踪和多消费者协作的场景 - 底层数据结构 - 什么是渐进式 rehash?为什么要渐进式? - 当哈希表需要扩容或缩容时,Redis 不会一次性把旧表里的所有数据全部搬到新表里,而是先申请一个新哈希表,然后把数据分多次、分批次慢慢迁移过去 - 如果一次性 rehash,在数据量很大时会导致主线程长时间阻塞,造成请求延迟升高 - 过程 - 保留旧哈希表 - 新建一个更大或更小的新哈希表 - 把 rehashidx 设为起始位置 - 之后每次在处理正常请求时,顺便搬迁一部分桶中的数据 - 等所有桶都迁移完后,再释放旧表 - 什么是跳表?为什么 Redis 的 ZSet 选择跳表而不是红黑树? - 跳表本质上是一种带多层索引的有序链表 - 它在最底层维护一个有序链表,在上层再增加多级索引,这样查找、插入、删除的平均复杂度都可以做到 O(logN) - 跳表实现更简单,不需要像红黑树那样处理复杂的旋转和染色逻辑 - ZSet 很强调按 score 做范围查询、顺序遍历和排名查询,而跳表在这类操作上非常自然,找到起点后顺着链表往后扫就行 - 跳表的平均性能已经足够好,插入、删除、查找都是 O(logN) - ziplist / listpack 是什么?为什么后来更多使用 listpack? - 在 ziplist 中间插入或删除元素时,可能出现 cascading effect,也就是连锁更新问题 - 持久化 - Redis 有哪几种持久化方式? - RDB 是快照持久化,也就是在指定时间点把内存中的数据做成快照写到磁盘 - 优点是文件紧凑、恢复快、适合做备份 - 缺点是两次快照之间宕机可能丢数据 - AOF 是追加日志持久化,会把每一次写命令追加到文件里,Redis 重启时通过重放命令恢复数据 - 优点是数据更完整、持久化粒度更细 - 缺点是文件通常更大、恢复可能更慢 - AOF 重写机制是什么? - Redis 根据当前内存中的最新数据状态,重新生成一份更精简的 AOF 文件,而不是去直接修改或压缩旧的 AOF 日志 - AOF 会记录每一次写操作,文件会越来越大,包含很多对恢复当前状态已经没有意义的历史命令,需要通过重写来把这些冗余命令收敛掉 - Redis 会 fork 一个子进程,由子进程在后台生成新的 AOF 文件,主进程则继续处理客户端请求,不会中断服务 - 重写期间产生的新写命令,Redis 也不会丢失 - 父进程会单独写增量 AOF 文件,最后通过 manifest 做原子切换 - RDB 快照是怎么生成的? - 它可以通过 save 配置自动触发,也可以通过 SAVE 或 BGSAVE 手动触发 - Redis 主进程先 fork 一个子进程,子进程基于 fork 那一刻看到的内存数据,把整个数据集写入一个临时 RDB 文件 - 主进程则继续处理客户端请求,不会中断服务 - 等子进程写完后,再用新的 RDB 文件替换旧文件 - fork 之后还能继续处理请求,是因为 Redis 利用了操作系统的写时复制机制,也就是 Copy-On-Write - 什么是写时复制(Copy-On-Write)? - 父进程 fork 子进程后,操作系统不会立刻把整份内存复制一遍,而是先让父子进程共享同一批物理内存页,并把这些页标记成只读 - 到某个进程真的要修改某个页时,操作系统才会把这个页复制一份,让修改发生在新副本上 - Redis 特别依赖这个机制,因为它在做 RDB 快照和 AOF 重写时,需要 fork 子进程在后台写磁盘,而主进程还要继续处理客户端请求 - 子进程可以看到 fork 那一刻的数据视图,主进程也能继续工作,只是后续被修改到的页才会额外复制 - 过期键与淘汰策略类 - Redis 过期键是立即删除吗? - Redis 的过期键通常不是立即删除的 - 通过惰性删除和定期删除两种机制配合处理 - 惰性删除是指客户端访问某个 key 时,Redis 发现它已经过期了,才会删除并返回不存在 - 定期删除是指 Redis 会周期性随机抽取一部分带过期时间的 key 进行检查,把已经过期的 key 清理掉 - Redis 内存淘汰策略有哪些? - allkeys-* 表示所有 key 都可以参与淘汰 - volatile-* 表示只有设置了过期时间的 key 才参与淘汰 - 按最近最少使用淘汰的 LRU,有按访问频率淘汰的 LFU,有按最近最少修改淘汰的 LRM,有随机淘汰的 random,还有按剩余 TTL 最短淘汰 - allkeys-lru 在很多符合二八分布的缓存场景下是个不错的默认选项 - 缓存问题类 - 缓存穿透 - 缓存穿透,就是查询一个根本不存在的数据。因为这个数据既不会命中缓存,数据库里也查不到,所以每次请求都会穿过缓存,直接打到数据库。 - 缓存空值,也就是当数据库查不到数据时,也把这个空结果写入缓存,这样下次再查同一个 key 时,就可以直接从缓存返回,避免再次访问数据库 - 布隆过滤器,在请求进入缓存和数据库之前,先判断这个 key 是否可能存在,如果判断一定不存在,就直接拦截 - 缓存击穿 - 某一个热点 key 在某个时刻失效了,而这时候又正好有大量并发请求同时访问这个 key - 给热点 key 加互斥锁或者分布式锁,在缓存失效时只让一个线程去查数据库并重建缓存,其他线程等待或快速失败 - 可以对热点数据做逻辑过期,也就是缓存物理上不立刻删除,而是由后台异步刷新,这样前台请求始终能拿到旧值 - 对特别核心的热点数据做预热和永不过期 - 缓存雪崩 - 在某一个时间段内,大量缓存 key 同时失效,或者 Redis 整体不可用 - 原本应该由缓存承接的请求一下子全部打到数据库,从而造成数据库压力骤增 - 给不同 key 的过期时间加随机值,避免大量 key 在同一时刻同时失效 - 对热点数据提前预热,或者对特别核心的数据设置永不过期 - 做好 Redis 的高可用,比如主从、哨兵或集群,避免因为单点故障导致整片缓存不可用 - 缓存失效时配合限流、降级、熔断等保护手段 - 对回源数据库的请求做互斥控制,比如只让少量线程去重建缓存 - 分布式锁类 - 如何用 Redis 实现分布式锁 - 加锁时使用 SET lock_key unique_value NX PX 过期时间,其中 NX 表示只有这个锁不存在时才能加锁成功,保证互斥 - PX 或 EX 用来给锁设置过期时间,避免持锁线程宕机后锁永远不释放 - 释放锁不能直接 DEL,而要用 Lua 脚本先比较 value 是否还是自己,再删除 - 避免自己的锁过期后被别人拿到,而自己误删了别人的锁 - 什么是锁续期(看门狗)? - 一个线程拿到 Redis 分布式锁之后,如果业务执行时间可能超过锁的过期时间 - 系统会在锁快过期之前,自动把锁的 TTL 延长,避免业务还没执行完锁就提前失效 - 事务与原子性类 - MULTI、EXEC、WATCH 的作用是什么 - MULTI 的作用是开启一个事务块,执行它之后,后面的命令不会立刻执行,而是先进入队列 - EXEC 的作用是一次性按顺序执行前面在事务里排队的所有命令,并把连接状态恢复成普通模式 - WATCH 的作用是给一个或多个 key 加“乐观锁”,也就是在执行事务前先监视这些 key,只要它们在 EXEC 之前被其他客户端修改过,本次 EXEC 就不会真正执行事务 - 通常一起用来实现“先检查、再批量提交”的原子操作 - Lua 为什么能保证原子性 - Redis 执行 Lua 脚本时,会把整段脚本当成一个“单条命令”来执行 - Redis 的命令执行主路径以单线程事件循环为核心,所以当 EVAL/EVALSHA 开始执行后,Redis 会在同一个线程里把脚本里的所有 Redis 命令按顺序跑完 - 在这段时间里不会切换去处理其他客户端请求,也不会出现其他命令插进来“穿插执行”的情况 - Lua 的原子性保证的是“脚本执行期间不被其他命令打断” - 高可用类 - 主从复制到原理 - 主从复制本质上是 leader-follower 模型,也就是主节点负责处理写请求,然后把数据变更以命令流的形式异步发送给从节点,让从节点尽量保持和主节点一致 - 主从连接稳定时,主节点会持续把客户端写入、过期删除、淘汰等导致数据集变化的操作同步给从节点 - 网络短暂中断,从节点重连后会优先尝试部分重同步,也就是把断线期间漏掉的那段命令流补回来,这个过程依赖 replication id + offset 来判断从节点缺了哪一段数据 - 如果主节点的复制积压缓冲区已经没有这段历史了,或者历史对不上,就会退化成 全量同步 - 全量同步时,主节点会后台生成一份 RDB 快照,同时把这期间新的写命令先缓存在内存里,等 RDB 发送给从节点并加载完成后,再把这段缓冲的增量命令继续发给从节点 - Redis 默认采用的是 异步复制,也就是说主节点写成功并不会等待从节点确认,所以性能高、延迟低 - 全量复制和增量复制的区别是什么? - 全量复制一般发生在从节点第一次连接主节点,或者断线后发现没法继续部分同步的时候 - 主节点先后台生成一份 RDB 快照,把这份全量数据发给从节点 - 从节点加载完成后,主节点再把生成快照期间以及之后积累的写命令继续发给从节点,让从节点追上最新状态 - 增量复制更准确地说是部分重同步,一般发生在主从短暂断线后重新连上时 - 如果从节点保存的 replication id 和 offset 还能和主节点的复制积压缓冲区对上,那么主节点只需要把从节点断线期间漏掉的那一小段命令流补发给它 - Sentinel 是如何判断主节点下线的? - 先判断主观下线,再判断客观下线。 - 每个 Sentinel 都会持续给主节点发 PING,如果在配置的时间内一直拿不到有效回复,它就会先认为这个主节点是 主观下线,也就是 SDOWN - 只有当足够多的 Sentinel 也都认为这个主节点处于 SDOWN,并且相互确认,达到里配置的 quorum 数量后,这个主节点才会被认定为 客观下线 - SDOWN 是单个 Sentinel 的本地判断,ODOWN 是多个 Sentinel 达成法定票数后的集体判断 - 脑裂问题是什么?怎么解决? - 网络分区,旧主节点并没有真的宕机,只是和大多数 Sentinel、从节点失联了 - Sentinel 在另一侧把某个从节点提升成了新主节点 - 和旧主节点同一侧的客户端还在继续向旧主节点写数据,于是系统短时间内同时出现两个都在接受写入的主 - 部署上要有足够的 Sentinel 和副本,形成法定人数,避免少数派分区误判 - 在主库上配置,这样旧主一旦发现自己已经无法把写同步给足够数量的副本,就会在限定时间后停止接受写入 - 客户端必须通过 Sentinel 做主节点发现和切换,不要把旧主地址写死 - Sentinel 的故障转移流程是什么? - Sentinel 故障转移的本质,就是通过多数派判断主库真的挂了,再由一个领导 Sentinel 协调整个“选新主、切复制关系、通知客户端 - 复制积压缓冲区 - replication backlog,你可以把它理解成 主节点在内存里维护的一块环形缓冲区,用来暂存最近一段时间已经发送过的复制命令流 - 是支持主从断线后的部分重同步:当从节点短暂掉线后再重连,不需要立刻重新做一次全量复制,而是先带着自己上次复制到的 offset 回来请求补数据 - 主节点的复制积压缓冲区里还保留着这段从节点缺失的命令流,主节点就只把缺的这一段补发给它,这样就能快速恢复同步 - 断线重连后从节点会优先尝试 partial resynchronization,而能不能成功,关键就取决于主节点 backlog 里是否还保留着那段历史命令 - 制积压缓冲区本质上就是主节点为复制链路准备的“最近增量命令缓存”,它不是给业务读写直接用的,而是专门用来提高主从断线重连后增量续传成功率、减少全量复制发生概率的 - 集群类 - Redis Cluster 是什么? - 是 Redis 官方提供的分布式集群方案,它的核心目标是同时解决 Redis 单机存在的容量瓶颈、并发瓶颈和单点故障问题 - 把整个 key 空间划分成 16384 个 hash slot,每个主节点负责其中一部分槽位 - 数据根据 key 计算后分布到不同节点上,这样就实现了数据分片 - 每个主节点通常还会挂从节点,用来做主从复制和故障转移 - Redis Cluster 为什么是 16384 个槽? - 在分片粒度、元数据开销和实现复杂度之间做一个平衡 - Redis Cluster 不是直接把 key 映射到节点,而是先把 key 映射到固定数量的 hash slot,公式就是 CRC16(key) mod 16384 - 取的是 CRC16 的低 14 bit,计算简单、定位快 - 它本质上就是用一个 2 的幂次槽位数,在“槽位足够细、计算足够简单、集群元数据又不会太重”之间取得的工程折中 - 集群扩容和缩容是怎么做的? - 先把新的 Redis 节点加入集群 - 如果要加主节点,就先让它以空节点身份 add-node 进来,然后把一部分槽位从现有主节点迁移到这个新主节点上,也就是做 reshard/rebalance - 如果加的是从节点,则一般是把它加入后挂到某个主节点下面做副本 - 缩容时,流程正好相反:先把准备下线的主节点上的槽位全部迁移到其他主节点,等它不再持有任何槽位和数据后,再把这个节点从集群里删除 - 集群在线扩缩容的核心机制就是 moving a hash slot from one node to another,实际迁移时会把目标节点的槽位标记成 IMPORTING、源节点标记成 MIGRATING - Redis Cluster 如何做数据迁移? - 迁的是 hash slot,不是直接“整库搬家” - Cluster 里每个 key 先通过 CRC16(key) mod 16384 映射到某个槽位,再由负责这个槽位的主节点存储 - 扩容、缩容或者做负载均衡时,核心动作就是把一部分槽位从源节点迁到目标节点 - 先把目标节点对应的槽标记成 IMPORTING,把源节点标记成 MIGRATING,然后把这个槽里的 key 通过 MIGRATE 命令逐个搬到目标节点 - 该槽的数据迁完后,再更新集群里这个槽的归属关系 - 在线进行、不需要停机,客户端在迁移过程中如果访问到正在搬迁的槽,集群会通过重定向机制把请求引导到正确节点上 - 本质就是在线槽位迁移:先迁 slot,再随 slot 迁 key,最后更新槽位归属 RocketMQ - RocketMQ - 基础认知 - RocketMQ 解决了哪些问题? - 主要解决的是系统之间 异步通信、应用解耦、流量削峰、最终一致性 这几类问题 - Producer、Consumer、Broker、NameServer、Topic、MessageQueue 分别是什么? - Producer 就是消息生产者,负责创建并发送消息 - 本身不直接把消息随便发到某台 Broker - 而是先从 NameServer 获取 Topic 的路由信息 - 再选择一个合适的 MessageQueue - 把消息发到这个 Queue 所在的 Broker 上 - Consumer 就是消息消费者,负责订阅 Topic 并处理消息 - 通过 NameServer 感知 Topic 路由,然后从对应 Broker 拉取消息进行消费 - 消费通常还会配合 Consumer Group 一起使用,这样同一组消费者可以共同分担一个 Topic 下多个 Queue 的消息 - Broker 是 RocketMQ 的核心服务节点,真正负责 存储消息、接收生产者发送的消息、向消费者提供消息读取能力 - Broker 启动后会把自己的信息以及它上面的 Topic 路由注册到 NameServer - Producer 和 Consumer 最终都是通过 NameServer 找到 Broker,再和 Broker 交互 - NameServer 本质上是 RocketMQ 的路由注册与发现中心 - 它不负责存储业务消息,主要负责保存 Topic 和 Broker 的映射关系 - Producer 和 Consumer 启动后都会连接 NameServer,去查询某个 Topic 在哪些 Broker 上、有哪些 Queue - Topic 可以理解为消息的逻辑分类,类似一个业务主题 - 生产者是把消息发到某个 Topic,消费者也是按 Topic 订阅消息 - MessageQueue 则是 Topic 在物理实现上的更细粒度拆分单元 - 一个 Topic 往往不会只对应一个队列,而是会拆成多个 Queue,分布在不同 Broker 上 - 提高并发能力、支撑负载均衡,以及在顺序消息场景下把同一类业务消息路由到同一个 Queue - 架构原理 - NameServer 为什么可以无状态设计? - 只负责路由发现,不负责业务消息存储,也不承担复杂的一致性协调 - 每个 Broker 会把自己的路由信息注册到每一个 NameServer - NameServer 自己不产生核心状态,只是被动接收 Broker 上报的路由信息并提供查询服务 - NameServer 里的路由信息在极短时间内可能不是绝对实时一致的 - RocketMQ 的路由信息是如何注册和发现的? - Broker 启动之后,会把自己的地址信息、自己承载了哪些 Topic、每个 Topic 下有多少个 MessageQueue、读写权限等路由元数据注册到 NameServer - 注册到配置的所有 NameServer - Broker 还会持续发送心跳,NameServer 通过心跳来判断这个 Broker 是否还存活 - 长时间收不到心跳,就会把它对应的路由信息剔除掉 - Producer 和 Consumer 启动时会先拿到 NameServer 地址,然后去 NameServer 查询某个 Topic 的路由信息 - 客户端并不是直接把 Topic 硬编码到某台 Broker,而是先查路由,再直连 Broker 通信 - 每个 NameServer 基本都会保存一份相对完整的路由视图,而且多个 NameServer 之间通常不互相通信 - 如果某个 NameServer 挂了,会有什么影响? - 客户端本身已经拿到过路由信息 - 只要 Broker 没问题、客户端手里还有可用路由,业务通常还能继续跑 - 客户端正好需要刷新路由,或者刚启动时去查路由 - 只要客户端配置了多个 NameServer 地址,通常只是短暂影响 - Broker 为什么要做主从架构? - 消息中间件天然要求高可用和高可靠,主从可以通过副本复制避免单点故障 - 在 Master 宕机时尽量保证消息不丢、服务不停,并通过同步或异步复制在性能和可靠性之间做权衡 - 消息发送机制 - RocketMQ 有哪几种消息发送方式? - 同步发送:Producer 把消息发出去之后,会阻塞等待 Broker 返回发送结果,拿到成功或者失败的响应之后,才继续往下执行 - 异步发送:Producer 把消息发出去之后不会一直阻塞等待,而是注册一个回调函数,等 Broker 返回结果后,再通过回调通知发送成功还是失败 - 单向发送:只负责把请求发出去,不等待 Broker 返回结果,也没有回调确认 - 消费机制 - 同一个 Consumer Group 下多个消费者如何分摊 Queue? - 集群消费:队列级负载均衡 - 一个 Topic 下的多个 MessageQueue 会分配给同一个 Consumer Group 里的不同消费者,每个 Queue 在同一时刻只会分配给组内一个消费者处理 - 默认策略一般是平均分配 - 如果 Queue 数量少于消费者数量,那多出来的消费者可能分不到 Queue,就会处于空闲状态 - 消费位点(offset)是什么?存在哪里? - 消费位点,也就是 offset,本质上就是消费者的消费进度 - 对于某个 Consumer Group 来说,它已经消费到某个 Topic 的某个 Queue 的哪个位置了 - 消费位点默认保存在服务端,也就是 Broker 端 - 广播消费因为每个消费者都要各自消费全量消息,所以它的 offset 往往保存在客户端本地 - 消息可靠性 - RocketMQ 如何保证消息不丢失? - 不能绝对保证 100% 不丢消息,但它是通过生产端、Broker 端、消费端三层机制,把消息丢失风险尽量降到很低 - 核心业务一般要用同步发送,拿到 Broker 的成功响应再认为发送成功 - 消息到了 Broker 以后,真正决定可靠性的关键是刷盘和副本复制,高可靠场景通常会配 同步刷盘 - 消费端,RocketMQ 的语义更接近 at least once,也就是说它更倾向于宁可重复,也尽量不要丢 - 延时/定时消息 - RocketMQ 如何实现延时消息? - 消息发送到服务端后,不会立刻投递给消费者,而是要等到指定时刻才变成可消费状态 - Producer 先发送一条带延时属性的消息,Broker 收到后不会马上把它交给 Consumer,而是先根据这个消息的投递时间做暂存和时间管理 - 等到到期之后,Broker 再把这条消息重新放到正常投递链路里,消费者这时候才会像消费普通消息一样把它拉走 - 为什么延时消息不一定能做到绝对精确时间触发? - RocketMQ 追求的是‘接近指定时间投递’,而不是操作系统级实时调度 - 消息发送时间是生产者本地时间,消息生效/存储时间是 Broker 本地时间 - 时间粒度限制、Broker 侧的调度与二次写入链路、存储异常或重启带来的延迟,以及客户端和 Broker 时钟不一致 - 存储与刷盘 - RocketMQ 的消息是如何存储到磁盘的? - 消息体顺序追加写入 CommitLog,再为消费和查询额外构建索引 - Producer 把消息发到 Broker 后,Broker 不会按 Topic 各写各的物理文件,而是把所有 Topic 的消息统一顺序追加到 CommitLog 里 - CommitLog 是消息真正的物理存储文件,这种顺序写磁盘的方式吞吐高、磁盘 I/O 更友好 - 两类索引 - ConsumeQueue,它可以理解成每个 Topic 的每个 Queue 对应的一份逻辑消费索引 - 不存完整消息体,只记录这条消息在 CommitLog 里的物理偏移量、消息大小以及 Tag 相关信息 - 消费者拉消息时通常先查 ConsumeQueue,再根据里面的 offset 去 CommitLog 定位并读取真实消息 - IndexFile,主要用于按消息 Key 或时间范围做查询 - CommitLog 负责真正存消息,ConsumeQueue 负责消费定位,IndexFile 负责条件检索 - 零拷贝在 RocketMQ 里有没有用到?你怎么理解? - RocketMQ 更核心的是利用 mmap 把文件映射到用户进程地址空间,让消息写入 CommitLog、读取消息时尽量直接走内存映射和 PageCache - 减少传统 read/write 模式下用户态和内核态之间的多次数据拷贝 Agent 开发 - Agent 开发 - 基础概念 - 什么是 Agent?它和普通 Chatbot 的本质区别是什么? - 以大模型为决策核心、能够感知状态、调用工具、分解任务并执行动作,最终完成目标闭环的智能体系统。 - 什么是 tool calling / function calling?为什么 Agent 离不开它? - 让大模型不只是输出自然语言,而是能够按照约定好的结构,去选择并调用外部工具完成任务 - tool calling 做的事情,就是给模型一组可用工具,并告诉它每个工具是做什么的、参数长什么样 - 这个任务是否需要借助外部能力,如果需要,应该调用哪个工具,以及传什么参数。 - 没有 tool calling,Agent 就没有真正的行动能力 - 没有 tool calling,Agent 很难获得真实、最新、可验证的信息 - 没有 tool calling,复杂任务无法形成闭环 - Agent、Workflow、Chain 三者有什么区别? - 流程是不是固定的、模型有没有决策权、系统的可控性和灵活性分别有多强 - Chain 是最简单的一种组织形式,本质上就是把多个步骤线性串起来 - 第一步提取关键词,第二步根据关键词检索,第三步让模型总结答案 - Workflow 可以理解为比 Chain 更复杂一层的编排方式 - 那 Workflow 更像一个流程图或者状态机 - 流程仍然是预先设计的,但路径可以更丰富,控制逻辑更强,工程上也更稳定 - 不是人把流程完全写死,而是把“下一步该做什么”的部分决策权交给模型 - 面对任务时,Agent 不一定按照固定路径执行,而是会根据当前目标、上下文、工具返回结果,动态决定下一步动作 - ReAct、Plan-and-Execute、Router-based workflow 各适合什么场景? - ReAct 的特点是 Reason + Act - 模型先基于当前上下文做一步推理,决定下一步行动,拿到工具返回结果之后,再继续思考下一步 - 本质上是一种 逐步决策、逐步执行 的方式,而不是一开始就把整条路径规划好 - 适合那种 信息不完整、过程不确定、需要根据中间结果动态调整方向 - 探索型、开放型、动态性强 - Plan-and-Execute 先规划,再执行 - 是模型先根据目标把任务拆成几个较大的步骤,形成一个 plan。 - 然后再按 plan 逐步执行,每一步需要时再调用工具。 - 任务目标明确、步骤较多、链路较长 - 复杂任务、长链路任务、需要任务拆解的场景 - 前面的 plan 可能并不完全正确 - 成熟的 Plan-and-Execute 系统,通常还要允许 plan revise - 是执行过程中根据新信息修正原计划,而不是机械地照着旧计划跑 - Router-based Workflow - 先做一个 路由决策:判断当前请求属于哪一类,然后把它送进对应的固定流程 - 适合的是 任务类型相对清晰、业务边界明确、流程可预定义 的场景 - 判断它是知识问答、数据查询、代码生成、工单处理,还是闲聊; - 不同类型走不同分支,每个分支里的步骤通常是预先设计好的 - 单 Agent 和多 Agent 的优缺点分别是什么? - 让一个智能体统一负责理解、规划和执行,还是把不同职责拆给多个智能体协作完成 - 单 Agent 的核心特点是:一个 Agent 持有主要上下文,围绕用户目标统一做决策,并根据需要调用工具完成任务 - 职责容易过载。 - 当任务越来越复杂时,一个 Agent 既要理解意图,又要规划,又要调用工具,还要整合结果,这会导致 prompt 非常臃肿,上下文也会越来越重 - 多 Agent 把复杂任务拆成多个相对独立的角色或能力单元,让它们分工协作 - 更适合复杂任务分治 - 任务天然包含多个不同领域、不同能力要求的子问题时,让一个 Agent 全包并不总是最佳选择 - 后续业务增加新能力,比如新增一个“数据分析 Agent”或者“审批 Agent”,多 Agent 架构通常更容易横向扩展 - 系统复杂、协作成本高、上下文传递有损耗、时延和成本更高 - 什么叫“agent loop”?如何判断循环该停止? - agent loop,本质上就是 Agent 完成任务时的一个 闭环执行过程 - 不断重复下面这几个步骤:理解当前状态、决定下一步动作、执行动作、观察结果、再基于新结果继续决策 - 业务层面的完成条件:任务目标已经满足了,就应该停止 - 模型主动判断无需继续 - 停止条件 - 是否触发了工程兜底条件,比如最大步数、重复调用、无新信息、连续失败或成本超限 - handoff/delegation - delegation 更强调‘把子任务分派给更合适的执行者’ - 这个问题里有一部分任务,并不是自己最擅长处理的,或者交给专门模块处理会更稳,那它就把这部分任务委派出去 - 把某个子任务交给更专业的执行单元处理,再把结果拿回来继续整合 - handoff 更强调‘把当前会话或任务控制权切换给另一个角色或模块’ - 责任和上下文的转交,后续由新的主体接手推进任务 - vibe coding - 不要让 Claude 直接写代码,而是先让它“深入研究 → 写计划 → 你批注修正计划 → 再执行实现” - Research(研究) - 先让 Claude 深入阅读代码库某一部分,搞清楚系统怎么工作、有哪些边界条件、潜在 bug 在哪 - 然后把理解结果写成一个持久化的 research.md 文件 - 如果 Claude 对现有系统理解错了,后面的计划和实现都会跟着错,最后就会出现“局部看似可用,但破坏了整体系统”的情况 - Planning(规划) - 在研究结果确认后,再让 Claude 基于真实代码库写一份详细的 plan.md。 - 这份计划通常会包含:实现思路、要改哪些文件、示例代码、权衡点等 - 自己维护 markdown 计划文件,因为这样可编辑、可持久保存、也更适合审阅 - 如果某个功能在开源项目里已经有很好的参考实现,他会把那段参考代码直接贴给 Claude - Annotation Cycle(批注循环) - 纠正假设、删除不需要的方案、补充业务约束、指定技术选择,然后再让 Claude 回来“根据这些批注更新计划,但先不要实现 - 这个循环通常会重复 1 到 6 次,直到计划真正贴合项目需求 - Claude 很擅长理解代码和产出方案,但它不知道你的产品优先级、历史包袱、团队习惯和你愿意接受的工程折中 - 这些必须由人通过批注注入进去 - Claude 把计划拆成详细 todo list - Implementation(实现) - 当计划完全定稿后,作者才会下达统一的实现指令 - 全部实现、完成一个任务就更新 plan、不要半路停下来、不要写无意义注释、不要用 any/unknown、持续跑 typecheck - 到这一步时,创造性的设计决策其实已经完成了,剩下的工作应该尽量变成“机械执行” - “你没实现某个函数”“你把页面放错应用里了”“再宽一点”“还有 2px 缝隙”。因为这时 Claude 已经拥有完整上下文,简短修正就足够 - 若某次实现方向彻底错了,他甚至会直接 git 回滚,然后重新缩小需求范围 - 因为作者认为“推倒重来并重设范围”通常比在错误方案上不断打补丁效果更好 - Claude 做的是“机械劳动和候选方案生成”,而真正的架构判断、范围裁剪、接口保护和技术取舍,仍由人负责 - 研究、规划、批注、实现都放在一个长会话里连续完成,而不是拆成多个短会话 - multi-agent research - 概述 - Research 功能不是单个 Claude 一路搜到底,而是一个**lead agent(主代理)+ subagents(子代理)**的多智能体系统 - 主代理先理解用户问题、制定研究策略 - 再并行创建多个子代理,让它们分别去不同方向搜索信息 - 最后由主代理汇总,再交给一个专门的 CitationAgent 做引用定位和归因 - 研究任务特别适合多 Agent - 研究任务不是固定流程,而是开放式探索 - 研究过程天然是“边查边改路线”的 - 单轮、线性、一次性生成的 pipeline 很难做好这种任务 - 多 Agent 的核心价值 - 并行探索多个方向 - 多 Agent 可以把问题拆成多个独立方向并行搜索 - 相当于扩展了“有效上下文容量” - 每个子代理都有自己的上下文窗口。它们各自探索、压缩,再把关键结果交回主代理 - 降低路径依赖 - 单个 Agent 一旦前几步搜偏了,后面可能一路都偏 - 多个子代理独立探索,可以减少“因为早期路线选错而导致全局失败”的问题。 - 本质上是在“花更多 token 解决更难问题” - 系统架构 - orchestrator-worker 架构,也就是“编排者—执行者”模式 - 用户提交问题 - LeadResearcher 先做计划 - 主代理会把研究计划保存到 Memory 里 - 创建多个 Subagent 并行工作 - 它们会独立进行多轮搜索、评估结果、修正查询,再把结果返回给主代理 - 主代理综合结果,决定是否继续 - CitationAgent 补齐引用 - 它和传统 RAG 的本质区别是什么 - RAG:先捞资料,再回答 - Research Agent:边想、边查、边改策略、边分工 - 带工具与任务编排能力的主动式研究系统 - 总结的 8 条 Prompt / Agent 设计原则 - 先建立对 Agent 行为的直觉 - 教会 orchestrator 怎么“分工” - 努力程度要跟问题复杂度匹配 - 工具设计和工具说明极其关键 - 让 Agent 帮你改 Agent - 搜索策略要“先宽后窄” - 显式引导 thinking 过程 - 并行工具调用极大提升速度 - 怎么评估这种多 Agent 系统 - 一开始就做小规模 eval - 用 LLM-as-judge 做大规模评分 - 人工评测不可替代 - 生产问题,比算法问题还难 - Agent 是有状态的,错误会累积 - 要结合“模型适应性 + 工程保护” - 调试非常难,因为 agent 非确定性强 - 部署不能粗暴替换版本 - 当前还是同步执行,未来可能异步 - 附录 - 如何评估“会改动状态”的 Agent:看终态,不要死盯过程 - 如何管理超长对话:压缩上下文 + 外部记忆 + 新上下文接力 - 当某个阶段完成后,让 agent 先把这一阶段总结出来,再把关键内容存到外部 memory - 当上下文快满时,可以创建一个新的 subagent,给它一个干净上下文,同时通过 handoff 把必要信息传过去 - agent 还可以从 memory 里重新取回之前存下来的研究计划 - 而不是因为上下文截断把前面的工作丢掉 - 让子 Agent 直接把结果写到文件系统,减少“传话失真” - 不要让子代理把所有内容都先口头汇报给主代理,再由主代理转述。 - 更好的方式是让子代理直接把产物写到某个外部 artifact / filesystem 里,只把轻量引用返回给主代理 - “传话游戏效应”——信息每转述一层,就更容易被压缩、改写、漏掉 - - 评估上看终态,运行上靠记忆与上下文接力,产物上让子代理直接落盘而不是层层转述 - Prompt 与工具调用设计 - 你会怎么给 Agent 设计 system prompt? - 定义角色和目标 - 明确告诉 Agent:你是谁,你的职责是什么,你服务的对象是谁,你最终要优化的目标是什么 - 定义决策原则 - Agent 和普通问答模型最大的区别在于,它需要不断判断:现在该直接回答,还是该调用工具,还是该进一步检索,还是应该拒绝 - 信息不足时,不要猜测,要优先检索或调用工具 - 涉及外部事实、实时信息、业务数据时,不要只靠参数知识回答 - 如果工具可以更可靠地完成任务,优先用工具 - 如果当前证据已经足够,就不要过度调用工具 - 定义工具使用规范 - 什么时候该用工具、什么时候不该用、如何处理工具返回结果 - 定义输出格式和交互风格 - Agent 的结果往往不是单纯给用户看,有时还要给下游系统消费,或者作为下一步流程输入 - 是定义边界、风险和兜底策略 - 权限边界、敏感操作、数据泄露风险、人工接管条件,这些都应该提前定义 - 模块 - Role:你是谁,服务谁,目标是什么 - Capabilities:你有哪些工具和能力 - Decision Policy:什么时候检索,什么时候调用工具,什么时候停止 - Tool Rules:工具选择、参数使用、失败处理 - Output Rules:回答格式、结构化输出、引用方式 - Safety / Boundaries:权限、安全、拒答、人工兜底 - 工具返回结果过长时,你怎么做压缩和摘要? - 关键信息被淹没、模型注意力分散、后续决策质量下降 - 先做结构化裁剪,再做分层摘要,最后按任务目标回注上下文,而不是一次性全量喂给模型 - 当前这段长结果里,哪些内容对当前目标是高价值的,哪些只是噪声 - 优先做结构化提取,而不是直接写自然语言摘要 - 先把长结果压成一个 结构化中间表示,比如 key-value、JSON、表格化摘要或者 bullet points - 分层压缩,而不是只做一层摘要 - 粗裁剪,也就是先把明显无关、重复、噪声大的内容去掉 - 关键信息摘要,也就是保留当前任务真正需要的核心事实 - 可追溯引用层,也就是虽然正文里不全放原文,但要保留原始片段索引、来源 ID、文档位置、URL 或 chunk 标识 - 区分“给模型看的摘要”和“给用户看的摘要” - Agent 架构与编排 - LangGraph 这类图式编排相比纯 prompt agent 的优势是什么? - 纯 prompt agent 更像是把任务尽量交给模型在一个循环里自由决策 - LangGraph 这类图式编排,是把系统拆成显式的状态、节点和边,让‘哪些地方交给模型决策,哪些地方由程序控制’变得更清楚 - 纯 prompt agent 很多时候本质上是“消息历史 + system prompt + tool loop” - LangGraph 的核心建模方式是 State + Nodes + Edges - 用共享 state 表示应用当前状态,用 nodes 执行业务逻辑,用 edges 控制固定或条件跳转 - workflow 和 agent:workflow 是预定义路径,agent 是动态决定流程和工具使用 - durable execution:也就是长时间运行、失败后可从中断点恢复。 - human-in-the-loop 直接作为核心能力之一,支持在执行过程中检查和修改 agent state - 图式编排最大的工程收益之一,就是把“Agent 的执行过程”从黑盒,变成半透明甚至可观测的系统。 - 官方把短期记忆直接纳入 agent state,并通过 checkpointer 持久化,使线程可以恢复;长期记忆则通过 store 跨会话保存 - 如何设计 agent state? - state 不是把所有信息都塞进去,而是只保留那些需要跨步骤持续存在、会影响后续决策、或者需要被恢复与追踪的信息 - 如果一条信息只在当前节点临时计算用一次,那它不应该进 state - 任务目标与任务上下文 - 执行过程状态 - 工作记忆,也就是对后续决策有价值的中间产物 - 控制信息 - 可观测与恢复相关信息 - 多 Agent 之间如何共享上下文 - 不是让所有 Agent 都看到全部信息,而是让每个 Agent 在合适的时机拿到完成自己任务所需的最小必要上下文 - 是全局任务上下文 - 所有 Agent 都可能需要知道的基础信息,比如用户目标是什么、当前任务 ID、任务的主约束是什么、当前系统处在哪个大阶段 - 局部执行上下文 - 某个 Agent 为了完成自己职责所必需的信息 - 共享上下文不是共享全部历史,而是共享经过裁剪和重组后的任务视图。 - 是中间产物上下文 - Agent 之间尽量传“结构化中间结果”,而不是传“大段原始对话和原始输出“。 - 方式 - 是共享状态仓,也就是 shared state / blackboard。 - 共享的是状态,不是无限扩展的聊天记录 - handoff 时显式传递上下文包 - 当前要解决的子问题是什么 - 为什么要转给这个 Agent - 已经完成了哪些步骤 - 当前有哪些关键事实或证据 - 还有哪些不确定点 - 通过外部记忆或工件存储共享 - RAG 和 Agent 结合 - RAG 和 Agent 的关系是什么?什么时候只用 RAG 就够了? - RAG 主要解决的是‘让模型拿到更相关、更真实的外部知识再回答’ - Agent 主要解决的是‘让模型围绕目标动态决策、调用工具并完成任务’ - RAG,也就是 Retrieval-Augmented Generation,本质上是“先检索,再生成”。 - 检索前置(retrieve-then-read)和动态检索(decide-whether-to-retrieve)怎么选? - 检索前置,也就是 retrieve-then-read:不管用户问什么,先检索,再让模型基于检索结果阅读和回答 - 检索是默认前置步骤,系统不需要先判断“要不要检索”,而是统一走“先找资料、再生成答案”的固定流程 - 动态检索,也就是 decide-whether-to-retrieve - 先让系统判断当前问题到底要不要检索,如果需要,再决定检索什么、检索几轮;如果不需要,就直接基于已有上下文回答 - 更灵活、更节省资源 - 召回、重排、生成分别可能出什么问题? - RAG 的问题要分层看,召回解决‘找不找得到’,重排解决‘排得准不准’ - 召回阶段 - 从大量候选知识里尽可能不要漏掉真正有用的内容 - 问题 - 召回不足,也就是该找的没找出来 - 召回噪声太多 - query 与知识表达不匹配 - 切块和索引策略问题 - 重排阶段 - 在召回回来的候选集里,把真正最值得给模型看的内容排到前面 - 问题 - 高相关内容没排到前面 - 表面相关高,但任务相关低 - 多片段信息没有被整体考虑 - 排序结果缺乏多样性 - 生成阶段 - 没有忠实使用检索证据 - 幻觉和过度补全 - 证据整合能力不足 - 如何让 Agent 基于检索证据回答,而不是凭空补全? - 先保证 Agent 真正拿到了足够好的证据 - 要把“证据”和“任务”在上下文里组织清楚,而不是一股脑把文本塞进去 - 在 prompt 和输出协议里明确要求“答案必须可追溯到证据” - 要让 Agent 在证据不足时优先补检索,而不是硬答 - 要通过评测专门检查它是不是在‘基于证据回答’,而不是只看答案像不像对 - 如何评估 RAG Agent:看 answer correctness 还是看 trajectory? - answer correctness - 你最后给的答案是不是对的,是不是完整,是不是有用 - answer correctness 决定系统有没有业务价值下限 - trajectory - 在 Agent 场景里,证据不足时有没有继续检索而不是直接生成 - trajectory 评估的是过程层 - Memory 记忆 - 短期记忆(short-term memory)和长期记忆(long-term memory)分别是什么? - 在 Agent 系统里,记忆的本质不是“把信息存起来”这么简单 - 而是让系统在合适的时间保留合适的信息,从而支持多轮交互、任务连续性和个性化决策。 - 短期记忆 - Agent 在当前会话或当前任务执行过程中使用的工作记忆 - 它是任务相关的 - 它是动态变化的 - 它通常生命周期较短 - 它更偏执行态 - 长期记忆 - 那些跨会话、跨任务仍然有价值的信息 - 下次再见到这个用户或类似场景时,系统能不能利用过去沉淀的信息做得更好 - Agent 的 长期知识沉淀 或 跨任务可复用经验层 - 跨会话复用的 - 更偏稳定信息 - 需要检索或选择性注入 - 更偏个性化和沉淀化 - 会话历史太长导致上下文爆炸时怎么处理? - 先区分“哪些历史真的有必要继续带着”,而不是把整个聊天记录原样续上 - 当前任务强相关信息 - 阶段性中间过程信息 - 低价值历史噪声 - 做分层压缩,而不是简单截断 - 原始最近几轮对话:保留高保真上下文,因为最近几轮通常最直接影响当前回答 - 中期历史摘要:把较早但仍有价值的内容压成结构化摘要 - 长期稳定事实:把跨轮仍然有效的用户偏好、任务背景、关键约束单独沉淀出来 - 把“消息历史”和“任务状态”分开管理 - 很多真正重要的东西不应该只存在于对话文本里,而应该被提炼成显式 state - 聊天记录负责保留交互表面,state 负责保留任务骨架 - 根据任务阶段动态构造上下文,而不是固定拼接 - 评测 - 你如何评估一个 Agent 是否“好用”? - 先看结果层:它到底有没有把事做成 - task success rate,也就是任务成功率 - groundedness 或可验证性。 - 结果正确性和完整性,factual correctness - 再看过程层:它是不是稳定、合理地完成任务 - 决策路径是否合理 - 有没有重复劳动和无效动作 - 在失败时怎么表现 - 再看工程层:成本、延迟、稳定性是否可接受 - 在线评测和离线评测有什么区别? - 离线评测:在一个预先准备好的测试集、基准集或者人工构造的 case 集上,对系统进行批量评估 - 在线评测:在真实流量、真实用户、真实业务环境下,对系统效果进行观测和比较 - 什么是 task success rate? - 用户交给 Agent 的任务,最后到底有没有被成功完成 - task success rate 就是 任务成功率 - 如何构造 eval dataset - eval dataset 要把真实业务目标、典型任务分布、关键风险场景和失败案例系统化地沉淀成一个可持续迭代的数据集 - 先定义评什么,而不是先收样本 - 用“任务分布”来采样,而不是只收容易做的题 - 人工精选样本 - 真实历史样本 - 定向构造样本 - 做分层分桶,而不是把所有样本混成一锅 - 每条样本不只要有输入,还要有“判分依据” - Nacos 配置中心 - Nacos 配置中心 - Nacos 是什么?解决了什么问题? - 服务实例很多,IP 和端口是动态变化的,如果没有注册中心,服务之间就很难稳定地互相发现和调用 - 配置分散在各个服务本地,不方便统一管理,一旦数据库地址、限流阈值、开关参数变更,往往需要改配置、重启甚至重新发布 - Nacos 通过服务注册与发现能力解决了服务寻址问题,通过配置中心能力解决了配置集中管理、动态推送和环境隔离问题 - Nacos 为什么既能做注册中心,又能做配置中心? - 底层都是在做一件事:集中管理分布式系统中的动态元数据 - 注册中心管理的是服务实例信息 - 配置中心管理的是应用配置数据 - 服务注册与发现流程是怎样的? - 服务提供者启动时,会把自己的服务名、IP、端口、实例信息、权重、元数据等注册到 Nacos Server - Nacos 会把这个服务对应的实例列表维护起来,并根据健康检查结果更新可用状态 - 服务消费者启动时,会向 Nacos 订阅自己关心的服务,比如某个订单服务或者用户服务,Nacos 会把当前可用的实例列表返回给消费者 - 后续如果这个服务有新增实例、实例下线或者健康状态变化,Nacos 还会把变更后的服务列表推送给消费者 - 本地一般会缓存一份服务列表,真正发起 RPC 或 HTTP 调用时,再基于本地拿到的实例列表做负载均衡,选出一个目标实例去调用 - 服务提供者注册、Nacos 维护实例健康状态、服务消费者订阅服务、消费者基于本地缓存做调用 - 服务实例注册到 Nacos 后,消费者是怎么发现它的? - 同时后续如果有实例上线、下线或者状态变化,Nacos 还会把最新列表推送给消费者,所以消费者本地一般会维护一份服务列表缓存 - 真正发起调用时,消费者并不是每次都实时去查 Nacos,而是优先从本地缓存里拿可用实例,再结合负载均衡策略 - 客户端是如何监听配置变化的? - 注册监听器 + 长轮询 + 配置变更后回调刷新 - 应用启动时,客户端会针对某个 DataId 和 Group 向 Nacos Server 注册监听器,并把自己本地缓存的配置内容摘要,比如 MD5,一起带过去 - 服务端收到后不会立刻断开,而是采用 长轮询 的方式挂住这个请求:如果在这段时间内发现服务端配置的 MD5 和客户端传来的 MD5 不一致,就说明配置发生了变化,这时会立即返回变更结果 - 如果一直没有变化,请求大概在 30 秒左右超时返回空结果 - 客户端拿到响应后,如果发现配置变了,就会再去服务端拉取最新配置内容,更新本地缓存,并触发对应的回调逻辑 WebSocket - WebSocket - 解决了什么问题 - 一种基于 TCP 的应用层全双工通信协议 - 核心价值是让客户端和服务端在建立一次连接之后,可以进行双向、实时、持续的数据传 - HTTP 在实时通信场景下效率不高的问题 - HTTP 本质上是半双工、请求驱动的,服务端不能主动推送消息给客户端 - WebSocket 就是为了解决 HTTP 在实时双向通信上的天然不足而设计的,它把频繁的请求响应,变成了一次建连后的持续双向通信 - WebSocket 和 SSE - WebSocket 适合双向实时通信,SSE 更适合服务端单向推送 - 是一个独立的全双工协议,基于 TCP,建立连接之后,客户端和服务端都可以主动发消息,所以它特别适合那种双向交互频繁的场景 - 只能由服务端主动推送给客户端,客户端不能通过同一条连接反向发消息 - 服务端持续推送、但客户端不需要频繁反向交互 - WebSocket 可以发文本也可以发二进制,SSE 主要是文本流 - WebSocket 握手成功后脱离传统 HTTP 请求响应模式,而 SSE 本质上还是一个持续不断开的 HTTP 响应流 - WebSocket 为什么需要先走一次 HTTP 握手? - WebSocket 之所以先走一次 HTTP 握手,本质上是为了完成协议升级和连接协商 - 复用现有的 HTTP 基础设施 - 在握手阶段完成能力协商 - 可以顺带复用 HTTP 阶段已有的认证和上下文 - WebSocket 建立连接的握手过程是怎样的 - 客户端先发起一个 HTTP/1.1 请求,请求行通常是 GET - 在请求头里带上 Upgrade: websocket 和 Connection: Upgrade,表示希望把当前这条 TCP 连接从 HTTP 升级成 WebSocket - 同时还会带上 Sec-WebSocket-Key,这是客户端生成的一个随机值 - Sec-WebSocket-Version: 13,表示自己使用的 WebSocket 协议版本 - 服务端收到请求之后,会先校验这是不是一个合法的 WebSocket 升级请求 - 响应头里同样会带 Upgrade: websocket 和 Connection: Upgrade,最关键的是会返回 Sec-WebSocket-Accept - 服务端拿客户端发来的 Sec-WebSocket-Key,拼接一个固定 GUID,再做 SHA-1 和 Base64 得到的 - 服务端确实理解 WebSocket 协议,也愿意建立这条连接 - 从这一刻开始,这条连接就不再按传统 HTTP 的请求-响应模式通信了,而是进入 WebSocket 的数据帧通信阶段 - 客户端和服务端都可以在这条长连接上主动发送消息,实现全双工通信 - WebSocket 传输的数据格式是什么?文本和二进制怎么区分? - WebSocket 在传输层面不是直接发一整块裸数据,而是按“数据帧”来传输的 - 应用层消息会被封装成一个个 WebSocket Frame,再在 TCP 连接上发送 - 文本和二进制的区分,核心看 opcode - 文本帧的 payload 按协议要求应该是 UTF-8 编码文本,二进制帧的 payload 则是原始二进制数据,具体怎么解释交给应用层 - 接收文本消息时通常直接拿到字符串;接收二进制消息时,浏览器会根据 binaryType 把它交给你,常见是 ArrayBuffer 或 Blob - WebSocket 帧结构了解吗? - WebSocket 在建立连接之后,双方传输的数据不是直接裸发业务内容,而是要封装成一个个 Frame,也就是数据帧 - 前面是帧头,后面是 Payload Data。帧头里最核心的字段有 FIN、RSV1/RSV2/RSV3、opcode、MASK、Payload Length - FIN,它表示这是不是当前消息的最后一个分片 - opcode,这是帧结构里最关键的字段之一,它决定这个帧是什么类型 - MASK 和 Masking Key 主要是给客户端到服务端方向用的 - 如果 MASK=1,说明 payload 被掩码处理了,这时帧里会多一个 4 字节的 masking key - 协议要求客户端发给服务端的帧都必须带 mask,所以浏览器发出去的消息一般都是 masked;服务端回给客户端通常不加 mask - WebSocket 的帧结构本质上就是“帧头 + 负载”,帧头负责描述消息边界、消息类型、是否掩码、负载长度等信息,负载里才是真正的业务数据 - WebSocket 为什么要有 masking,为什么客户端要 mask、服务端通常不 mask - WebSocket 的 masking,本质上是一种对帧 payload 做按位异或处理的机制 - 而是防止中间网络设备被“误导”或者被攻击 - 为什么服务端通常不 mask,是因为这个防护模型主要针对的是“浏览器里的不可信客户端代码” - 断线重连怎么设计才合理? - 什么时候该重连 - 一类是收到了 close 事件; - 一类是底层连接异常,比如 error 或 TCP 被动断开; - 还有一类是“假在线”,也就是连接表面还在,但心跳超时、长时间收不到服务端响应,这种情况也应该主动判定为失效并触发重连 - 重连一定要做退避,而不是立即无限重试 - 区分“可重连”和“没必要重连”的关闭场景 - 重连成功后不能只恢复连接,还要恢复业务状态 - 要做好上限控制和降级处理 - 消息推送为什么常和 Redis、MQ 一起使用? - WebSocket 只解决了“连接通道”的问题,但没有解决分布式场景下的消息路由、跨节点转发、削峰解耦和可靠投递问题 - 实际做消息推送时,通常会把 WebSocket 和 Redis、MQ 组合起来使用 - Redis 常见的作用有两个 - 连接路由信息存储 - 节点间的轻量级消息分发 - 没有 WebSocket,就很难高效实时推到前端 - 没有 Redis,就很难在集群里快速找到用户连在哪台机器 - 没有 MQ,就很难优雅处理高并发、异步化和消息可靠性 - 为什么有些场景需要 sticky session? - Sticky session,也叫会话粘滞,本质上是让同一个用户的后续请求,尽量持续落到同一台服务器上 - 这些场景里的状态是保存在单机内存里的,或者说连接和上下文是和某台具体机器强绑定的 - sticky session 本质上是一种工程上的折中方案,不是最理想的终态 - 能无状态就尽量无状态,把 Session 放到 Redis,把用户路由放到集中式存储,把消息转发交给 MQ 或 Redis Pub/Sub - WSS 和 WS 的区别是什么? - ws:// 类似于 HTTP,数据在传输过程中默认不经过 TLS 加密 - wss:// 类似于 HTTPS,是在 WebSocket 外面再套一层 TLS,所以它具备加密传输、身份校验和更好的抗窃听、抗篡改能力 - Token 放在什么位置比较常见? - 如果是浏览器场景,最常见的几种位置是 Cookie、URL 查询参数,或者在连接建立后作为第一条业务消息发送 - 心跳是前端做还是后端做?一般怎么设计? - WebSocket 心跳不是单纯前端做,也不是单纯后端做,而是要前后端配合做 - 心跳更偏向后端主导;如果从浏览器场景落地看,通常又要补一层前端应用级心跳 - 生产里比较常见的设计是“双层心跳” - 服务端协议级心跳 - 服务端定时给客户端发 Ping,客户端按协议自动回 Pong,这一层主要解决连接保活和基础探活问题 - 浏览器应用级心跳 - 前端定时发一条轻量业务消息,比如 "ping" 或带时间戳的 heartbeat - 服务端回 "pong" 或 ack - 这一层主要解决浏览器侧无法直接操作 Ping/Pong、以及业务层需要感知延迟、假在线、会话状态的问题 - 服务端负责统一治理连接,前端负责尽早感知用户侧链路异常 - 心跳间隔一般不会设计得特别短 - 如何判断 WebSocket 断开了? - 判断 WebSocket 断开,不能只靠一个信号,通常要分成“显式断开”和“隐式断开”两类来看。 - 连接已经被浏览器或服务端明确关闭了,这时候最直接的判断方式就是监听 close 事件 - 很多时候 WebSocket 不是“明确断了”,而是“假在线 - 如何避免网络抖动导致频繁重连? - 不能一断就立刻无限重连,核心做法是“指数退避 + 随机抖动” - 不要只靠 close 事件判断,要把“真断开”和“假在线”区分开 - 要给重连加“稳定窗口”和失败阈值,而不是一次超时就判死 - 要按关闭原因决定是否立刻重连 - 服务端也要配合,不然客户端再聪明也容易抖 - WebSocket 为什么比长轮询更适合实时场景? - 长轮询本质上还是客户端驱动的:客户端发一个 HTTP 请求,服务端如果暂时没数据就挂住,等有数据再返回 - 客户端收到后还要再发下一次请求 - 它始终存在“本次响应结束到下次请求发起之间”的衔接成本 - 长轮询每来一条消息,本质上都伴随着一次 HTTP 请求和一次 HTTP 响应,里面有请求行、响应行、Header 等一整套开销 - 长轮询在这种场景下会不断地“请求—响应—再请求”,服务端需要频繁创建和回收 HTTP 请求上下文 Linux - Linux - 基础认知 - Linux 的内核态和用户态有什么区别? - 用户态是应用程序运行的受限环境,内核态是操作系统内核运行的特权环境 - 用户态下,程序不能直接访问硬件,也不能随意执行像内存管理、进程调度、文件系统操作这类敏感指令; - 而内核态拥有最高权限,可以直接操作 CPU、内存、磁盘、网卡等系统资源 - Linux 中一切皆文件是什么意思? - Linux 会把大多数资源都抽象成统一的文件接口来管理和访问 - 对上层程序来说,很多资源都可以用类似的方式操作 - 比如打开、读取、写入、关闭,也就是常说的 open、read、write、close 这一套 - Linux 在设计上把很多资源统一抽象成了“文件”这种访问模型 - Linux 系统的目录结构有哪些?各自是做什么的? - Linux 是一个统一的目录树,不管是本地磁盘、移动硬盘,还是网络存储,最终都会挂载到这棵树的某个目录下面 - /bin 存放普通用户也能使用的基础命令,比如 ls、cp、cat - /sbin 存放更多偏系统管理的命令,比如网络配置、磁盘管理这类,通常 root 用得更多 - /usr 可以理解为系统级应用和共享资源目录,里面也有 bin、sbin、lib 这些子目录 - /etc 非常重要,它主要放系统和应用的配置文件,比如用户配置、网络配置、服务启动配置等 - /home 是普通用户的家目录,每个用户一般在这里有自己的工作空间 - /root 是 root 用户自己的家目录 - /tmp 用来放临时文件,系统重启后很多内容可能会被清理掉 - /var 存放经常变化的数据,比如日志、缓存、队列、邮件这些,所以像排查问题时经常会去看 /var/log - /dev 存放设备文件,因为 Linux 里很多硬件设备也被抽象成文件 - /proc 和 /sys 则更偏内核和系统信息,/proc 里能看到进程和内核运行时状态,/sys 更多是设备和内核参数的接口 - 配置看 /etc,用户数据看 /home,日志看 /var/log,设备看 /dev,进程和系统状态看 /proc,临时文件看 /tmp - `/bin`、`/sbin`、`/usr/bin`、`/usr/sbin` 有什么区别? - /bin 主要放最基础、最常用的用户命令,比如 ls、cp、mv、cat 这类,即使系统进入单用户模式,通常也要保证这些命令可用 - /sbin 放的是更偏系统管理和维护的命令,比如 fdisk、iptables、mount 这类,一般是管理员或者 root 更常使用 - /usr/bin 和 /usr/sbin 可以理解为 /bin 和 /sbin 的扩展 - /usr/bin 里放的是大量普通用户程序和应用命令 - /usr/sbin 里放的是非核心但同样偏管理类的系统命令 - 在现代 Linux 发行版里,这几个目录很多时候已经做了合并或者软链接处理,比如 /bin 可能直接链接到 /usr/bin - Linux 的启动流程大致是什么? - BIOS 或 UEFI 加电自检 - 加载 BootLoader,最典型的就是 GRUB - BootLoader 的作用是把操作系统内核从磁盘加载到内存里,同时把启动参数传给内核 - Linux 内核启动 - 内核被加载到内存之后,会先初始化最核心的系统能力,比如内存管理、进程调度、中断处理、设备驱动这些 - 然后内核会挂载根文件系统,并启动第一个用户态进程 - 启动 init 进程 - 早期这个进程通常是 init,进程号是 PID 1 - 现在大多数发行版用的是 systemd,本质上它也是用户空间的第一个核心进程 - 它负责继续拉起后续所有用户空间服务,并负责系统初始化工作 - 启动各类系统服务和登录环境 - Linux 的环境变量有哪些?`PATH` 是什么? - Linux 的环境变量,本质上就是 操作系统传递给进程的一组键值对配置 - 告诉程序当前运行环境是什么 - 通过这些环境变量知道自己的用户是谁、当前家目录在哪、命令去哪里找、默认语言是什么等 - PATH 是最重要的一个,它表示命令搜索路径 - 告诉 Shell,输入一个命令名时,应该去哪些目录下查找可执行程序 - 常用环境变量 - 比如 HOME 表示当前用户的家目录 - USER 或 LOGNAME 表示当前用户名 - PWD 表示当前工作目录 - SHELL 表示当前使用的 Shell - HOSTNAME 表示主机名 - LANG 表示语言和地区设置 -JAVA_HOME 这种则是应用程序自己约定的环境变量,用来指定 JDK 安装目录 - 环境变量分成临时生效和永久生效两种 - 在当前终端里执行 export PATH=/opt/bin:$PATH,这是临时修改 - 只对当前 Shell 和它启动的子进程有效 - 想永久生效,通常会写到 ~/.bashrc、~/.bash_profile 或 /etc/profile 这类配置文件里 - 文件与目录操作 - 常见文件操作命令有哪些? - 查看、创建、复制、移动、删除 这几类 - ls,它是用来查看目录内容的 - touch,是一个比较轻量的文件创建和时间戳更新命令 - cp 是复制命令,用来把文件或者目录复制到另一个位置 - mv 是移动命令,也常拿来做重命名 - rm 是删除命令,用来删除文件或目录 - ls -l 输出的各列分别表示什么? - 第一列是 文件类型和权限信息 - 第二列是 硬链接数 - 第三列是 属主,也就是这个文件归哪个用户所有 - 第四列是 属组,表示这个文件属于哪个用户组 - 第五列是 文件大小,单位默认是字节 - 目录,这个大小并不等于目录下所有文件大小之和,而只是目录项本身占用的空间 - 第六到第八列通常是 最后修改时间 - 最后一列是 文件名 - 如果是软链接,通常还会看到类似 link -> target 这样的形式 - 如何查找某个文件? - 最常用的命令是 find - 按文件名、类型、大小、时间、权限等条件去搜索指定目录 - 在当前目录及其子目录里查找一个叫 app.log 的文件,会用 find . -name "app.log" - 查找目录可以用 -type d,查找普通文件可以用 -type f - 按大小查可以用 -size,按修改时间查可以用 -mtime - 不仅能回答“这个文件在哪”,还可以回答“满足某些条件的文件有哪些” - 如果只是想更快地按文件名查,而且系统已经建立过文件索引,也可以用 locate - 查的是数据库,不是实时遍历磁盘 - 什么是 inode?文件名和 inode 的关系是什么? - inode 是 Linux 文件系统里用来描述文件元数据的数据结构 - 是“文件的身份证”,里面记录的是这个文件的关键信息,比如文件大小、权限、属主属组、时间戳、数据块位置 - inode 里通常不直接存文件名,文件名实际上是保存在目录这个特殊文件里的 - 目录里保存的是“文件名 -> inode 编号”的映射关系 - 系统拿到文件名后,先到对应目录里找到 inode 编号,再根据 inode 里的信息去定位真实的数据块 - 文件名更像是给人看的入口,inode 才是操作系统真正识别文件的依据 - 硬链接 本质上就是多个文件名指向同一个 inode,所以它们其实是同一个文件,只是有多个名字 - 软链接 则不一样,软链接有自己独立的 inode,它保存的是目标文件的路径 - 进程与线程 - `top` 命令你怎么用?重点看哪些指标? - 第一块是 load average,也就是 1 分钟、5 分钟、15 分钟的平均负载 - 如果 load 很高,而 CPU 不高,那我会怀疑是不是有大量进程阻塞在 IO 上 - 第二块我会看 CPU 使用情况,重点关注 us、sy、id、wa 这几个值 - us 表示用户态 CPU 占用 - sy 表示内核态占用 - id 是空闲比例 - wa 是等待 IO 的时间 - 内存区域 - 重点看总内存、已用内存、空闲内存以及 buff/cache - 进程列表 - 先按 P 看 CPU 排序 - 按 M 看内存排序,快速找到最消耗资源的进程 - PID、USER、%CPU、%MEM、VIRT、RES、SHR、S - 其中 %CPU 看谁最耗 CPU - %MEM 看谁最占内存 - VIRT 是虚拟内存 - RES 是实际驻留物理内存 - S 是进程状态,比如运行中、睡眠中、僵尸状态等 - 如果我发现某个 Java 进程 CPU 很高,我下一步通常会结合 ps、jstack 去看具体是哪个线程在忙 - 按 P 按 CPU 排序,按 M 按内存排序,按 H 可以看线程级别,排查 Java 高 CPU 时很常用 - `kill`、`kill -9`、`pkill`、`killall` 有什么区别? - 本质上都是给进程发送信号,让进程结束或者执行某种动作 - kill,它是最基础的命令,按 PID 给进程发信号 - 这个信号比较“温和”,意思是通知进程你该退出了,进程可以先做一些善后操作 - kill -9 本质上还是 kill,只是它发的是 SIGKILL,也就是 9 号信号 - 这个信号是强制杀死,进程不能捕获、不能忽略、不能自行处理,内核会直接把它干掉 - pkill,它和 kill 的主要区别是:kill 按 PID,pkill 按进程名或条件匹配 - killall 也是按进程名杀进程,它会把同名的进程都处理掉 - 什么是前台进程和后台进程? - 前台进程会占用当前终端,和用户直接交互 - 后台进程不占用当前终端,启动后可以在后台继续运行 - 父进程和子进程的关系是什么? - 子进程是由父进程创建出来的一个新进程,它们是两个独立的进程 - 但子进程在创建初期会继承父进程的一部分运行环境 - 通常父进程通过 fork 来创建子进程 - 从操作系统角度看,它们是两个平级的进程,不是说子进程只是父进程里的一个线程,这一点要区分清楚 - 会继承父进程的一些上下文信息,比如环境变量、当前工作目录、打开的文件描述符、用户身份、信号处理方式等 - 如果子进程结束了,但父进程一直不回收它的状态信息,就会形成 僵尸进程 - 如果父进程先退出了,而子进程还在运行,那这个子进程就会变成 孤儿进程 - 一个进程占用 CPU 很高,你怎么排查? - 我会先用 top 或者 ps -ef、ps aux 确认到底是哪个进程占用 CPU 高 - 是 Java 进程,我通常会先记下它的 PID - 会进一步用 top -Hp <pid> 看这个进程里到底是哪个线程占用 CPU 高 - 拿到高 CPU 线程之后,我会把线程 ID 转成十六进制 - 像 Java 的线程栈信息里,线程 ID 一般是十六进制展示的 - 如果是 Java 应用,我会用 jstack <pid> 导出线程栈,然后搜索对应线程 ID - 找到这个高 CPU 线程当前到底在执行什么代码 - 结合业务场景判断原因 - 死循环或空转、自旋锁竞争、频繁 GC、算法复杂度过高、某些异常逻辑导致无限重试,或者流量突然增大导致计算压力上升 - 系统态 CPU 也高,我还会怀疑是不是系统调用、网络中断、上下文切换过多这类内核层问题 - 看是不是偶发还是持续问题 - 一个进程占用内存很高,你怎么排查? - 先用系统命令确认到底是谁占用了内存 - 用 top、ps aux --sort=-%mem、free -h 先看整体机器内存是否真的紧张 - 排查内存问题时更关注 RES,也就是实际驻留物理内存,而不是只看 VIRT - 判断这是正常业务占用,还是异常持续增长 - 一种是机器已经开始频繁使用 swap,性能明显下降 - 另一种是进程内存持续上涨,而且不回落 - 如果是 Java 进程,我通常会重点看 JVM 堆的使用情况 - 先用 jstat -gc <pid> 看 GC 和各代内存变化,判断是年轻代、老年代还是元空间压力大 - 再用 jmap -histo <pid> 看当前对象直方图 - 怀疑有内存泄漏,我会进一步导出 heap dump - 如果不是 Java 进程,我会看是不是有资源泄漏 - 内存管理 - Linux 内存分为哪些区域? - 代码段、数据段、BSS 段、堆、栈,以及内存映射区 - 代码段,也叫 text 段,主要存放程序的机器指令,也就是可执行代码 - 数据段,主要存放已经初始化的全局变量和静态变量 - 堆。堆是程序运行时动态申请内存的区域 - 栈。栈主要用于保存函数调用过程中的局部变量、参数、返回地址、寄存器现场等 - 内存映射区,也就是 mmap 区。它主要用于共享库加载、文件映射、匿名映射等 - `free -h` 的输出怎么看? - free -h 是 Linux 里查看内存使用情况最常用的命令之一 - -h 表示 human-readable,也就是把结果按 KB、MB、GB 这种更容易读的单位展示出来 - 第一列 total 表示总内存,也就是这台机器物理内存总共有多少 - 第二列 used 表示已经被使用的内存,但这里要注意,它不是单纯表示“真正被业务进程占满”的内存 - Linux 会把一部分空闲内存拿去做缓存,所以这个值看起来通常会比较大 - 第三列 free 表示当前完全空闲、还没有被使用的内存 - available。判断系统内存是否真的紧张,要重点看 available,而不是只看 free - available 表示在不明显影响系统运行的前提下,当前大概还能提供给新进程使用的内存 - 磁盘与文件系统 - `df -h` 和 `du -sh` 的区别是什么? - df 看的是文件系统层面的磁盘使用情况 - df -h 更适合回答“这块盘整体还有没有空间”这个问题 - 线上如果有人说磁盘快满了,我通常先看 df -h,因为我要先知道到底是哪个挂载点满了 - du 看的是目录或文件本身占用了多少空间 - 磁盘满了怎么排查? - 先用 df -h 看整体磁盘使用情况,先确认到底是哪个挂载点满了 - 因为只有先定位到具体文件系统,后面的排查才有方向 - 确认了具体盘之后,我会进入对应目录 - 用 du -sh * 或者 du -h --max-depth=1 一层层往下找,定位到底是哪个目录占用最大 - 找到大目录以后,我会继续往下定位到具体大文件 - find /path -type f -size +500M 这种方式 - 网络基础 - `ifconfig` 和 `ip addr` 有什么区别? - ifconfig 和 ip addr 都可以用来查看和配置网卡信息 - ifconfig 是传统工具 - ip addr 是现在更推荐的现代工具 - ifconfig 看网卡信息,ifconfig eth0 up 启动网卡 - ip addr 主要用来查看和管理 IP 地址 - 日常查看 IP 地址我更倾向用 ip addr,因为这是现代 Linux 的推荐方式 - `netstat` 和 `ss` 的区别是什么? - netstat 是传统工具,ss 是现在更推荐的工具 - 常用 netstat -tunlp 看监听端口,或者看 TCP 连接状态 - ss 本质上也是看 socket 信息,但它获取数据的方式更高效,尤其是在连接数很大的机器上,查询速度通常比 netstat 更快 - 如何抓包分析网络问题? - 先明确问题现象。比如到底是连不上、连接很慢、请求发不出去、响应回不来、还是频繁重传和超时 - 会先做一些基础确认,而不是立刻抓包。 - 先用 ping 看网络是否可达 - 用 telnet、nc 或 curl 看目标端口和接口能不能通 - 用 ss 或 netstat 看本地连接状态和监听情况 - 如果基础命令还不能定位,我才会开始抓包 - Linux 下我最常用的是 tcpdump - 尽量缩小范围,比如指定网卡、主机、端口、协议,减少无关数据 - 抓和某个服务端口相关的包,我会按 目标 IP 加端口 去抓 - 看几个关键点。 - 三次握手有没有成功 - 连 SYN 都发不出去,可能是本机路由、防火墙或者网卡问题 - 发了 SYN 但收不到 SYN-ACK,可能是中间网络丢包、目标机器不可达,或者目标端口没开 - 有没有重传、乱序、窗口过小、RST、FIN 这些异常标志 - 频繁重传,通常说明链路质量差、丢包严重,或者对端压力太大来不及处理 - 收到 RST,说明连接被一方强制重置 - 请求和响应的时间关系 - 请求很快发出去了,但响应很久才回来,那要区分是网络传输慢,还是服务端处理慢 - TIME_WAIT 是什么?为什么会出现大量 TIME_WAIT? - TIME_WAIT 是 TCP 连接关闭过程中的一个状态,通常出现在主动关闭连接的一方 - 确保最后的 ACK 能让对方收到 - 防止旧连接里延迟到达的报文影响后续新连接 - 在发出最后这个 ACK 之后,主动关闭方不会立刻把连接彻底释放 - 而是会进入 TIME_WAIT 状态,并等待一段时间,通常是 2MSL - 大量 TIME_WAIT 通常说明系统存在高并发短连接或者频繁主动关闭连接的情况,它不一定是错误,但可能带来端口和资源压力 - CLOSE_WAIT 是什么?大量 CLOSE_WAIT 通常说明什么问题? - 它表示本机已经收到了对方发来的 FIN,也就是对方已经准备关闭连接了 - 但本机这边还没有真正执行 close,所以连接还没有完全关闭 - 大量 CLOSE_WAIT 通常说明服务端或者本机应用存在资源回收不及时的情况 - 先用 ss -antp 或 netstat -antp 看哪些连接处于 CLOSE_WAIT - `tcpdump` 你用过吗?常见用法是什么? - 是 Linux 下非常常用的命令行抓包工具,主要用于网络问题排查和协议分析 - tcpdump 更适合在线上或服务器环境里做快速抓包 - 按网卡抓包,比如先用 tcpdump -i eth0 指定网卡;如果不确定网卡名,可以先看机器网卡列表 - 按主机过滤,比如只抓和某个 IP 之间的流量,可以用 host 条件 - 按端口过滤,比如只抓 80、443、8080 这种业务端口 - 按协议过滤,像 tcp、udp、icmp 分开抓,这样可以大幅减少无关数据 - 把抓包结果保存到文件,比如写到 .pcap 文件里,后续再用 Wireshark 分析 - Linux 性能监控 - 什么是负载 load?和 CPU 使用率有什么区别? - 它表示在一段时间内,系统中处于可运行状态和不可中断睡眠状态的平均进程数量 - 而 load 看的是系统里有多少任务在等待 CPU 或者等待某些关键资源 - 服务管理与部署 - Linux 中如何启动、停止、重启服务? - 现在最常见的方式是用 systemctl,因为大多数现代发行版都使用 systemd 来管理系统服务 - 启动服务用 systemctl start 服务名 - 停止服务用 systemctl stop 服务名 - 重启服务用 systemctl restart 服务名 - 查看服务状态用 systemctl status 服务名 - reload 表示重新加载配置,通常不会中断服务进程 - enable 是设置开机自启,disable 是取消开机自启 - Linux 服务是什么 - 在系统中长期运行、为系统或其他程序提供某种功能的后台进程 - 往往会随着系统启动自动拉起 - 它通常会监听端口、处理请求,或者周期性执行任务 - 服务本质上还是进程,只不过它是被系统长期托管和管理的进程 - Linux 服务通常由专门的管理器来维护。早期常见的是 init 或 SysV,现在大多数发行版主要用 systemd - 常用文本处理命令 - `awk` 是干什么的? - awk 会一行一行地读取输入内容,然后按照分隔符把每一行拆成多个字段 - 默认分隔符是空格或制表符,我们可以直接用 $1、$2、$3 这样的方式取第 1 列、第 2 列、第 3 列数据 - 最常见的能力有四类 - 第一类是取列,比如提取某一列内容 - 第二类是条件过滤,比如只保留某一列满足条件的行 - 第三类是统计计算,比如计数、求和、求平均值 - 第四类是格式化输出,把原始文本重新整理成自己想要的结构 - sed 是干什么的? - 它会按行读取输入内容,然后按照我们写的规则对文本做处理,处理完直接输出结果 - 它通常不是把整个文件一次性读到内存里再编辑,而是一行一行地处理输入流 - 替换,这也是最常见的,比如把某个字符串批量替换成另一个 - 删除,比如删掉某些行、空行、注释行 - 截取,比如只打印某几行内容 - 插入和追加,比如在某一行前后加内容 计算机组成原理 - 计算机组成原理 - 存储系统 - 虚拟内存与地址转换 - 什么是虚拟地址?什么是物理地址? - 虚拟地址是程序运行时看到的地址,也叫逻辑地址 - 物理地址是内存条上真实存在的地址,是硬件真正访问的地址 - 程序里用的通常不是直接的物理地址,而是虚拟地址 - 再通过 MMU,也就是内存管理单元,结合页表把虚拟地址翻译成物理地址,最后再去访问真正的内存 - 地址转换是怎么做的? - 转换过程第一步通常会先查 TLB,也就是快表 - TLB 本质上是页表项的高速缓存,里面存的是“虚拟页号到物理页框号”的映射 - 如果 TLB 没命中,就要进一步去查页表 - 页表一般放在内存里,操作系统会维护它,记录每个虚拟页映射到哪个物理页框 - 如果查页表时发现这个虚拟页当前不在物理内存中 - 由操作系统介入,把对应页面从磁盘调入内存,必要时还会淘汰一个旧页 - 多级页表是为了解决什么问题? - 多级页表本质上是为了解决单级页表过大、内存开销太高的问题 - 多级页表的核心思想就是把原来一张连续的大页表拆成分层结构,按需创建 - 页表不再一次性全部分配,而是先有一个上层页表,只有当某一段虚拟地址空间真的被用到了,才去创建对应的下一级页表 ElasticSerach - ElasticSerach - 基础概念 - ElasticSearch 的核心应用场景有哪些? - 全文检索与相关性排序 - 日志与可观测性场景 - 聚合分析与报表统计 - 什么是倒排索引?它为什么适合全文检索? - 倒排索引本质上是一种“从词到文档”的索引结构 - 传统数据库更常见的是正排,也就是根据文档 ID 去找到文档内容 - 而倒排索引是先把文档内容经过分词,拆成一个个 term - 为每个 term 维护一个倒排列表,记录这个词出现在哪些文档中 - 它适合全文检索 - 核心原因是查询路径特别短 - 用户一搜某个关键词,系统可以直接定位到这个词对应的文档集合 - 它天然支持分词后的检索 - 不仅能查到,还能支持相关性排序 - 倒排索引里通常不只是保存“出现过”,还会保存词频、位置等信息 - 搜索引擎就可以结合 TF、IDF 或 BM25 等算法判断某篇文档和查询词的相关程度 - ElasticSearch 为什么检索快? - 最根本的原因是倒排索引 - ES 会先分词,再基于 term 检索 - ES 是分布式架构,可以并行查询 - Lucene 底层做了很多针对检索的优化 - 什么是 Lucene?ElasticSearch 和 Lucene 是什么关系? - Lucene 是一个高性能的全文检索库,本质上是“搜索引擎的核心算法与索引实现” - ElasticSearch 和 Lucene 的关系可以理解为:ES = Lucene + 分布式 + 工程化能力 - ElasticSearch 中的 segment 是什么? - segment 可以理解为 Lucene 里的一个最小索引单元,或者说是一个不可变的倒排索引文件集合 - Lucene 不会把一个分片的数据维护成一个单独的大索引文件,而是会拆成多个 segment - 每个 segment 内部都包含这个批次文档对应的倒排索引、词典、posting list、存储字段等数据结构 - 查询时其实不是只查一个整体索引,而是同时在多个 segment 上查,再把结果合并 - 索引与数据结构类 - ElasticSearch 的文档写入后,底层经历了哪些过程? - 请求路由到主分片 - 主分片执行写入:先写 translog,再写内存缓冲 - ES 会先把这次操作以追加的方式写到 translog - 把文档写入到 in-memory buffer(indexing buffer) - refresh 让数据“可被搜索” - 当触发 refresh(默认大约 1 秒一次,或手动 refresh) - buffer 里的数据会被写成一个新的 Lucene segment 并打开一个新的 searcher - 使这批文档变成 near real-time 可检索 - flush 触发真正的持久化与 translog 截断 - 随着 translog 增长或达到条件,会触发 flush - 把当前 segment 的提交点(commit point)持久化,并生成新的 translog - 后台 merge 合并 segment 优化查询 - ES/Lucene 会在后台做 segment merge,把多个小段合并成更大的段,减少段数量、提升查询效率 - 同步到 replica 分片并返回结果 - doc_values 是什么?有什么作用? - ES 为“按文档读取字段值”专门构建的一种列式存储结构 - 有一类场景不是“找哪些文档包含某个词”,而是“我已经拿到这些文档了,还要基于某个字段做排序、聚合、脚本计算” - 最核心的作用,是支持排序、聚合和脚本访问字段值 - doc_values 的设计目标,是用磁盘空间换内存压力 - 查询原理类 - ES 一次查询的流程是什么 - 请求进入协调节点(coordinating node) - 客户端把搜索请求发到集群里任意一个节点,这个节点就会扮演协调节点 - 负责解析 DSL、校验索引与参数、并根据索引的分片分布决定这次查询要打到哪些 shard - 分片路由与并行分发 - 协调节点会根据目标索引的 shard 列表,把查询请求并行发给每个相关分片的一个副本 - Query Phase:各分片本地执行查询并返回 TopN - 如果是全文检索,会做分词、走倒排索引,计算相关性 _score - 如果有 filter,会做结构化过滤 - 如果有排序/聚合,也会在分片侧先做一部分计算 - 然后每个分片会返回本分片的 TopK 文档 ID + score/排序字段 - 协调节点做全局归并与排序 - Fetch Phase:回表取文档内容 - 全文检索时,分词、倒排索引、BM25 打分这个过程具体是怎么跑的 - 建索引阶段 - 有个 title 字段内容是“Java 分布式系统设计”,如果这个字段类型是 text - ES 会先用 analyzer 对它做分析 - 先做字符过滤,然后由 tokenizer 分词,最后再经过 token filter 做小写化、停用词处理、同义词扩展等 - 原始文本不会直接拿去全文匹配,而是会变成一个个 term - ES 会基于这些 term 建立倒排索引 - 会存成“某个词出现在哪些文档里” - 系统 -> doc1, doc2, doc5, doc9 - 倒排列表里通常不只是 docID,还会记录词频、位置等信息 - 建索引阶段的核心,就是把文本拆成 term,然后维护 term 到文档集合的映射关系 - 查询阶段,用户输入的关键词也会走一遍分析流程 - 也会先分词,得到类似“java”和“系统”这两个查询 term - 然后 ES 就会去倒排索引里找这两个 term 对应的 posting list - 也就是分别找到包含“java”的文档集合和包含“系统”的文档集合 - 找到 posting list 之后,会做候选文档召回 - 接下来才是相关性打分,也就是 BM25 发挥作用的地方 - 查询词在当前文档里出现了多少次,也就是词频 - 这个词在整个索引里稀不稀有,越稀有权重越高 - 文档本身有多长,太长的文档会被做一定归一化,避免天然占优 - 各个分片会先在本地完成这个召回和打分过程 - 如果进入 fetch phase,才会去拿完整文档内容 - 全文检索和精确查询有什么区别? - 全文检索强调的是“分词后匹配”,适合搜文本内容 - 精确查询强调的是“值是否完全一致”,适合结构化字段 - 两者在底层使用场景上也不一样 - 全文检索更关注“召回”和“相关性排序” - 而精确查询更关注“过滤” - 它们对应的典型查询方式也不同 - BM25 是什么? - BM25 是 ElasticSearch 默认使用的一种相关性评分算法,用来衡量“某篇文档和用户查询到底有多相关” - 词频、逆文档频率、文档长度归一化 - 词频,也就是某个词在当前文档里出现得越多,通常说明这篇文档和查询更相关 - 逆文档频率,也就是越稀有的词,区分度越强,权重越高 - 文档长度归一化 - BM25 比较适合搜索场景 - BM25 可以理解为在 TF-IDF 基础上的改进版。 - 保留了“词频 + 逆文档频率”的核心思想,但对词频增长做了饱和控制 - 同时对文档长度处理得更合理,所以通常比传统 TF-IDF 更适合现代搜索引擎 - 写入性能与查询优化 - 热点分片是什么?怎么解决? - 热点分片,本质上是指某一个或少数几个 shard 的压力明显高于其他 shard,导致集群负载不均衡 - 它的本质问题不是“集群性能不够”,而是“流量分布不均” - 热点分片通常有两大类原因 - 写热点 - 读热点 - 一般会从“数据分布、请求分布、资源分布”三层来处理 - 优先从数据分布上解决,也就是让数据尽量均匀落到各个 shard - 从请求分布上缓解热点,如果是读热点,可以增加 replica shard - 从资源和架构上做隔离 - 必要时通过重建索引来调整分片策略 - 系统设计 - 系统设计 - 设计一个短链接系统(类似 TinyURL) - 客户端把长链接发到服务端,服务端先做参数校验,比如 URL 格式是否合法,是否命中黑名单 - 系统生成一个全局唯一的短码,再把短码和原始长链接的映射关系存到数据库里,最后返回类似 https://xx.com/abc123 这样的短链接 - 用户访问这个短链接时,请求会先到接入层,再到短链接解析服务,系统根据短码查到原始 URL,然后返回 302 或 301 重定向 - 如何把一条长链接映射成一个短且唯一的标识 - 自增 ID 加 Base62 编码,也就是数据库先生成一个唯一 ID,比如 1000001,再把它编码成短字符串 - 对长链接做哈希,比如 MD5,再截取一部分字符作为短码,但这种方式会有哈希冲突问题,需要额外处理 - 发号器或分布式 ID,比如 Snowflake,再做 Base62 编码 - 存储设计上,我会准备一张核心映射表,比如字段包括:短码、原始长链接、创建时间、过期时间、创建人、状态、访问次数等 - 短码字段要建立唯一索引,因为跳转查询本质上就是根据短码查原始 URL,这是系统最核心的查询路径 - 跳转成功后异步发送一条埋点消息到 MQ,由后面的日志分析或实时计算系统去消费,写入 ClickHouse、ES 或数仓中做分析报表 - 设计一个聊天系统(类似微信 / WhatsApp) - 接入层,也就是长连接网关层,客户端通过 WebSocket 或者移动端长连接协议和接入层建立持续连接 - 这一层主要负责连接管理、心跳保活、路由转发和用户在线状态维护 - 后面是业务层,包括消息服务、会话服务、群组服务、离线消息服务、历史消息服务、推送服务 - 存储层,通常会结合 Redis、MySQL、消息队列以及可能的对象存储来实现 - 客户端登录成功后,会和某个接入节点建立长连接,这时候系统需要维护一份‘用户 ID 到连接 ID,再到机器节点’的映射关系 - 有人给这个用户发消息时,消息服务可以先查路由,找到他当前连接在哪台机器上,再把消息投递过去 - 消息发送流程上 - 发送方把消息发给接入层,接入层先做鉴权和基础校验,然后把请求转发到消息服务 - 消息服务会先生成全局唯一的 messageId,并做消息落库或者写入消息日志 - 根据接收方是单聊还是群聊走不同流程 - 对于单聊,系统查接收方在线路由,如果在线,就把消息投递到对应接入节点,再由接入节点推送给接收方客户端 - 如果不在线,就把消息写入离线消息存储,并通过厂商推送做通知 - 用户上线后,再拉取离线消息并同步到本地 - 存储设计 - 第一类是会话数据,比如用户最近联系人列表、每个会话的最后一条消息、未读数,这类数据读写都很频繁,适合放 Redis 以及 MySQL 持久化 - 消息数据,也就是聊天记录,这类数据量最大,通常会做分片存储,可以按会话 ID 或者用户 ID 哈希分库分表 - 关系数据,比如群成员、好友关系,这类更适合放 MySQL - 消息表,字段一般包括消息 ID、会话 ID、发送方、接收方、消息类型、内容、发送时间、状态等 - 严格全局有序在分布式系统里代价很高,聊天系统通常只保证‘单会话内尽量有序’ - 单聊里,可以以会话维度串行化处理,或者让同一个会话的消息固定路由到同一个队列分区 - 群聊里也可以按群 ID 做分区,这样同一个群内的消息进入同一个顺序队列处理 - 离线消息和多端同步 - 不能只依赖实时推送,因为用户可能离线、弱网或者切后台,所以服务端一定要保存消息投递记录和消息内容 - 用户重新上线后,可以基于上次同步到的游标或者 seq 去增量拉取消息,这样就能保证消息补偿 - 群聊是另一个重点 - 群聊发送时最直观的方式是服务端拿到一条群消息后展开成多份,分别投递给每个成员 - 但如果群特别大,这种在线展开代价就会非常高 - 群聊一般要区分大小群。小群可以实时扇出,也就是服务端直接展开分发 - 大群可以采用写扩散和读扩散结合的方式,或者通过消息流加拉模型来降低服务端写放大 - 设计一个微博 / Twitter 时间线系统 - 用户可以发微博,也就是发布内容 - 用户可以关注别人,建立关注关系 - 用户可以看到自己的时间线,也就是关注的人最近发了什么 - 支持点赞、评论、转发这些互动能力 - 发布微博 - 发布一条微博后,请求先到内容服务,内容服务先做参数校验、敏感词过滤和内容存储 - 正文、作者 ID、发布时间这些元数据可以先写入 MySQL 或者分布式 KV 存储 - 图片和视频这种大对象一般放对象存储 - 写成功后,系统会生成一个全局唯一的 postId,然后把这条发布事件发送到消息队列,由后续的时间线分发服务异步处理 - Push 模式和 Pull 模式 - Push 模式,也叫写扩散,就是一个用户发微博后,系统把这条微博直接推送到所有粉丝的时间线缓存里。这样用户读时间线时非常快 - 如果一个大 V 有上千万粉丝,一次发帖就会造成巨大写放大,系统压力很大 - Pull 模式,也叫读扩散,就是用户读时间线时,系统实时去拉取他关注的人最近发的内容,再做聚合排序 - 写操作很轻,但读操作会比较重,特别是一个用户关注了很多人时,聚合成本会很高 - 混合模式 - 普通用户走 Push,大 V 用户走 Pull - 普通用户粉丝少,发帖时推送给粉丝成本可控,换来读性能很好 - 而大 V 粉丝太多,如果还坚持 Push,单次发帖就会带来海量写入,不划算 - 可以给用户分层,比如粉丝数低于某个阈值的按写扩散处理,超过阈值的大 V 按读扩散处理 - 最终一个用户读取时间线时,先读取自己 inbox 里已经推送好的内容 - 再补充拉取他关注的大 V 最近的内容,最后做合并排序 - 设计一个朋友圈 / Feed 流系统 - 内容写成功后,系统生成一个全局唯一的 feedId,然后发送一条发布事件到 MQ,由后续的 Feed 分发服务异步处理 - 设计一个关注 / 粉丝系统 - 关注关系本质上是一张有向边,也就是 userA 关注 userB,可以表示成一条 (follower_id, followee_id) 的记录 - 一般会设计一张关系表,核心字段包括:id、follower_id、followee_id、status、create_time、update_time - 这里 status 一般不一定直接物理删除,可以做成逻辑状态,比如 1 表示已关注,0 表示已取消 - 设计一个搜索系统 - 搜索系统的核心目标,是让用户输入一个查询词之后,系统能够在海量数据中快速召回相关内容,并按照相关性排序 - 业务系统里通常有商品表、文章表或者内容表,这些数据的新增、修改、删除不能直接靠搜索服务主动查库 - 业务数据变更后通过 Binlog、CDC 或 MQ 把变更事件发送到索引构建服务 - 索引构建服务接收到变更后,做字段清洗、分词、结构化处理,然后写入搜索引擎更新索引 项目 - 项目 - 异步收银台 - 问题 - WebSocket 与长连接网关 - 差量协议 - 收益 - BPMN L4 - 事件网关 - 异步回调 - 问题挑战 - 视图构建并发修改 panic - 机器资源 - 上屏率 - 未来要做的 - 上屏率提升 - 权益中心 - 痛点 - 接入成本高 - 权益存储分散,不可追溯 - 规则复杂 - 解决方案 - 数据模型 - 权益元数据-权益是什么/业务含义 - 权益来源/策略-权益从哪里来(免费试用/商业订单/审批补发) - 权益度量/实例:度量-订单 1vs1 实例:度量的组合 - 流水:审计、对账 - 库存:消耗记录,度量、实例 - 权益属性:配置信息 - 规则表 - SPI 机制(平台与业务分离) - 参数填充 - 发放 - 到期 - 回收 - 不同权益规则很大,平台放无法全包 - 历史业务前移 - 模型映射转换-SPI 实现 - 权益中心较晚出现,历史业务切流 - 消耗数据,同步流水,异步聚合(流水对账) - 历史权益发放数据:订单级别重放 + 对账 - 新流入数据:权益 + 业务方双写 - 切流灰度 - 热点问题 - 发放没有热点问题,查询/消耗有热点问题 - 大组织热点/mysql 无法承载,转换成 Redis - Redis 将库存记录转换为 list 存储起来 - 每次路由到桶,写记录 - 跨桶扣减会加锁(桶碎片问题) - 只会少买,不会超卖(扣库存成功,写流水失败,后续补偿) - 富客户端,请求不直接打进来 - 异步写流水 - 规则引擎 - 发放规则复杂(商品、购买周期、企业行业、时间) - 不用硬编码,直接 JEXL 规则引擎配置 - 其他,版本号 + 阶段控制 vibe coding 了后台 - 报价管理 - 商机客保规则限制,跨商品线下下单困难 - 订单价格不统一,无法统一改价 - 流程繁多、改价审批、商机转交审批、时间长 - 新人培训成本高,企业信息了解少 - 报价的审批会改变订单的状态,报价审批拒绝了,订单关单 - 合同分散、集中合同,报价统一出 - AI 做什么 - 热点词捕捉 - 什么时候微调/什么时候 RAG - Planning - ReAct,还没有做 Planning - 历史服务数据承担 SFT 微调 - MCP 服务开放,HSF 转 MCP,订单/小记 - 客户情报说明/雷达图/商品打分/服务话术推荐 - 合单分期 - 痛点:支付方式不灵活 - 不同店铺单支付方式冲突 - 搭配购、顺手买 - 可扩展数据结构 mixPayOption { payOption<string> } - 店铺单分期方式扩展 - 渲染订单号 - 实际订单号转换的过程 - 扩展监控面板 - 店铺单/拍下单支付方式路由(一个资产、多个资产) - 新面板,提单信息 - 商品信息 - 店铺信息 - 价格信息 - 运费信息 - 冲突校验 - 懒加载 - 面板不请求(前端聚合计算) - 未来方向 - 组合支付 - 自选快递 - 提单 - 识别场景能力 - 从时效获取支持快递 + 运费模板 - 从运费模板 + 获取加价运费 - 楼层渲染 - 冲突规则 - 失效可能会变/运费可能会变 -> 冲突文案 - 下单 - 选中打标

2026.02.28

leetcode-155-最小栈

实验元数据 (Meta Data) 用于日后检索和归档,建立知识索引。 实验编号/标题:例如:LeetCode-155-最小栈 …

2026.02.27

实验-leetcode-153-寻找旋转排序数组中的最小元素

实验元数据 (Meta Data) 用于日后检索和归档,建立知识索引。 实验编号/标题:实验-leetcode-153-寻找旋转排序数组中的最小元素 …

2026.02.27

实验-leetcode-198-打家劫舍

实验元数据 (Meta Data) 用于日后检索和归档,建立知识索引。 实验编号/标题:实验-leetcode-198-打家劫舍 …

2026.02.27
10

架构 · 设计

11 posts 查看全部 →

SSE 原理

1. 学习主题 我要学习的内容: SSE 原理 我为什么要学它: (写清楚用途、场景、目标,比如面试、项目、论文、考试) …

11

Hugo 示例

7 posts 查看全部 →

mindmap 测试

mindmap 测试

2026.01.15

技术文档

整理常用的技术文档操作

2025.09.07

Diagram Support

Eureka supports the rendering of diagrams by using Mermaid. …

2021.03.31

Math Support

Eureka supports the rendering of mathematical formulas by using KaTeX. …

2020.11.20