服务端面试题手册

梳理高频技术问题,帮助你按主题复习和查漏补缺。

服务端阅读 05月28日 02:15

Cookie 的 Expires 和 Max-Age 有什么区别?

核心结论Max-Age 优先于 Expires。两者同时设置时,浏览器只认 Max-Age。Expires 是 HTTP/1.0 的产物,用绝对时间;Max-Age 是 HTTP/1.1 引入的,用相对秒数,不受客户端时钟偏差影响。现代开发应优先使用 Max-Age,仅在需要兼容 IE 时同时设置 Expires 作为降级。两者的本质区别| 维度 | Expires | Max-Age ||------|---------|---------|| 规范来源 | HTTP/1.0 (Netscape 草案) | HTTP/1.1 (RFC 6265) || 时间类型 | 绝对时间 (GMT 字符串) | 相对时间 (秒数) || 优先级 | 低 | 高 || 时钟依赖 | 依赖客户端本地时间 | 不依赖,从收到 Cookie 起算 || 浏览器兼容 | 全部浏览器 | IE6/7/8 不支持 || 典型值 | Wed, 09 Jun 2026 10:18:14 GMT | 3600 |关键差异在于时钟依赖:Expires 指定“到什么时刻过期”,依赖客户端系统时钟。如果用户电脑时间比服务器快几小时,Cookie 可能提前过期;慢几小时则延迟过期。Max-Age 指定“从现在起还能活多久”,浏览器收到 Cookie 后自己倒计时,不受时钟偏移影响。Expires 的用法// 设置 7 天后过期const expires = new Date();expires.setDate(expires.getDate() + 7);document.cookie = "token=abc; Expires=" + expires.toUTCString();// 设置过去的日期 → 立即删除 Cookiedocument.cookie = "token=abc; Expires=Thu, 01 Jan 1970 00:00:00 GMT";时间格式必须是 UTC (GMT) 字符串。toUTCString() 生成正确格式,toISOString() 生成的格式浏览器不一定识别。Max-Age 的用法// 1 小时后过期document.cookie = "token=abc; Max-Age=3600";// 1 天后过期document.cookie = "token=abc; Max-Age=86400";// 立即删除document.cookie = "token=abc; Max-Age=0";// 会话 Cookie(浏览器关闭即删除)——不设置 Max-Age 和 Expires 即可document.cookie = "sessionId=abc";Max-Age 的特殊值:0 表示立即删除;负数也表示立即删除(但部分浏览器行为不一致,推荐用 0);不设置则变成会话 Cookie。优先级规则两者同时出现时,Max-Age 优先:// 同时设置,Max-Age 生效,Cookie 在 1 小时后过期document.cookie = "token=abc; Expires=Wed, 09 Jun 2026 10:18:14 GMT; Max-Age=3600";这个优先级是 RFC 6265 明确规定的。IE6/7/8 不识别 Max-Age,会回退使用 Expires——这也是为什么兼容旧浏览器时要同时设置两者。追问:服务器端怎么设置?前端 document.cookie 只能设置非 HttpOnly 的 Cookie。实际项目中 Cookie 过期时间主要由服务端通过 Set-Cookie 响应头控制:Set-Cookie: token=abc; Max-Age=3600; Path=/; HttpOnly; Secure; SameSite=StrictNode.js Express 示例:res.cookie("authToken", token, { maxAge: 3600 * 1000, // 注意:Express 的 maxAge 单位是毫秒 httpOnly: true, secure: true, sameSite: "strict", path: "/"});注意坑:Express 的 maxAge 选项单位是毫秒,而 HTTP 头中的 Max-Age 单位是秒。Express 内部会自动转换。追问:删除 Cookie 的正确姿势删除 Cookie 必须满足三个条件:名称、域名(Domain)、路径(Path) 全部匹配,否则浏览器会创建一个同名新 Cookie 而非删除旧的。// 删除时必须与设置时的 Path 和 Domain 一致document.cookie = "token=abc; Max-Age=0; Path=/; Domain=example.com";实际开发建议优先用 Max-Age:不受客户端时钟影响,计算简单,是 RFC 6265 推荐方式兼容旧浏览器时同时设置两者:Max-Age 为主,Expires 做降级敏感信息用短过期:认证 Token 建议几小时级别,配合刷新机制“记住我”用长过期:30 天左右,但务必配合 HttpOnly + Secure + SameSite不要依赖 Expires 做精确过期:用户设备时钟不可控
服务端阅读 05月28日 02:14

pnpm 如何处理 peer dependencies?与 npm 有什么不同?

pnpm 通过隔离的 node_modules 结构和严格的依赖图解析,在安装阶段就检测 peer dependencies 冲突,而 npm 的扁平化结构可能让版本不匹配的 peer 依赖静默通过,直到运行时才暴露问题。核心机制差异npm 采用扁平化 node_modules,依赖会被提升(hoist),包能访问到不该访问的依赖(幽灵依赖)。peer dependencies 版本不匹配时,npm v7 之前直接忽略,v7+ 会自动安装但可能产生重复实例。pnpm 使用 .pnpm 存储目录 + 符号链接的隔离结构,每个包只能访问自己声明的依赖。peer dependencies 从依赖图更高层级解析——如果宿主项目提供了匹配版本,符号链接指向它;否则安装报错:ERR_PNPM_PEER_DEP_ISSUES Unmet peer dependenciesreact-dom@18.0.0 requires react@^18.0.0 but you have react@17.0.0这种严格性确保版本一致性,避免运行时因 React 多实例导致的 hooks 报错等问题。实际处理方式1. 匹配版本安装pnpm add react@18 react-dom@182. 全局覆盖{ "pnpm": { "overrides": { "react": "^18.0.0" } }}3. 自动安装 peer 依赖(.npmrc)auto-install-peers=truestrict-peer-dependencies=falseauto-install-peers=true 让 pnpm 自动解析并安装缺失的 peer 依赖,但不会解决版本冲突。strict-peer-dependencies=false 将冲突从报错降级为警告。4. 标记可选 peer 依赖{ "peerDependenciesMeta": { "react-dom": { "optional": true } }}monorepo 中的行为pnpm workspace 内,子包的 peer dependencies 会从同一 workspace 中其他包的 dependencies 中解析。workspace 依赖(workspace:*)的 peer 需求会被自动满足,无需额外配置。对比总结| 维度 | npm | pnpm ||------|-----|------|| 检查时机 | v7+ 安装时检查,v6 及以前延迟到运行时 | 始终在安装时严格检查 || 版本冲突 | 可能安装多个实例 | 单一实例,冲突直接报错 || node_modules 结构 | 扁平化,存在幽灵依赖 | 隔离存储 + 符号链接 || peer 解析来源 | 自行安装或从提升结构中查找 | 从依赖图高层级解析 |追问方向pnpm 的 .pnpm 目录结构如何保证 peer 依赖只链接到正确版本?auto-install-peers=true 在大型 monorepo 中可能带来什么问题?为什么 React 生态对 peer dependencies 尤其敏感?
服务端阅读 05月28日 02:13

WebRTC的数据通道有什么作用?如何使用它传输非媒体数据?

WebRTC 数据通道(Data Channel)是 WebRTC 中与媒体通道并列的传输机制,基于 SCTP over DTLS 协议栈,在浏览器之间建立加密的点对点连接来传输任意二进制或文本数据。数据通道的核心作用与媒体通道的区别:媒体通道使用 SRTP 传输音视频,而数据通道使用 SCTP over DTLS 传输非媒体数据。两者共享同一个 ICE 传输层,但走不同的协议栈,互不干扰。相比 WebSocket 的优势:WebSocket 需要服务器中继,数据经由服务端转发;数据通道建立后直接在两端之间传输,省去服务器中转的延迟和带宽开销。API 设计上与 WebSocket 高度相似,降低了学习成本。关键特性:DTLS 加密:所有数据自动经过 DTLS 加密,无需应用层额外处理全双工通信:双方可同时收发,不存在方向限制可靠性可配置:通过 ordered、maxRetransmits、maxPacketLifeTime 三个参数,可在可靠有序(类 TCP)和不可靠无序(类 UDP)之间自由选择NAT 穿透:复用 ICE 框架的 NAT 穿透能力,无需额外处理数据通道的协议栈理解协议栈有助于在面试中讲清"为什么数据通道既能可靠传输又能低延迟":应用层数据 ↓SCTP(流控传输协议,提供多流、可靠/部分可靠传输) ↓DTLS(加密层,提供安全性) ↓UDP(ICE 协商后的 P2P 通道)SCTP 支持在同一关联上建立多个流(Stream),每个数据通道对应一个 SCTP 流,通道间互不阻塞。SCTP 的"部分可靠"扩展(PR-SCTP)使得配置 maxRetransmits 和 maxPacketLifeTime 成为可能,这是数据通道灵活性的根源。创建数据通道const pc = new RTCPeerConnection(config);// 创建方(Offerer)主动创建const channel = pc.createDataChannel('chat', { ordered: true, // 保证消息有序到达 maxRetransmits: 3, // 最多重传3次,超出则丢弃 // maxPacketLifeTime: 3000, // 与maxRetransmits二选一,消息最大存活时间(ms) protocol: 'text', // 子协议标识 negotiated: false, // false=自动协商,true=手动指定id});参数说明:ordered: true + maxRetransmits: null + maxPacketLifeTime: null = TCP 模式(可靠有序)ordered: false + maxRetransmits: 0 = UDP 模式(不可靠无序)maxRetransmits 和 maxPacketLifeTime 互斥,只能设一个监听事件与收发数据// 发送方channel.onopen = () => { // 通道就绪后才能发送 channel.send('Hello!');};channel.onmessage = (e) => { console.log('收到:', e.data);};channel.onclose = () => { console.log('通道关闭');};// 接收方(被动方通过 ondatachannel 获取)pc.ondatachannel = (e) => { const rc = e.channel; rc.onmessage = (ev) => { console.log('收到:', ev.data); }; rc.onopen = () => { rc.send('收到你的消息'); };};发送数据的三种格式:channel.send('字符串'); // string → PPID 51channel.send(new ArrayBuffer(8)); // binary → PPID 53channel.send(new Blob(['data'])); // binary → PPID 53SCTP 内部通过 PPID(Payload Protocol Identifier)区分 UTF-8 文本(51)和二进制数据(53),接收方 onmessage 的 event.data 会自动还原为 string 或 ArrayBuffer。缓冲区管理与背压数据通道有发送缓冲区,持续发送大量数据而不检查缓冲区会导致内存暴涨甚至通道崩溃:// 检查缓冲区是否快满if (channel.bufferedAmount > channel.bufferedAmountLowThreshold) { // 暂停发送,等待缓冲区排空 channel.onbufferedamountlow = () => { // 恢复发送 sendNextChunk(); }; return;}channel.send(chunk);bufferedAmountLowThreshold 默认为 0,建议设为缓冲区容量的 1/4 左右。这是面试中常被追问的实战细节。常见应用场景| 场景 | 配置建议 | 原因 ||------|---------|------|| 实时游戏状态同步 | ordered: false, maxRetransmits: 0 | 旧状态无需重传,只要最新帧 || 协作白板 | ordered: true, maxRetransmits: 3 | 操作顺序不能乱,但偶尔丢帧可接受 || 文件传输 | ordered: true(默认可靠模式) | 文件必须完整到达 || 聊天消息 | ordered: true(默认可靠模式) | 消息不能丢、不能乱序 || IoT 传感器数据 | ordered: false, maxPacketLifeTime: 1000 | 传感器数据实时性优先 |面试追问方向数据通道能传多大消息? 理论上 SCTP 支持最大 1 GiB 的消息(通过分片重组),但实际受浏览器实现限制,建议单条消息不超过 64 KiB,大文件应分块传输。数据通道和 WebSocket 怎么选? 需要服务器中转或一对多广播用 WebSocket;需要点对点低延迟且能接受 ICE 协商开销用数据通道。两者也可以混合使用。negotiated: true 是什么? 跳过 DCEP 协商,双方通过约定相同的 id 手动创建通道,减少一次 RTT 的协商开销。
服务端阅读 05月28日 02:13

什么是 GORM,它的核心特性有哪些?

GORM 是什么GORM 是 Go 语言中应用最广泛的 ORM 库,基于反射机制将结构体映射为数据库表,将方法调用转换为 SQL 语句。它遵循约定优于配置的原则——结构体名的蛇形复数即为表名,ID 字段默认为主键,CreatedAt / UpdatedAt 自动管理时间戳。核心特性关联关系:支持 Has One、Has Many、Belongs To、Many To Many 四种关联,通过 Preload 预加载或 Joins 联表查询获取关联数据钩子机制:在 Create / Update / Delete / Find 前后可注册回调,例如 BeforeCreate 中做字段默认值填充,AfterDelete 中清理关联资源事务支持:db.Transaction(func(tx *gorm.DB) error { ... }) 提供闭包式事务,返回 error 自动回滚,返回 nil 自动提交自动迁移:db.AutoMigrate(&User{}) 根据结构体定义同步表结构(新增列、索引),但不会删除已有列链式调用:db.Where(...).Order(...).Limit(...).Find(&results) 风格的查询构建器,中间态可复用基本 CRUD 示例type User struct { gorm.Model // 内置 ID、CreatedAt、UpdatedAt、DeletedAt Name string Email string `gorm:"type:varchar(100);uniqueIndex"` Age int}// 创建db.Create(&User{Name: "Alice", Email: "alice@test.com", Age: 28})// 查询var user Userdb.First(&user, 1) // 按主键db.Where("age > ?", 20).Find(&users) // 条件查询// 更新db.Model(&user).Update("age", 29) // 单字段db.Model(&user).Updates(map[string]interface{}{"age": 29, "name": "Alice W"}) // 多字段// 删除(软删除,DeletedAt 非空)db.Delete(&user)db.Unscoped().Delete(&user) // 硬删除面试高频追问Q1:GORM 的软删除是如何实现的?如何查询被软删除的记录?GORM 在模型中嵌入 gorm.Model 后会包含 DeletedAt 字段(类型为 gorm.DeletedAt)。调用 Delete 时 GORM 将 DeletedAt 设为当前时间而非执行 DELETE。所有查询自动追加 WHERE deleted_at IS NULL。使用 db.Unscoped() 可跳过此条件查询到已删除记录,Unscoped().Delete() 则执行硬删除。Q2:N+1 查询问题是什么?GORM 如何解决?查询主表 N 条记录后,遍历每条记录单独查询关联表,产生 1 + N 次 SQL。GORM 通过 Preload("Orders") 在一次查询中批量加载关联数据(生成两条 SQL:一条查主表,一条用 WHERE id IN (...) 查关联),也可用 Joins("Orders") 生成单条 JOIN SQL。Preload 适合一对多场景,Joins 适合过滤关联条件的场景。Q3:GORM 钩子的执行顺序是什么?以 Create 为例:BeforeSave → BeforeCreate → 执行插入 → AfterCreate → AfterSave。如果 BeforeSave 或 BeforeCreate 返回 error,整个流程中断。注意:批量操作(如 CreateInBatches)中钩子对每条记录单独触发。Q4:GORM 的事务有几种用法?三种:闭包事务:db.Transaction(func(tx *gorm.DB) error { ... }),最推荐,返回 error 自动回滚手动事务:db.Begin() → tx.Commit() / tx.Rollback(),需要自行处理 panic嵌套事务:通过 SavePoint / RollbackTo 实现,适用于需要部分回滚的场景Q5:GORM 的 First 和 Find 有什么区别?First 查询一条记录(追加 LIMIT 1),记录不存在时返回 ErrRecordNotFound;Find 查询多条记录,记录不存在时不报错,只返回空切片。如果只需要一条数据,用 First 更明确。GORM 的局限与注意事项反射开销:基于反射的字段映射在高频写入场景下有性能损耗,极端场景可考虑 sqlx 或 sqlc复杂 SQL 受限:窗口函数、CTE 等复杂查询需要手写原生 SQL(db.Raw())自动迁移只增不删:AutoMigrate 不会删除列或修改列类型,生产环境应使用专业迁移工具软删除陷阱:Unique 约束与软删除冲突——软删除的记录仍占唯一索引位,需用复合唯一索引或 WHERE 条件索引
服务端阅读 05月28日 02:12

Shell 脚本中如何进行错误处理和调试?有哪些常用的技巧?

Shell 脚本没有编译器的类型检查和异常机制,错误一旦发生往往悄无声息地传播,导致脚本在错误的状态下继续运行,产出难以排查的脏数据。所以错误处理和调试能力是区分"能跑的脚本"和"可靠脚本"的分水岭,也是面试中的高频考点。错误处理的核心手段set 选项:让脚本在错误面前不再沉默Shell 默认行为是"命令失败了就失败了,继续执行下一条",这在自动化场景中极其危险。set 选项可以从源头改变这个行为:set -e # 任何命令返回非零退出码时,脚本立即终止set -u # 引用未定义变量时报错退出,而非当作空字符串set -o pipefail # 管道中任意命令失败,整个管道返回失败状态面试中常问的是为什么要组合使用 set -euo pipefail:只用 set -e 不够:false | true 这条管道,false 失败了但管道整体返回 true 的退出码 0,set -e 不会触发退出。加上 pipefail 才能捕获管道内的失败。只用 set -e + pipefail 仍不够:echo $UNDEFINED_VAR 在 set -u 缺失时只输出空行,不会报错;如果这个变量是文件路径,后续操作会作用在错误的目标上。需要注意 set -e 的一个坑:它在 if、while、&&、|| 等条件上下文中不会触发退出,因为 Shell 认为你已经在主动处理该命令的返回值了。trap:在脚本退出时做最后一件事trap 用于捕获信号或事件,最常见的用途是资源清理:cleanup() { rm -f "$TEMP_FILE" echo "Cleaned up temporary resources" >&2}trap cleanup EXIT面试追问:trap 能捕获哪些信号?EXIT 是信号吗?EXIT 不是真正的信号,而是 Shell 内置的伪信号,在脚本以任何方式退出时都会触发(包括正常退出、set -e 触发的退出、未被捕获的信号导致的退出)。INT(Ctrl+C)、TERM(kill 默认信号)是真正的信号,可以在脚本运行中被外部触发。ERR 是另一个伪信号,仅在命令失败(返回非零)时触发,可以用来做错误日志记录:error_handler() { local line_no=$1 echo "Error at line $line_no" >&2}trap 'error_handler $LINENO' ERR自定义错误处理函数对于需要携带上下文信息的错误,封装一个错误处理函数比直接 exit 1 更实用:die() { echo "[FATAL] $1 (line: ${BASH_LINENO[0]})" >&2 exit "${2:-1}"}# 使用示例[ -f "$CONFIG" ] || die "Config file not found: $CONFIG"BASH_LINENO 是 Bash 的内置数组,BASH_LINENO[0] 给出调用者所在行号,比手动标注更可靠。调试的核心手段set -x:最常用的调试开关# 对整个脚本开启bash -x script.sh# 对脚本中某一段开启set -x# 需要调试的代码set +xset -x 的输出以 + 开头,会显示变量展开后的实际值。set -v 则只显示原始输入行,不做变量展开。两者可以组合使用(set -xv),但实践中 set -x 单独使用频率远高于 -v。ShellCheck:静态分析比手动调试更高效面试中提到调试,很多人只想到 set -x,但静态分析工具 ShellCheck 才是效率最高的手段。它能捕获类型错误、未引用变量、不安全的 rm 操作、废弃语法等问题:shellcheck myscript.sh安装一次、受益全程。在 CI 流水线中加入 shellcheck 检查,可以在代码合并前拦截大量低级错误,比运行时调试成本低得多。条件式调试输出生产环境不能到处加 echo,一个受环境变量控制的调试函数更合适:debug() { [ "${DEBUG:-0}" = "1" ] && echo "[DEBUG] $*" >&2 return 0}关键细节:用 ${DEBUG:-0} 提供默认值,避免 set -u 下未定义变量报错;输出重定向到 >&2(标准错误),不污染标准输出的正常数据流。面试中容易忽略的点管道中的错误处理:这是面试高频追问。set -e + set -o pipefail 是基本答案,但更完整的做法是用 PIPESTATUS 数组获取管道中每个命令的退出码:grep "error" app.log | sort | uniq -cecho "Exit codes: ${PIPESTATUS[@]}"子 Shell 中的 set 选项不向上传播:管道中的每个命令运行在子 Shell 中,set -e 在子 Shell 中触发退出只退出子 Shell,不会导致父脚本退出。这是 set -o pipefail 存在的原因之一。临时文件的安全创建:mktemp 比 /tmp/myfile.$$ 更安全,后者存在符号链接攻击风险:TEMP_FILE=$(mktemp) || die "Failed to create temp file"trap 'rm -f "$TEMP_FILE"' EXIT一个可靠脚本的骨架把以上手段组合起来,得到一个可以直接复用的模板:#!/usr/bin/env bashset -euo pipefail# === 错误处理 ===die() { echo "[FATAL] $*" >&2 exit 1}# === 资源清理 ===TEMP_FILE=""cleanup() { [ -n "$TEMP_FILE" ] && rm -f "$TEMP_FILE"}trap cleanup EXIT INT TERM# === 调试支持 ===debug() { [ "${DEBUG:-0}" = "1" ] && echo "[DEBUG] $*" >&2 return 0}# === 主逻辑 ===main() { local input="${1:-}" [ -n "$input" ] || die "Usage: $0 <input>" [ -f "$input" ] || die "File not found: $input" TEMP_FILE=$(mktemp) || die "mktemp failed" debug "Created temp file: $TEMP_FILE" # 业务逻辑 ... debug "Done"}main "$@"面试中给出这个骨架,再配合对每个 set 选项和 trap 信号的讲解,基本能覆盖错误处理和调试这两个考点的全部内容。
服务端阅读 05月28日 02:12

什么是 pnpm,它与 npm 和 Yarn 有什么区别?

pnpm 是什么pnpm(Performant npm)是 Node.js 的包管理工具,核心设计目标是通过内容寻址存储解决 npm/Yarn 的磁盘浪费和幽灵依赖问题。存储机制:内容寻址 vs 扁平复制npm 和 Yarn classic 采用扁平化安装:每个项目的 node_modules 都复制一份完整的包文件。10 个项目用 lodash,磁盘上就有 10 份副本。pnpm 的做法不同——所有包只存一份到全局 store(通常在 ~/.local/share/pnpm/store),项目中通过硬链接指向 store 中的文件:# 查看全局 store 路径pnpm store path# /home/user/.local/share/pnpm/store/v3# 查看 store 中已缓存的包pnpm store status安装同一个包 10 次,磁盘上只有 1 份数据,项目中的 node_modules 只是硬链接指针。这就是 pnpm 能节省 70%+ 磁盘空间的原因。node_modules 结构:严格隔离 vs 幽灵依赖npm/Yarn 的扁平化结构会产生幽灵依赖——你可以在代码中 import 一个没有写在 package.json 里的包,因为它被其他依赖提升(hoisting)到了顶层。// 你的 package.json 只声明了 express// 但 express 依赖了 body-parser,body-parser 又依赖了 qs// npm 下这段代码能运行,因为 qs 被提升到了顶层const qs = require("qs") // 危险:没有在 package.json 中声明// 一天 express 换了依赖不再安装 qs,你的代码就崩了pnpm 的 node_modules 结构是这样的:node_modules/├── .pnpm/│ ├── express@4.18.2/│ │ └── node_modules/│ │ ├── express/ → 硬链接到 store│ │ └── body-parser/ → express 的依赖,只 express 能访问│ └── qs@6.11.0/│ └── node_modules/│ └── qs/ → 硬链接到 store└── express/ → 软链接到 .pnpm/express@4.18.2/node_modules/express每个包只能访问自己声明的依赖,require("qs") 如果没写在 package.json 里会直接报错。如果遇到必须用扁平结构的兼容性问题,可以在 .npmrc 中设置:shamefully-hoist=true但这会失去严格隔离的优势,只作为最后的兜底方案。安装速度对比冷安装(无缓存)和热安装(有缓存)的实际表现:| 场景 | npm | Yarn | pnpm ||------|-----|------|------|| 冷安装(1500 依赖) | ~48s | ~22s | ~14s || 热安装(已有缓存) | ~12s | ~3s | ~3s || 更新单个依赖 | ~9s | ~1.2s | ~0.9s |热安装快的原因:pnpm 检测到 store 中已有该包,直接创建硬链接,不需要网络请求和文件复制。monorepo 支持pnpm 原生支持 workspace,通过 pnpm-workspace.yaml 配置:# pnpm-workspace.yamlpackages: - "apps/*" - "packages/*"# 只安装某个 workspace 的依赖pnpm install --filter @myapp/web# 在所有 workspace 中执行脚本pnpm -r run build# 包间依赖引用pnpm add @myapp/utils --filter @myapp/web相比 npm workspaces,pnpm 的 --filter 语法更灵活,且 workspace 间的依赖也遵循严格隔离,不会意外访问兄弟包的内部模块。迁移到 pnpm# 从 npm 迁移:导入 lockfilepnpm import # 自动读取 package-lock.json 生成 pnpm-lock.yaml# 或直接安装rm -rf node_modules package-lock.jsonpnpm install# 从 Yarn 迁移同理,pnpm import 也支持 yarn.lock追问Q: pnpm 的硬链接在跨文件系统时会失效吗?会。硬链接要求源文件和目标在同一个文件系统分区。如果全局 store 和项目不在同一分区(比如 store 在 SSD,项目在机械硬盘),pnpm 会回退到复制文件,磁盘节省效果消失。Docker 容器中挂载卷时也需注意这个问题。Q: .npmrc 中 shamefully-hoist=true 和 hoist-pattern[] 怎么选?优先用 hoist-pattern 做精确控制,只提升特定包到顶层:# 只提升 eslint 相关包hoist-pattern[]=eslint*hoist-pattern[]=@eslint/*shamefully-hoist=true 是全部提升,等于放弃严格隔离,只在遇到无法绕过的兼容性问题时使用。
服务端阅读 05月28日 02:12

Shell 脚本中如何进行进程管理?如何启动、监控和终止进程?

Shell 进程管理是运维和后端面试的高频考点,核心考查你对进程生命周期、信号机制和作业控制的理解。进程启动Shell 中启动进程有前台、后台和脱离终端三种方式,区别在于进程与当前 shell 的绑定关系。前台与后台执行前台执行会阻塞当前 shell,后台执行在命令末尾加 &,shell 立即返回控制权:# 前台执行(阻塞 shell)./long_task.sh# 后台执行(shell 不阻塞)./long_task.sh &echo "后台进程 PID: $!"$! 保存最近一个后台进程的 PID,是进程管理的关键变量。nohup 与 disown:脱离终端用户退出终端时,shell 会向所有子进程发送 SIGHUP 信号,导致后台进程一并终止。nohup 和 disown 用于解决这个问题:# nohup:启动时即忽略 SIGHUP,输出重定向到 nohup.outnohup ./long_task.sh > task.log 2>&1 &# disown:对已启动的后台进程脱离 shell 控制./long_task.sh &disown %1# disown -h:标记为不接收 SIGHUP,但仍在 jobs 列表中./another_task.sh &disown -h %2面试追问:nohup 和 disown 有什么区别? nohup 在启动时就屏蔽 SIGHUP,是预防性的;disown 是事后将已有作业从 shell 的作业表移除,是补救性的。nohup 还会自动重定向输出,disown 不会。子 shell 与进程替换括号 () 创建子 shell,子 shell 中的变量和目录切换不影响父 shell:# 子 shell 中修改变量不影响父进程( export MY_VAR="child"; echo "子: $MY_VAR" )echo "父: $MY_VAR" # 空# 用子 shell 实现并行执行( task_a.sh ) &( task_b.sh ) &waitecho "两个任务都完成了"进程监控查看进程信息ps 是最基础的进程查看命令,不同参数组合提供不同维度的信息:# 查看所有进程的完整信息ps aux# 查看进程树(显示父子关系)ps auxf# 自定义输出列ps -eo pid,ppid,user,%cpu,%mem,cmd --sort=-%cpu | head# 查看指定进程的详细信息ps -p $(pgrep -d, nginx) -o pid,ppid,state,%cpu,%mem,cmd面试追问:ps aux 和 ps -ef 有什么区别? 两者都显示所有进程,但格式不同。ps aux 是 BSD 风格,aux 不加横杠,输出包含 %CPU、%MEM 等资源占用列;ps -ef 是 System V 风格,-ef 加横杠,输出包含 PPID 列但无资源占用。实际工作中 ps aux 更常用。进程查找# pgrep:按名称模式查找,直接输出 PIDpgrep -f "python.*app.py"# pidof:精确匹配进程名pidof nginx# pgrep -P:查找子进程pgrep -P $PARENT_PID实时监控top 和 htop 提供实时视图。脚本中更常用的是定时轮询:# 检查进程是否存活is_alive() { kill -0 "$1" 2>/dev/null}if is_alive 12345; then echo "进程 12345 正在运行"fikill -0 不发送任何信号,仅检测进程是否存在,是脚本中判断进程存活的标准做法。进程终止与信号信号机制信号是进程间通信的基础机制,理解信号是进程管理的核心:# 列出所有信号kill -l# 常用信号# SIGHUP (1) - 挂起,常用于通知守护进程重载配置# SIGINT (2) - 中断,等同于 Ctrl+C# SIGTERM (15) - 优雅终止,进程可捕获并做清理# SIGKILL (9) - 强制终止,进程无法捕获或忽略# SIGSTOP (19) - 暂停,进程无法捕获# SIGCONT (18) - 继续执行被暂停的进程面试追问:SIGTERM 和 SIGKILL 有什么区别?为什么不推荐直接用 kill -9? SIGTERM (15) 是优雅终止信号,进程可以捕获该信号并执行清理操作(关闭连接、保存状态、释放锁),然后主动退出。SIGKILL (9) 是强制终止,进程无法捕获或忽略,操作系统立即回收资源。直接使用 kill -9 会导致:1)进程来不及释放资源(锁文件、临时文件、socket 连接);2)子进程可能变成孤儿进程;3)数据库等应用可能损坏数据文件。正确做法是先 kill(默认 SIGTERM),等待超时后再 kill -9。终止进程的多种方式# 按 PID 终止kill 12345 # SIGTERMkill -9 12345 # SIGKILL# 按名称终止pkill -f "python.*app.py" # 匹配命令行模式killall nginx # 精确匹配进程名# 终止进程组kill -- -$PGID # 向进程组发送信号# 优雅重启kill -HUP $(cat /var/run/nginx.pid)wait 与退出码wait 命令等待后台进程结束并获取其退出码:./task_a.sh & PID_A=$!./task_b.sh & PID_B=$!wait $PID_A; STATUS_A=$?wait $PID_B; STATUS_B=$?if [ $STATUS_A -eq 0 ] && [ $STATUS_B -eq 0 ]; then echo "全部成功"else echo "task_a 退出码: $STATUS_A, task_b 退出码: $STATUS_B"fi僵尸进程与孤儿进程这是面试中区分候选人水平的关键考点。僵尸进程(Zombie)子进程退出后,父进程必须调用 wait() 读取其退出状态,否则子进程的进程描述符会残留在系统中,成为僵尸进程(状态 Z):# 查找僵尸进程ps aux | awk "$8 ~ /Z/ {print}"# 或用更精确的方式ps -eo pid,ppid,stat,cmd | awk "$3 ~ /Z/ {print}"僵尸进程本身已死,不占 CPU 和内存,但占用 PID 资源和内核进程表项。大量僵尸进程会耗尽 PID,导致无法启动新进程。解决方法:修复父进程,让其正确调用 wait()/waitpid()杀死父进程,僵尸进程会被 init 进程(PID 1)接管并回收在代码中设置 SIGCHLD 信号处理函数自动回收# Shell 中预防僵尸进程trap "wait" CHLD孤儿进程(Orphan)父进程先于子进程退出,子进程被 init 进程(PID 1)收养,成为孤儿进程。孤儿进程不是问题进程,init 会负责回收。但在容器环境中,PID 1 可能不是 init,需要特别注意:# 容器中用 init 系统确保孤儿进程被回收# Dockerfile 中使用cmd ["/sbin/tini", "--", "./app.sh"]面试追问:僵尸进程和孤儿进程有什么区别? 僵尸进程是子进程已死但父进程未回收其状态,进程还在进程表中但已不运行;孤儿进程是父进程已死,子进程还在运行,被 init 接管。僵尸进程是问题需要处理,孤儿进程是正常现象。进程优先级Linux 用 nice 值控制进程优先级,范围 -20(最高优先级)到 19(最低优先级),默认 0:# 以低优先级启动进程nice -n 10 ./heavy_task.sh &# 修改运行中进程的优先级renice -n 5 -p 12345# 查看进程优先级ps -eo pid,nice,cmd普通用户只能降低优先级(增大 nice 值),只有 root 可以提高优先级(减小 nice 值)。信号捕获与 traptrap 命令捕获信号,实现优雅退出是面试常考点:#!/bin/bashcleanup() { echo "清理临时文件..." rm -f /tmp/my_script_*.lock rm -f /tmp/my_script_*.tmp echo "清理完成" exit 0}# 捕获 SIGINT、SIGTERM 和脚本退出trap cleanup INT TERM EXIT# 创建锁文件防止重复运行LOCK_FILE="/tmp/my_script.lock"if [ -f "$LOCK_FILE" ]; then OLD_PID=$(cat "$LOCK_FILE") if kill -0 "$OLD_PID" 2>/dev/null; then echo "脚本已在运行 (PID: $OLD_PID)" exit 1 fifiecho $$ > "$LOCK_FILE"# 主逻辑while true; do echo "工作中... $(date)" sleep 5done面试追问:trap 中捕获 EXIT 信号和捕获 TERM 有什么区别? EXIT 是 shell 特殊信号,在脚本以任何方式退出时(正常结束、exit 命令、收到信号等)都会触发。TERM 只在收到 SIGTERM 时触发。捕获 EXIT 可以确保无论脚本如何退出,清理逻辑都能执行。进程间通信管道匿名管道是最常用的 IPC 方式,命名管道(FIFO)支持无亲缘关系进程间通信:# 匿名管道cat access.log | awk "{print \$7}" | sort | uniq -c | sort -rn | head# 命名管道mkfifo /tmp/task_pipe# 生产者echo "task_data" > /tmp/task_pipe &# 消费者while read line; do process "$line"done < /tmp/task_pipe进程替换进程替换是 Bash 的高级特性,将进程的输出映射为临时文件描述符:# 比较两个命令的输出diff <(sort file1.txt) <(sort file2.txt)# 合并多个进程的输出paste <(command_a) <(command_b)/proc 文件系统/proc 提供内核和进程的实时信息,面试中常问如何不依赖工具获取进程信息:# 查看进程的命令行参数cat /proc/12345/cmdline | tr "\0" " "# 查看进程的环境变量cat /proc/12345/environ | tr "\0" "\n"# 查看进程的文件描述符ls -la /proc/12345/fd/# 查看进程的内存映射cat /proc/12345/maps# 查看进程状态cat /proc/12345/status | head -20实战场景守护进程管理脚本#!/bin/bashAPP_NAME="my_app"APP_CMD="/usr/local/bin/my_app"PID_FILE="/var/run/${APP_NAME}.pid"LOG_FILE="/var/log/${APP_NAME}.log"start() { if [ -f "$PID_FILE" ] && kill -0 $(cat "$PID_FILE") 2>/dev/null; then echo "$APP_NAME 已在运行 (PID: $(cat $PID_FILE))" return 1 fi nohup $APP_CMD >> "$LOG_FILE" 2>&1 & echo $! > "$PID_FILE" echo "$APP_NAME 已启动 (PID: $!)"}stop() { if [ ! -f "$PID_FILE" ]; then echo "$APP_NAME 未运行" return 1 fi local pid=$(cat "$PID_FILE") kill "$pid" local timeout=10 while [ $timeout -gt 0 ] && kill -0 "$pid" 2>/dev/null; do sleep 1 ((timeout--)) done if kill -0 "$pid" 2>/dev/null; then kill -9 "$pid" echo "$APP_NAME 强制终止" else echo "$APP_NAME 已停止" fi rm -f "$PID_FILE"}restart() { stop sleep 1 start}case "$1" in start) start ;; stop) stop ;; restart) restart ;; status) if [ -f "$PID_FILE" ] && kill -0 $(cat "$PID_FILE") 2>/dev/null; then echo "$APP_NAME 运行中 (PID: $(cat $PID_FILE))" else echo "$APP_NAME 未运行" fi ;; *) echo "用法: $0 {start|stop|restart|status}" ;;esac并发控制#!/bin/bashMAX_PARALLEL=4running=0for task in tasks/*.sh; do bash "$task" & ((running++)) if [ $running -ge $MAX_PARALLEL ]; then wait -n # 等待任意一个后台进程完成 ((running--)) fidonewait # 等待所有完成echo "全部任务完成"wait -n 是 Bash 4.3+ 的特性,等待任意一个后台进程完成,是并发控制的关键。进程超时控制```bash使用 timeout 命令timeout 30s ./may_hang.sh手动实现超时runwithtimeout() { local cmd="$1" local timeout=$2$cmd &local pid=$!( sleep $timeout kill -9 $pid 2>/dev/null) &local watchdog=$!wait $pid 2>/dev/nulllocal exit_code=$?kill $watchdog 2>/dev/nullreturn $exit_code}runwithtimeout "./long_task.sh" 60
服务端阅读 05月28日 02:12

pnpm 常用命令有哪些?与 npm 命令有什么区别?

核心回答pnpm 常用命令与 npm 高度相似,关键差异在于:npm install <pkg> 对应 pnpm add <pkg>;npx 对应 pnpm dlx;monorepo 用 pnpm -r(递归)和 pnpm --filter(按包过滤)替代 npm workspaces。但命令相似只是表象,真正的区别在于底层机制——pnpm 使用内容寻址存储(content-addressable store)+ 符号链接结构,解决了 npm 的幽灵依赖和磁盘浪费问题。追问:为什么 pnpm 不存在幽灵依赖?npm 采用扁平化 nodemodules,所有依赖都被提升到顶层,导致代码能 import 未声明的包。pnpm 通过 .pnpm 目录存放硬链接,再用 symlink 严格映射到项目 nodemodules,只暴露 package.json 中声明的依赖。追问:pnpm add 和 npm install 的区别?npm install <pkg> 既用于安装所有依赖(无参数),也用于添加单个包。pnpm 将这两个职责拆分:pnpm install 安装已有依赖,pnpm add 添加新包,语义更清晰。安装与依赖管理# 安装所有依赖pnpm install# 添加依赖(默认 dependencies)pnpm add lodashpnpm add lodash@4.17.21# 添加到不同依赖组pnpm add lodash -D # devDependenciespnpm add lodash -O # optionalDependenciespnpm add lodash --save-peer # peerDependencies# 全局安装pnpm add -g typescript更新与删除# 更新pnpm update lodash # 更新单个包pnpm up -L # 更新所有包到最新版pnpm up -i # 交互式选择更新# 删除pnpm remove lodashpnpm rm lodash express # 删除多个包pnpm remove -g lodash # 删除全局包运行脚本与执行包# 运行 package.json 中的脚本pnpm build # 等同 pnpm run buildpnpm test# 传递参数pnpm build -- --watch# 执行远程包(替代 npx)pnpm dlx create-vite my-app追问:pnpm dlx 和 npx 有什么区别?pnpm dlx 下载包到临时目录执行,不污染全局。npx 在 npm 5.2+ 中引入,行为类似但会优先查找本地已安装的包。两者核心场景一致,但 pnpm dlx 的临时隔离更彻底。Monorepo 命令pnpm 原生支持 monorepo,通过 pnpm-workspace.yaml 声明工作区,配合 --filter 实现精准的包范围操作:# 在指定包中执行pnpm --filter @scope/pkg buildpnpm -F @scope/pkg build# 递归执行(所有包)pnpm -r build# 并行执行pnpm -r --parallel build# 只执行有变更的包pnpm -r --filter "...[origin/main]" build追问:pnpm workspace 和 npm workspaces 的核心差异?pnpm workspace 默认严格隔离依赖,通过 .npmrc 的 shamefully-hoist=true 可切换为扁平模式;npm workspaces 默认扁平提升。pnpm 的 --filter 支持正则、依赖图范围等灵活选择,npm 的 --workspace 功能相对简单。实际项目中 pnpm workspace 的依赖隔离更可靠,避免子包间隐式依赖。Store 管理pnpm 的核心优势来自全局 store,所有项目共享同一份包存储:pnpm store path # 查看 store 路径pnpm store prune # 清理未引用的包,释放磁盘pnpm store verify # 验证 store 完整性追问:pnpm store 的硬链接机制如何节省磁盘?所有项目共享一个全局 store(默认 ~/.local/share/pnpm/store),每个包只存一份。项目 node_modules 中通过硬链接指向 store,不复制文件。10 个项目用同一个版本的 lodash,磁盘只占一份空间。这也是 pnpm 安装速度比 npm 快 2-3 倍的原因——大部分包已在 store 中,只需创建链接。命令速查对比| 功能 | npm | pnpm | 差异说明 ||------|-----|------|----------|| 安装依赖 | npm install | pnpm install | 语义一致 || 添加包 | npm install <pkg> | pnpm add <pkg> | pnpm 语义更清晰 || 删除包 | npm uninstall <pkg> | pnpm remove <pkg> | 命令名不同 || 运行脚本 | npm run <cmd> | pnpm <cmd> | pnpm 可省略 run || 执行远程包 | npx <cmd> | pnpm dlx <cmd> | dlx 隔离更彻底 || 查看依赖 | npm list | pnpm list | 语义一致 || 查看过时包 | npm outdated | pnpm outdated | 语义一致 || 安全审计 | npm audit | pnpm audit | 语义一致 || 全局安装 | npm install -g | pnpm add -g | add vs install || Monorepo | --workspace | --filter | filter 更灵活 |其他实用命令pnpm init # 初始化 package.jsonpnpm create vite # 用模板创建项目pnpm import # 从 npm/yarn 锁文件迁移pnpm link ../local-pkg # 链接本地包pnpm why lodash # 查看包被谁依赖pnpm config list # 查看配置pnpm config set registry https://registry.npmmirror.com # 设置镜像源pnpm outdated # 检查过时依赖pnpm audit # 安全审计
服务端阅读 05月28日 02:10

什么是 React Query,它解决了前端开发中的哪些常见问题?

React Query(现名 TanStack Query)是一个专门管理服务器状态的异步状态管理库。它解决的核心问题是:把原本散落在 useEffect + useState + 全局状态库中的数据获取逻辑,收敛到声明式的 Hook 调用中。它解决的关键问题1. 手动管理请求状态的样板代码没有 React Query 时,每个请求都要写 loading、error、data 三件套:// 传统写法const [data, setData] = useState(null);const [loading, setLoading] = useState(true);const [error, setError] = useState(null);useEffect(() => { fetch("/api/todos") .then(res => res.json()) .then(setData) .catch(setError) .finally(() => setLoading(false));}, []);用 React Query 一行替代:const { data, isLoading, isError } = useQuery({ queryKey: ["todos"], queryFn: () => fetch("/api/todos").then(res => res.json()),});2. 缓存与重复请求React Query 基于 queryKey 自动缓存响应数据。相同 key 的多个组件只发一次请求,后续直接读缓存。通过 staleTime 和 gcTime 控制数据新鲜度和缓存回收。3. 数据同步与过期刷新内置 stale-while-revalidate 策略:先展示缓存数据,后台静默刷新。窗口重新聚焦、网络恢复时自动重新请求,无需手动监听事件。4. 竞态条件当快速切换页面或筛选条件时,早期请求的响应可能覆盖最新数据。React Query 自动取消过期请求,确保结果与当前 queryKey 匹配。5. 服务端状态与客户端状态混用Redux 管客户端 UI 状态没问题,但把服务端数据也塞进去会导致:手动写 loading 状态、手动处理缓存失效、手动同步更新。React Query 把服务端状态单独抽出来,让 Redux 只管 UI 状态。追问staleTime 和 gcTime 的区别是什么?staleTime 控制数据何时标记为过期(触发后台刷新),gcTime 控制缓存多久后被垃圾回收(彻底删除)。useMutation 如何配合 queryClient.invalidateQueries 实现乐观更新?先通过 onMutate 回调更新缓存,失败时在 onError 中回滚。React Query v5 相比 v4 有哪些破坏性变更?移除了 onError/onSuccess 全局回调,统一用 queryClient.getQueryData 替代。为什么说 React Query 是异步状态管理器而非数据请求库?它不关心请求怎么发(fetch/axios/GraphQL 都行),只管 Promise 的状态流转和缓存策略。
服务端阅读 05月28日 02:10

如何进行 SSH 安全加固?有哪些最佳实践和安全配置建议?

SSH 安全加固是运维和后端面试中的高频考点,也是生产环境必须落地的安全措施。一次配置不当的 SSH 服务,可能让整个服务器暴露在暴力破解和未授权访问的风险之下。本文从认证、网络、加密、密钥管理、监控五个层面系统讲解 SSH 安全加固方案。核心原则:最小权限 + 纵深防御SSH 安全加固不是单一配置的修改,而是从多个层面构建防线。核心思路:即使某一层被突破,还有下一层阻拦。没有任何一项配置能单独保证安全,只有组合使用才能形成有效防御体系。认证层加固禁用密码认证,仅允许密钥登录密码认证是暴力破解的最大突破口。密钥认证从根本上消除了密码被猜解的风险——密钥空间足够大,暴力破解在计算上不可行。# /etc/ssh/sshd_configPasswordAuthentication noPubkeyAuthentication yesAuthorizedKeysFile .ssh/authorized_keys密钥类型选择:优先 ED25519(更短、更快、更安全),RSA 至少 4096 位。# 推荐:ED25519ssh-keygen -t ed25519 -C "user@company"# 兼容场景:RSA 4096ssh-keygen -t rsa -b 4096 -C "user@company"注意:ED25519 不支持 -b 参数指定密钥长度,它的密钥长度是固定的。原始文章中 ssh-keygen -t ed25519 -b 4096 的写法是错误的,-b 参数会被忽略。禁止 root 直接登录生产环境中,root 直接登录是高危行为。应先以普通用户登录,再通过 sudo 提权,这样所有操作都有审计记录,便于事后追溯。# /etc/ssh/sshd_configPermitRootLogin no如果必须允许 root 登录(极少数场景),至少使用 PermitRootLogin prohibit-password,仅允许密钥认证。prohibit-password 比 without-password 更明确,是 OpenSSH 7.0+ 的推荐写法。限制可登录的用户和组不要让系统上所有用户都能 SSH 登录,明确指定允许登录的白名单。# /etc/ssh/sshd_configAllowUsers admin deployAllowGroups ssh-usersAllowUsers 和 AllowGroups 是白名单机制,不在名单中的用户即使有密钥也无法登录。两者同时配置时取交集,即用户必须同时满足用户和组的限制。搭配 DenyUsers 使用时,deny 优先于 allow。限制认证尝试次数和连接速率暴力破解依赖大量快速尝试,限制速率可以直接阻断此类攻击。# /etc/ssh/sshd_configMaxAuthTries 3MaxStartups 10:30:100LoginGraceTime 60MaxStartups 10:30:100 的含义:超过 10 个未完成连接时,以 30% 概率拒绝新连接;超过 100 个时全部拒绝。这个参数是防止连接耗尽型攻击的关键配置。网络层加固修改默认端口改端口不是安全措施,而是降低噪声的手段。自动化扫描工具通常只扫 22 端口,改端口后日志中的扫描噪音会大幅减少,便于发现真正有威胁的连接。# /etc/ssh/sshd_configPort 2222修改后务必同步更新防火墙规则和安全组,否则会被自己挡在门外。建议在修改前先通过 console 或带外管理确认回退方案。防火墙限制来源 IP最有效的访问控制是只允许已知 IP 连接。# ufw 示例:仅允许办公网段ufw allow from 10.0.1.0/24 to any port 2222 proto tcpufw enable# iptables 示例iptables -A INPUT -p tcp --dport 2222 -s 10.0.1.0/24 -j ACCEPTiptables -A INPUT -p tcp --dport 2222 -j DROP如果来源 IP 不固定,可以配合 VPN 或跳板机使用,将 SSH 访问收敛到单一入口。云上环境建议通过安全组实现,比 iptables 更不易出错。使用 fail2ban 自动封禁fail2ban 通过分析日志自动封禁异常 IP,是对暴力破解的自动化防御。# /etc/fail2ban/jail.local[sshd]enabled = trueport = 2222maxretry = 3bantime = 3600findtime = 600注意事项:maxretry 不宜设太小,否则合法用户输错一次密码就可能被封。生产环境建议配合 ignoreip 将管理网段加入白名单,避免误封。bantime 建议设为较长时间(如 86400),频繁攻击的 IP 没必要短时间解封。配置连接超时长时间空闲的 SSH 会话是安全隐患,终端可能被旁人操作。# /etc/ssh/sshd_configClientAliveInterval 300ClientAliveCountMax 2300 秒(5 分钟)无操作发送一次心跳探测,连续 2 次无响应则断开。实际超时时间 = ClientAliveInterval * ClientAliveCountMax = 600 秒。根据业务场景调整:高频操作环境可以设短一些,长时间编译部署的环境适当延长。加密层加固使用强加密算法默认配置可能包含已淘汰的弱算法(如 3des、arcfour)。显式指定安全算法列表,确保通信强度。# /etc/ssh/sshd_configKexAlgorithms curve25519-sha256,curve25519-sha256@libssh.org,diffie-hellman-group-exchange-sha256Ciphers chacha20-poly1305@openssh.com,aes256-gcm@openssh.com,aes128-gcm@openssh.comMACs hmac-sha2-512-etm@openssh.com,hmac-sha2-256-etm@openssh.com可以用 ssh-audit 工具检测当前配置的加密强度:# 安装pip3 install ssh-audit# 检测ssh-audit server_ipssh-audit 会输出当前使用的密钥交换、加密、MAC 算法的安全等级,标记出需要替换的弱算法。禁用不必要的功能每多开一个功能就多一个攻击面。不需要的功能一律关闭。# /etc/ssh/sshd_configX11Forwarding noGatewayPorts noPermitTunnel noAllowTcpForwarding 需要根据业务判断:如果需要端口转发(如数据库调试),保留 yes;否则设为 no。注意设为 no 并不能完全阻止端口转发,用户仍可通过 command= 方式间接实现,真正需要禁止时应配合 no-port-forwarding 在 authorized_keys 中限制。密钥管理为私钥设置密码短语即使私钥文件泄露,没有密码短语也无法使用。这是密钥安全的最后一道防线。ssh-keygen -t ed25519 -C "user@company"# 在提示时输入强密码短语已有密钥可以补设密码短语:ssh-keygen -p -f ~/.ssh/id_ed25519配合 ssh-agent 使用,输入一次密码短语后,后续连接自动认证,不必每次都输入。限制密钥用途在 authorized_keys 中可以为每个密钥设置限制条件,实现精细化的访问控制。# ~/.ssh/authorized_keys# 限制只能执行特定命令command="/usr/local/bin/backup.sh" ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI...# 限制来源 IPfrom="10.0.1.0/24" ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI...# 组合限制:来自特定 IP 且不能端口转发from="10.0.1.0/24",no-port-forwarding ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI...这种机制特别适合 CI/CD 部署场景:为部署密钥绑定 command,即使密钥泄露,攻击者也只能执行指定命令。可用的限制选项还包括 no-pty(禁用交互式终端)、no-agent-forwarding(禁用 agent 转发)等。定期轮换密钥密钥应该像密码一样定期更换,尤其在人员离职时必须及时清理。# 生成新密钥ssh-keygen -t ed25519 -f ~/.ssh/new_key -C "user@company"# 部署新公钥ssh-copy-id -i ~/.ssh/new_key.pub user@server# 确认新密钥可用后再删除旧密钥rm ~/.ssh/old_key ~/.ssh/old_key.pub建议在密钥的 -C 注释中包含创建日期,方便追踪轮换周期。多因素认证SSH 证书替代传统密钥大规模团队管理中,逐个分发密钥并维护 authorized_keys 效率极低。SSH 证书通过 CA 签发,自动过期,无需手动清理。# 生成 CA 密钥ssh-keygen -t ed25519 -f /etc/ssh/ca_key# 签发用户证书(有效期 52 周)ssh-keygen -s /etc/ssh/ca_key -I user_id -n username -V +52w ~/.ssh/user_key.pub# 服务器端信任 CA# /etc/ssh/sshd_configTrustedUserCAKeys /etc/ssh/ca_key.pub证书到期自动失效,人员离职只需不再签发新证书,无需逐台服务器删除公钥。这是 SSH 证书相比传统公钥的最大优势——集中化管理。Facebook(Meta)的基础设施就是大规模使用 SSH 证书的典型案例。TOTP 双因素认证在密钥认证基础上增加动态验证码,即使密钥泄露,没有验证码也无法登录。# 安装 Google Authenticator PAM 模块apt-get install libpam-google-authenticator# 每个用户独立配置google-authenticator# /etc/pam.d/sshd 添加auth required pam_google_authenticator.so# /etc/ssh/sshd_configKbdInteractiveAuthentication yes注意:OpenSSH 8.2+ 推荐使用 KbdInteractiveAuthentication 替代已弃用的 ChallengeResponseAuthentication。配置前确保有 console 访问作为回退手段,万一 PAM 配置出错不会锁死服务器。监控与应急日志监控# /etc/ssh/sshd_configLogLevel VERBOSESyslogFacility AUTHPRIVVERBOSE 级别会记录密钥指纹,便于追踪是哪个密钥登录的。INFO 级别不够详细,DEBUG 级别日志量过大影响性能,VERBOSE 是生产环境的平衡选择。查看登录活动:# 最近成功登录last -n 20# 失败登录尝试lastb -n 20# 实时监控tail -f /var/log/auth.log | grep sshd登录告警在关键服务器上配置登录通知,异常登录可以第一时间发现。# /etc/profile 或 ~/.bashrcif [ -n "$SSH_CLIENT" ]; then echo "SSH login: $(whoami) from $(echo $SSH_CLIENT | awk '{print $1}') at $(date)" | mail -s "SSH Login Alert: $(hostname)" admin@company.comfi大规模环境建议接入集中化告警系统(如 Prometheus AlertManager),而不是依赖邮件通知。入侵应急响应一旦发现异常登录迹象,按以下步骤处置:# 1. 查看异常连接ss -tnp | grep :2222# 2. 紧急封禁可疑 IPiptables -A INPUT -s 可疑IP -j DROP# 3. 如果确认被入侵,立即切断 SSH 服务systemctl stop sshd# 4. 修改配置后重启(先验证语法)sshd -t && systemctl start sshdsshd -t 在重启前检查配置语法,避免配置错误导致无法远程连接。养成任何配置修改后都先执行 sshd -t 的习惯。配置检查清单每次加固后逐项确认:端口已改为非默认值,防火墙规则已同步更新密码认证已禁用,仅允许密钥登录root 直接登录已禁止可登录用户已限制为白名单认证尝试次数已限制(MaxAuthTries <= 3)加密算法已更新为安全列表fail2ban 已启用且白名单已配置空闲连接超时已设置日志级别设为 VERBOSE配置修改后已执行 sshd -t 验证语法已备份原配置文件(cp sshdconfig sshdconfig.bak)私钥已设置密码短语旧密钥和离职人员密钥已清理常见误区误区一:改了端口就安全了。 端口扫描工具可以遍历所有端口,改端口只是降低噪音,不能替代其他加固措施。误区二:密钥认证就不需要 fail2ban 了。 fail2ban 还能防御探测行为和异常连接模式,两者互补而非替代。误区三:加固一次就万事大吉。 SSH 软件需要定期更新,密钥需要轮换,日志需要审计。安全是持续过程,不是一次性操作。误区四:配置越严格越安全。 过度加固可能导致运维不便甚至锁死自己。加固策略要根据实际场景权衡,始终确保有可恢复的手段(如 console 访问或带外管理)。误区五:AllowTcpForwarding no 就能禁止端口转发。 用户仍可通过 command= 方式或 SSH 代理转发间接实现。真正需要禁止时应配合 authorized_keys 中的 no-port-forwarding 限制。
服务端阅读 05月28日 02:10

pnpm workspace 如何配置和使用?

pnpm workspace 是 pnpm 内置的 monorepo 方案,让你在一个仓库里管理多个互相依赖的包。相比 yarn workspaces 和 lerna,它零额外依赖、硬链接共享磁盘空间,配置也最简单。如何声明 workspace在项目根目录创建 pnpm-workspace.yaml:packages: - 'packages/*' - 'apps/*' - 'shared/*'根目录的 package.json 必须设置 "private": true,防止根包被误发布。典型目录结构my-monorepo/├── pnpm-workspace.yaml├── package.json # private: true├── pnpm-lock.yaml├── .npmrc # pnpm 专用配置├── packages/│ ├── ui/│ │ ├── package.json # name: @my-org/ui│ │ └── src/│ └── utils/│ ├── package.json # name: @my-org/utils│ └── src/└── apps/ ├── web/ │ ├── package.json # name: @my-org/web │ └── src/ └── server/ ├── package.json └── src/包间依赖如何引用使用 workspace: 协议引用本地包,开发时直接链接源码,不用发布再安装:// apps/web/package.json{ "name": "@my-org/web", "dependencies": { "@my-org/ui": "workspace:*", "@my-org/utils": "workspace:^1.0.0" }}workspace: 后面的版本写法决定了发布时的替换规则:| 协议写法 | 开发时行为 | 发布时替换为 ||---------|-----------|-------------|| workspace:* | 链接最新 | 精确版本如 1.2.3 || workspace:^ | 链接最新 | ^1.2.3 || workspace:~ | 链接最新 | ~1.2.3 || workspace:^1.0.0 | 链接最新 | ^1.0.0(保留原范围) |发布时 workspace: 会被自动替换为实际版本号,这是 pnpm 的内置行为,不需要额外配置。常用命令速查# 安装所有包的依赖pnpm install# 在指定包中执行命令(--filter 或 -F)pnpm --filter @my-org/ui buildpnpm -F @my-org/ui build# 递归执行:所有包都跑 buildpnpm -r build# 只构建有变更的包(基于 git diff)pnpm -r --filter "...[origin/main]" build# 给指定包添加依赖pnpm --filter @my-org/web add @my-org/ui# 给根目录添加公共开发依赖(-w 标志)pnpm add -Dw eslint prettier-r(递归)和 --filter 的区别:-r 对所有包执行,--filter 按条件筛选。生产环境推荐用 --filter 精确控制,避免无关包被意外构建。.npmrc 关键配置pnpm workspace 的许多行为可以通过 .npmrc 调整:# 未在 workspace 找到的包是否从 registry 下载(默认 true)link-workspace-packages=true# 依赖提升策略(避免幽灵依赖)shamefully-hoist=falsenode-linker=hoisted # 需要 hoist 时用这个,不推荐# 严格对等依赖strict-peer-dependencies=truelink-workspace-packages=true 配合 workspace:* 使用时,如果本地包版本满足范围就链接本地,否则从 registry 下载。这在逐步迁移 monorepo 时很有用。用 changesets 管理版本和发布多包版本管理推荐用 changesets,它是 pnpm 官方推荐的方案:# 安装pnpm add -Dw @changesets/clipnpm changeset init# 工作流pnpm changeset # 交互式记录本次变更(选包、选版本类型、写 changelog)pnpm changeset version # 根据记录更新 package.json 和 CHANGELOG.mdpnpm -r publish # 发布所有有新版本的包changeset init 会生成 .changeset/ 目录和 config.json,其中可以配置 changelog 格式、access(public/restricted)等。常见问题Q: 修改了子包代码,依赖它的包要重新安装吗?不需要。workspace:* 链接的是源码,修改即生效(前提是需要重新 build 的包要重新构建)。Q: 发布时 workspace: 协议怎么处理?pnpm publish 时 workspace:* 会自动替换为 package.json 中的实际版本号。如果版本号不存在会报错。Q: 怎么排查某个包的依赖关系?pnpm list --filter @my-org/web --depth 1 # 查看依赖树pnpm why @my-org/ui --filter @my-org/web # 查看为什么依赖这个包Q: 子包之间循环依赖怎么办?pnpm 会报错。解决方式是抽取共享逻辑到第三个包,或者通过接口/事件解耦。pnpm workspace vs 其他方案| 特性 | pnpm workspace | Yarn Workspaces | Lerna | Turborepo ||------|---------------|-----------------|-------|-----------|| 内置支持 | 是 | 是 | 需安装 | 需安装 || 依赖存储 | 硬链接(全局 store) | 符号链接 | 各自安装 | 依赖 pnpm/yarn || 磁盘效率 | 最高 | 中等 | 最低 | 取决于底层 || 任务缓存 | 否 | 否 | 否 | 是 || 配置复杂度 | 低 | 低 | 高 | 中 || 版本管理 | 需配合 changesets | 需配合工具 | 内置 | 需配合工具 |pnpm workspace 本身只解决依赖管理和包链接,任务编排(缓存、并行)交给 Turborepo 或 Nx,版本发布交给 changesets——这是目前最主流的组合方案。
服务端阅读 05月28日 02:10

pnpm 的 overrides 和 resolutions 有什么区别?如何使用?

pnpm.overrides:原生覆盖机制pnpm.overrides 是 pnpm 原生的依赖覆盖配置,写在 package.json 的 pnpm 字段中,也可以写在 pnpm-workspace.yaml 里。// package.json{ "pnpm": { "overrides": { "lodash": "^4.17.21", "react": "^18.0.0" } }}# pnpm-workspace.yamloverrides: "lodash": "^4.17.21" "react": "^18.0.0"精确路径覆盖用 > 指定只覆盖某个包的依赖,不影响其他包对同一依赖的使用:{ "pnpm": { "overrides": { "webpack>lodash": "^4.17.21", "antd>rc-util": "^5.30.0" } }}也可以限定只覆盖特定版本的依赖:{ "pnpm": { "overrides": { "minimist@<1.2.6": "^1.2.6" } }}引用项目直接依赖用 $ 前缀引用项目自身声明的依赖版本,避免硬编码版本号导致不一致:{ "dependencies": { "react": "^18.2.0" }, "pnpm": { "overrides": { "react": "$react" } }}这样所有间接依赖的 react 都会使用项目声明的 ^18.2.0,而不是单独写一个版本。移除依赖用 - 可以把某个依赖从依赖树中移除:{ "pnpm": { "overrides": { "some-package>unused-dep": "-" } }}这在处理可选依赖或减少安装体积时有用。替换包将一个包替换为另一个:{ "pnpm": { "overrides": { "node-sass": "sass", "request": "axios" } }}对 peerDependencies 生效pnpm.overrides 会覆盖 peerDependencies 的版本解析。这在统一 React 版本时很常见——某些组件库的 peerDependency 声明了旧版 React,但你希望它们都使用项目中的版本。resolutions:Yarn 兼容字段resolutions 是 Yarn 的依赖覆盖字段,pnpm 为了方便从 Yarn 迁移的项目也支持读取它:{ "resolutions": { "lodash": "^4.17.21" }}resolutions 的局限resolutions 在 pnpm 中是兼容层,功能比 pnpm.overrides 少很多:不支持 > 路径指定,只能全局覆盖不支持 $ 引用直接依赖不支持 - 移除依赖不支持 @版本号 限定范围只能写在 package.json 中,不支持 pnpm-workspace.yaml两者优先级当 pnpm.overrides 和 resolutions 同时存在时,pnpm.overrides 优先级更高。如果同一条目两边都写了,以 pnpm.overrides 为准。有个已知问题:当 pnpm-workspace.yaml 中写了 overrides 时,package.json 里的 resolutions 可能被忽略而不是合并。所以迁移项目时建议把 resolutions 手动迁移到 pnpm.overrides,而不是两边混用。区别对比| 特性 | pnpm.overrides | resolutions ||------|----------------|-------------|| 路径指定(>) | 支持 | 不支持 || 版本限定(@版本) | 支持 | 不支持 || 引用直接依赖($) | 支持 | 不支持 || 移除依赖(-) | 支持 | 不支持 || 配置位置 | package.json 或 pnpm-workspace.yaml | 仅 package.json || peerDependencies 覆盖 | 支持 | 支持 || 优先级 | 高 | 低 || 来源 | pnpm 原生 | Yarn 兼容 |什么时候用哪个新项目直接用 pnpm.overrides,不需要考虑 resolutions。从 Yarn 迁移的项目,短期可以保留 resolutions 让项目先跑起来,但应尽快迁移到 pnpm.overrides,否则缺少路径指定和版本限定功能,覆盖精度不够,还可能遇到优先级冲突问题。常见使用场景修复传递依赖的安全漏洞第三方包依赖了有漏洞的旧版本,该包还没更新,你先手动覆盖:{ "pnpm": { "overrides": { "follow-redirects@<1.15.6": "1.15.6" } }}统一 React 版本monorepo 里不同包可能依赖不同版本的 React,用 $ 引用统一:{ "dependencies": { "react": "^18.2.0", "react-dom": "^18.2.0" }, "pnpm": { "overrides": { "react": "$react", "react-dom": "$react-dom" } }}只覆盖某个包的依赖某个包依赖了不兼容的版本,但你不想影响其他包:{ "pnpm": { "overrides": { "antd>rc-util": "^5.30.0" } }}验证覆盖效果修改 overrides 后需要重新安装依赖:pnpm install用以下命令确认覆盖是否生效:# 查看实际安装的版本pnpm list react# 查看某个依赖被谁引入pnpm why lodash# 查看完整依赖树pnpm list --depth=10注意事项全局覆盖要谨慎。把所有包的某个依赖强制升到大版本,可能导致不兼容。优先用路径覆盖(>)只影响目标包。修改 overrides 后记得重新 pnpm install,如果锁文件没更新,可以 pnpm install --force 强制刷新。
服务端阅读 05月28日 02:09

WebView中如何管理Cookie?有哪些注意事项?

WebView中管理Cookie是混合开发中的核心问题,涉及原生与H5的登录态同步、安全防护和跨平台差异。以下是完整的管理方案和注意事项。核心答案:Cookie同步机制WebView与原生应用维护独立的Cookie存储,必须手动同步才能保持登录态一致。Android端使用CookieManager:CookieManager cookieManager = CookieManager.getInstance();cookieManager.setAcceptCookie(true);// 设置CookiecookieManager.setCookie(url, "session_id=abc123; Path=/; Domain=.example.com");// 强制持久化(SDK 21+自动同步,低版本需手动flush)cookieManager.flush();// 读取CookieString cookie = cookieManager.getCookie(url);iOS端使用WKHTTPCookieStore(iOS 11+):let cookieStore = webView.configuration.websiteDataStore.httpCookieStorelet cookie = HTTPCookie(properties: [ .domain: ".example.com", .path: "/", .name: "session_id", .value: "abc123", .secure: true])!cookieStore.setCookie(cookie) { self.webView.load(request)}关键差异:iOS的WKWebView与原生HTTPCookieStorage是隔离的,不像旧版UIWebView自动共享。必须通过WKHTTPCookieStore显式同步,且操作是异步的。Cookie持久化与恢复应用重启后Cookie可能丢失,需要做好持久化:设置合理的expires或max-age,避免会话级Cookie在应用关闭后失效Android:CookieManager自动持久化到Cookies.db,调用flush()确保写入iOS:WKWebsiteDataStore.default()会自动持久化;使用nonPersistent()则仅内存存储监听Cookie变化,将关键Cookie备份到原生存储(如SharedPreferences / Keychain)Cookie安全属性安全属性配置直接影响应用安全,必须逐项检查:| 属性 | 作用 | 设置方式 ||------|------|----------|| Secure | 仅HTTPS传输 | Secure 标志 || HttpOnly | 禁止JS访问 | 服务端Set-Cookie头设置 || SameSite | 防CSRF攻击 | SameSite=Strict或Lax || Domain/Path | 限制作用范围 | 服务端配置 |SameSite属性详解:Strict:完全禁止第三方携带,最安全但可能影响跳转体验Lax(默认):允许顶级导航携带,平衡安全与体验None:允许跨站发送,必须配合Secure使用跨域与第三方Cookie处理WebView加载第三方页面时,Cookie策略需要特别注意:Android:CookieManager.setAcceptThirdPartyCookies(webView, true)允许第三方CookieiOS:WKWebView默认限制第三方Cookie,需在WKWebViewConfiguration中配置注意ITP(Intelligent Tracking Prevention)会自动清除未交互域的Cookie跨域场景优先使用postMessage传递令牌,而非依赖CookieCookie清理策略合理清理Cookie避免内存泄漏和隐私问题:// Android:清理指定域CookieManager.getInstance().setCookie(url, "session_id=; Path=/; Max-Age=0");// 清理全部CookieManager.getInstance().removeAllCookies(null);// iOS:清理指定CookiecookieStore.deleteCookie(cookie) { // 处理完成}// 清理全部WKWebsiteDataStore.default().removeData(ofTypes: [WKWebsiteDataTypeCookies], modifiedSince: Date.distantPast) { }清理时机:用户登出、账号切换、隐私设置变更、应用进入后台超过阈值时间。常见踩坑与解决方案1. Cookie设置后首次请求不带Cookie原因:iOS的setCookie是异步操作,Cookie写入完成前请求已发出。解决:在setCookie的回调中再执行loadRequest,或使用dispatch_group确保所有Cookie设置完成。2. Android 5.0+部分机型Cookie同步失败原因:Chromium内核版本差异导致CookieSyncManager行为不一致。解决:SDK 21+已废弃CookieSyncManager,统一使用CookieManager.flush();确保在onPageFinished回调后读取Cookie。3. X5内核WebView的Cookie问题腾讯X5内核使用独立的CookieManager(com.tencent.smtt.sdk.CookieManager),需单独处理,不能复用系统WebView的Cookie。4. Cookie数量和大小限制单个Cookie不超过4KB每个域最多50个Cookie(浏览器间有差异)超出限制时旧Cookie会被淘汰,导致登录态丢失隐私合规注意事项GDPR/CCPA要求:使用Cookie前需获取用户同意中国《个人信息保护法》:Cookie属于个人信息,需明示收集用途ATT(App Tracking Transparency):iOS 14.5+追踪需用户授权建议实现Cookie Consent弹窗,提供Cookie偏好设置入口追问:如何判断Cookie同步是否成功?在WebView加载完成后,通过onPageFinished(Android)或WKNavigationDelegate.didFinish(iOS)回调中,用CookieManager.getCookie(url)或WKHTTPCookieStore.getAllCookies()读取Cookie,与原生存储比对。也可在shouldInterceptRequest中拦截请求头检查Cookie字段。调试时Android用Chrome DevTools,iOS用Safari Web Inspector直接查看Cookie存储内容。
服务端阅读 05月28日 02:08

WebView中如何实现文件上传和下载功能?

WebView中如何实现文件上传和下载功能?文件上传Android端实现Android WebView默认不支持<input type="file">标签,需要开发者手动处理文件选择回调。核心思路是重写WebChromeClient中的文件选择方法,启动系统文件选择器,然后将用户选择的文件通过ValueCallback回传给WebView。版本适配是关键难点。 Android不同版本中回调方法签名不同,需要处理三个重载版本:Android 4.x:openFileChooser(ValueCallback<Uri>, String)Android 5.0+:onShowFileChooser(WebView, ValueCallback<Uri[]>, FileChooserParams)Android 4.4 KitKat:存在已知Bug,文件上传回调不会被触发,这是面试高频追问点具体实现步骤:第一步,在WebChromeClient中重写文件选择方法,保存ValueCallback引用:private var filePathCallback: ValueCallback<Array<Uri>>? = nulloverride fun onShowFileChooser( webView: WebView?, filePathCallback: ValueCallback<Array<Uri>>?, fileChooserParams: FileChooserParams?): Boolean { this.filePathCallback = filePathCallback val intent = fileChooserParams?.createIntent() startActivityForResult(intent, REQUEST_CODE_FILE_CHOOSER) return true}第二步,在onActivityResult中处理选择结果并回传:override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { if (requestCode == REQUEST_CODE_FILE_CHOOSER) { val result = if (resultCode == Activity.RESULT_OK) { data?.data?.let { arrayOf(it) } } else null filePathCallback?.onReceiveValue(result) filePathCallback = null }}第三步,处理运行时权限。Android 6.0+需要动态申请存储权限:if (ContextCompat.checkSelfPermission(this, Manifest.permission.READ_EXTERNAL_STORAGE) != PackageManager.PERMISSION_GRANTED) { ActivityCompat.requestPermissions(this, arrayOf(Manifest.permission.READ_EXTERNAL_STORAGE), PERM_REQUEST)}面试常问: 为什么onShowFileChooser返回true?——返回true表示由应用自己处理文件选择,返回false则WebView不会等待结果。iOS端实现iOS的WKWebView对文件上传的支持相对完善。需要实现WKUIDelegate中的文件选择代理方法:func webView(_ webView: WKWebView, runOpenPanelWithParameters parameters: WKOpenPanelParameters, initiatedByFrame frame: WKFrameInfo, completionHandler: @escaping ([URL]?) -> Void) { let documentPicker = UIDocumentPickerViewController(documentTypes: ["public.item"], in: .import) documentPicker.delegate = self self.completionHandler = completionHandler present(documentPicker, animated: true)}在documentPicker的代理回调中,将选择的文件URL传回:func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) { completionHandler?(urls) completionHandler = nil}iOS相比Android更简单,因为WKWebView在iOS 12+已经原生支持了基本的文件选择,只有需要自定义选择行为时才需要实现上述代理方法。文件下载Android端实现Android WebView不会自动处理下载请求,需要设置DownloadListener拦截下载链接:webView.setDownloadListener { url, userAgent, contentDisposition, mimetype, contentLength -> val request = DownloadManager.Request(Uri.parse(url)).apply { setTitle(URLUtil.guessFileName(url, contentDisposition, mimetype)) setDescription("下载文件中...") setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED) setDestinationInExternalPublicDir(Environment.DIRECTORY_DOWNLOADS, URLUtil.guessFileName(url, contentDisposition, mimetype)) } val dm = getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager dm.enqueue(request)}面试追问:DownloadManager和自定义下载如何选择?DownloadManager:系统级服务,自动处理网络切换、断点续传、通知栏进度,适合大多数场景自定义下载(OkHttp/HttpURLConnection):需要更多控制(自定义证书、进度回调到页面、加密存储)时使用自定义下载的核心代码:val client = OkHttpClient()val request = Request.Builder().url(downloadUrl).build()client.newCall(request).enqueue(object : Callback { override fun onResponse(call: Call, response: Response) { val body = response.body ?: return val total = body.contentLength() body.byteStream().use { input -> FileOutputStream(outputFile).use { output -> val buffer = ByteArray(8192) var bytesRead: Int var downloaded = 0L while (input.read(buffer).also { bytesRead = it } != -1) { output.write(buffer, 0, bytesRead) downloaded += bytesRead val progress = (downloaded * 100 / total).toInt() runOnUiThread { updateProgress(progress) } } } } } override fun onFailure(call: Call, e: IOException) { /* 错误处理 */ }})iOS端实现iOS端通过WKNavigationDelegate拦截导航响应,判断Content-Type来识别下载请求:func webView(_ webView: WKWebView, decidePolicyFor navigationResponse: WKNavigationResponse, decisionHandler: @escaping (WKNavigationResponsePolicy) -> Void) { if let response = navigationResponse.response as? HTTPURLResponse, let mimeType = response.mimeType, mimeType != "text/html" { // 非HTML响应,按下载处理 URLSession.shared.downloadTask(with: response.url!) { tempUrl, _, error in guard let tempUrl = tempUrl else { return } let documentsUrl = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] let destinationUrl = documentsUrl.appendingPathComponent(response.suggestedFilename ?? "download") try? FileManager.default.moveItem(at: tempUrl, to: destinationUrl) }.resume() decisionHandler(.cancel) } else { decisionHandler(.allow) }}两端差异与常见坑点Android端主要坑点:KitKat(4.4)文件上传完全不工作,需引导用户使用系统浏览器openFileChooser方法在Android源码中是隐藏API,不同ROM可能有差异onActivityResult中必须调用ValueCallback,即使用户取消选择也要传null,否则下次无法触发Android 10+ Scoped Storage限制,不能直接访问外部存储iOS端主要坑点:iOS 13+ UIDocumentPicker需要配置com.apple.developer.icloud-container-identifiers下载大文件时NSURLSession需配置background session才能在App退到后台后继续Content-Type判断不够准确时可能误拦截正常页面请求安全要点文件上传下载涉及用户数据安全,需重点关注:上传前校验文件类型和大小,不能仅依赖前端accept属性,服务端也要校验下载文件存储到应用沙盒目录,避免外部存储被其他应用篡改对文件名做sanitize处理,防止路径遍历攻击(如"../../data")HTTPS环境下注意证书校验,中间人攻击可能替换下载内容大文件上传考虑分片和断点续传,避免网络波动导致重头开始
服务端阅读 05月28日 02:07

WebRTC的信令过程是怎样的?为什么需要信令服务器?

WebRTC的信令过程是怎样的?为什么需要信令服务器?WebRTC 本身只负责音视频采集、编解码和点对点传输,但在两个浏览器建立直连之前,它们需要先交换一些关键信息——这个过程就叫信令(Signaling)。信令是 WebRTC 连接建立的前置环节,没有它,两个隔离在各自网络里的浏览器根本找不到对方。信令过程的核心步骤信令过程可以拆成两个并行的子流程:SDP 协商和 ICE 候选交换。它们不是串行的,而是几乎同时进行。SDP 协商:双方就"用什么格式通话"达成一致呼叫方创建 RTCPeerConnection,调用 createOffer() 生成 SDP Offer呼叫方调用 setLocalDescription() 保存本地描述,然后通过信令服务器将 Offer 转发给应答方应答方收到 Offer 后调用 setRemoteDescription() 设置远端描述应答方调用 createAnswer() 生成 SDP Answer,再通过信令服务器回传呼叫方收到 Answer 后调用 setRemoteDescription() 完成协商SDP 里包含了什么?媒体格式(编解码器、payload type)、带宽限制、ICE 账号密码、DTLS 指纹等。双方通过 Offer/Answer 交换这些信息,选出共同支持的编码方案。ICE 候选交换:双方就"通过哪条网络路径连接"达成一致双方创建 RTCPeerConnection 后,ICE Agent 开始收集候选地址每发现一个候选,触发 onicecandidate 事件,通过信令服务器发送给对方对方收到后调用 addIceCandidate() 添加候选ICE 候选有三种类型:Host 候选:本机网卡地址,优先级最高Server Reflexive 候选(srflx):通过 STUN 服务器获取的公网映射地址Relay 候选(relay):TURN 服务器分配的中继地址,优先级最低但穿透能力最强实际项目中常用 Trickle ICE 策略:不等所有候选收集完毕,而是找到一个就发一个,加快连接速度。连接建立SDP 协商和 ICE 候选交换完成后,ICE 会对候选对进行连通性检查。一旦找到可用的路径,DTLS 握手随即完成,SRTP 通道建立,双方开始传输音视频数据。为什么需要信令服务器?WebRTC 规范故意不定义信令协议,但信令服务器在连接建立中不可或缺,原因如下:NAT 穿透的必要条件大多数设备在 NAT 或防火墙后面,无法直接被外部访问。信令服务器是双方交换 ICE 候选的唯一通道——没有它,两个浏览器根本不知道对方的公网地址和端口,也无法协调 STUN/TURN 的使用。媒体协商的桥梁两个浏览器支持的编解码器可能不同(比如 Chrome 支持 VP8/VP9/AV1,Safari 可能只支持 H.264)。SDP 协商让双方在连接建立前就确定好共同支持的编码方案,避免连接成功却发现无法解码的尴尬。会话状态管理实际应用中还需要处理房间创建、用户加入/离开、通话挂断等业务逻辑。这些都不在 WebRTC 规范内,需要信令服务器配合业务层实现。安全认证的起点DTLS 证书指纹通过 SDP 传递,确保连接建立后双方可以验证对方身份。如果指纹不匹配,连接会被拒绝。信令服务器是这个信任链的传输通道。信令协议怎么选?WebRTC 不限制信令协议,常见的选型:WebSocket:最主流的方案,全双工、低延迟,适合实时通信场景Socket.io:在 WebSocket 基础上加了房间、广播等语义,开发效率高HTTP 轮询:实时性差,仅作为降级方案生产环境建议使用 WSS(加密的 WebSocket),防止信令数据被截获或篡改。常见踩坑忘记在 setRemoteDescription 之前设置好 ontrack 等事件监听,导致远端流丢失ICE 候选在 SDP 协商完成前到达,调用 addIceCandidate() 会报错,需要做缓冲处理信令服务器只存在单进程内存中,服务器重启所有房间丢失,建议用 Redis 共享会话状态
服务端阅读 05月28日 02:07

Cookie 在跨域场景下如何使用?需要注意哪些问题?

核心回答Cookie 默认受同源策略约束,只在同源请求中发送。要实现跨域携带 Cookie,需要前后端同时配置:前端设置 credentials: 'include',后端返回 Access-Control-Allow-Credentials: true 且 Access-Control-Allow-Origin 不能为 *。此外,Chrome 80+ 默认将 SameSite 设为 Lax,跨站场景下必须显式设置 SameSite=None; Secure。同源策略对 Cookie 的限制浏览器同源策略要求协议、域名、端口三者完全一致。Cookie 的"源"判断比脚本更宽松——它只看 Domain 和 Path,不区分端口和协议。但跨域请求(如 frontend.com 向 api.com 发请求)默认不会携带 Cookie,这是浏览器的安全行为。跨域携带 Cookie 的配置方案前端:声明携带凭证// fetch 方式fetch('https://api.example.com/data', { credentials: 'include' // 跨域时发送 Cookie});// XMLHttpRequest 方式const xhr = new XMLHttpRequest();xhr.withCredentials = true;xhr.open('GET', 'https://api.example.com/data');xhr.send();credentials: 'include' 表示无论同源还是跨域都发送 Cookie。如果只想同源发送,用 'same-origin'。后端:允许接收凭证// Express + cors 中间件app.use(cors({ origin: 'https://frontend.example.com', // 必须是具体域名,不能是 * credentials: true}));// 设置 Cookieres.setHeader('Set-Cookie', [ 'token=xyz; HttpOnly; Secure; SameSite=None; Path=/']);三个关键约束:Access-Control-Allow-Credentials: true,否则浏览器忽略响应中的 CookieAccess-Control-Allow-Origin 必须是具体域名,* 会导致浏览器拒绝携带 Cookie 的请求Cookie 本身需要 Secure 和 SameSite=None(见下文)SameSite 属性详解SameSite 是 Cookie 最重要的跨域相关属性,控制 Cookie 在跨站请求中是否发送:| 值 | 行为 | 典型场景 ||---|---|---|| Strict | 仅同站请求发送,跨站导航也不发送 | 银行、支付等高安全场景 || Lax(Chrome 80+ 默认) | 同站请求 + 顶级导航的 GET 请求发送 | 大多数场景的默认选择 || None | 跨站请求也发送,必须搭配 Secure | 跨域 API 认证、SSO |Chrome 80 之前 SameSite 默认是 None,之后改为 Lax。这意味着如果你的 Cookie 没有显式声明 SameSite,跨站的 POST 请求、iframe 内请求、Ajax 请求都不会携带 Cookie。实际影响:如果你的前端部署在 app.com,API 部署在 api.com,这两个属于跨站(cross-site),必须设置 SameSite=None; Secure。Domain 属性实现子域共享如果只是子域名之间共享 Cookie(如 a.example.com 和 b.example.com),不需要走 CORS,用 Domain 属性即可:document.cookie = "token=xyz; Domain=.example.com; Path=/";设置 Domain=.example.com 后,所有子域名都能读取该 Cookie。注意 Domain 只能设为当前域名的父域或自身,不能跨顶级域。第三方 Cookie 的淘汰趋势主流浏览器正在逐步淘汰第三方 Cookie:Safari:早已默认阻止所有第三方 Cookie(ITP 策略)Chrome:从 Chrome 115 开始为第三方 Cookie 引入 Storage Access API 限制,计划全面淘汰(时间线多次调整,目前仍在推进)Firefox:默认阻止已知跟踪器的第三方 Cookie(ETP 策略)替代方案正在发展中:Cookie Partitioning(CHIPS):通过 Partitioned 属性让第三方 Cookie 按顶级站点隔离,Chrome 已开始支持Storage Access API:允许用户授权特定第三方访问 CookieFirst-Party Set:同一组织下的域名声明为第一方集合如果你的业务依赖第三方 Cookie(如 SSO、嵌入式登录),需要尽早关注这些变化。预检请求与 Cookie跨域请求如果使用了非简单方法或自定义 Header,浏览器会先发 OPTIONS 预检请求。预检请求本身不会携带 Cookie,但服务器需要在预检响应中包含 Access-Control-Allow-Credentials: true,否则后续的实际请求携带的 Cookie 会被浏览器拒绝。// 预检请求的响应也需要配置app.options('/api/*', cors({ origin: 'https://frontend.example.com', credentials: true, methods: ['GET', 'POST', 'PUT'], allowedHeaders: ['Content-Type', 'Authorization']}));安全防护要点跨域 Cookie 场景下安全风险更高,务必注意:必须设置 HttpOnly:防止 XSS 窃取 Cookie必须设置 Secure:SameSite=None 强制要求,确保只在 HTTPS 下传输验证 Origin 和 Referer:后端应校验请求来源,防止非法域名的跨域请求CSRF 防护:即使有 SameSite,仍建议配合 CSRF Token,因为 SameSite=Lax 对顶级导航 GET 请求仍会放行设置合理的过期时间:避免长期有效的 Cookie 被盗用面试追问方向SameSite 从 None 改为 Lax 后,哪些请求受影响? —— 跨站 POST、iframe 内请求、subresource 请求不再携带 Cookie,但顶级导航的 GET(如 <a> 链接点击)仍会发送。CORS 为什么不允许 Access-Control-Allow-Origin: * 搭配 credentials? —— 因为 * 表示任何域名都可携带凭证访问,这等于完全绕过了同源策略的保护,浏览器的安全模型不允许。Cookie 的 Domain 和 CORS 的 Origin 有什么区别? —— Domain 控制 Cookie 的作用域(哪些域名能访问),Origin 是请求头中的来源标识。两者判断"同源/跨站"的标准不同:Cookie 看注册域名(eTLD+1),CORS 看协议+域名+端口。
服务端阅读 05月28日 02:06

如何配置 SSH 密钥认证?密钥认证相比密码认证有哪些优势?

SSH 密钥认证基于非对称加密,客户端持有私钥、服务器持有公钥,登录时通过加密挑战完成身份验证,无需传输密码。相比密码认证,密钥认证抗暴力破解、免输入密码、可精细控制权限,是服务器安全运维的基本要求。密钥类型怎么选主流密钥算法对比:| 算法 | 密钥长度 | 安全性 | 性能 | 推荐度 ||------|---------|--------|------|--------|| ED25519 | 256 bit | 极高 | 最快 | 首选 || ECDSA | 256 bit | 高 | 快 | 可用 || RSA | 4096 bit | 高 | 慢 | 兼容性场景 |ED25519 基于 Curve25519 椭圆曲线,密钥短、签名快、抗侧信道攻击,是当前首选。RSA 4096 仅在需要兼容老旧系统时使用。生成密钥对# 生成 ED25519 密钥(推荐)ssh-keygen -t ed25519 -C "user@example.com"# 生成 RSA 4096 密钥(兼容旧系统)ssh-keygen -t rsa -b 4096 -C "user@example.com"# 指定密钥文件名ssh-keygen -t ed25519 -f ~/.ssh/deploy_key -C "ci/deploy"# 生成带硬件绑定的 FIDO2 密钥(需要 YubiKey 等设备)ssh-keygen -t ed25519-sk -C "user@example.com"生成后会产生两个文件:私钥(~/.ssh/id_ed25519):绝对不能泄露,等同于你的身份凭证公钥(~/.ssh/id_ed25519.pub):可以公开,上传到目标服务器私钥建议设置 passphrase,即使私钥被盗,攻击者也无法直接使用。配置服务器端密钥登录第一步:复制公钥到服务器# 方法一:ssh-copy-id(最简单)ssh-copy-id -i ~/.ssh/id_ed25519.pub user@hostname# 方法二:手动追加(目标机器没有 ssh-copy-id 时)cat ~/.ssh/id_ed25519.pub | ssh user@hostname \ "mkdir -p ~/.ssh && chmod 700 ~/.ssh && cat >> ~/.ssh/authorized_keys && chmod 600 ~/.ssh/authorized_keys"第二步:确认服务器 SSH 配置编辑服务器上的 /etc/ssh/sshd_config:# 启用公钥认证PubkeyAuthentication yesAuthorizedKeysFile .ssh/authorized_keys# 确认密钥登录成功后,再禁用密码认证PasswordAuthentication no修改后重启服务:# Ubuntu/Debiansudo systemctl restart sshd# CentOS/RHELsudo systemctl restart sshd注意:先测试密钥登录成功,再禁用密码认证,否则可能锁死自己。第三步:设置文件权限权限不对是密钥登录失败最常见的原因:chmod 700 ~/.sshchmod 600 ~/.ssh/authorized_keyschmod 600 ~/.ssh/id_ed25519 # 私钥chmod 644 ~/.ssh/id_ed25519.pub # 公钥密钥认证比密码认证强在哪抗暴力破解:密码可被字典攻击逐个尝试,而 256 bit 的 ED25519 私钥暴力破解概率在计算上不可能。一次暴力尝试的代价相差约 2^128 倍。零密码传输:密码认证每次登录都要把密码发送到服务器,存在中间人截获风险。密钥认证只传输加密签名,私钥永不离开本地。免交互登录:配合 ssh-agent 或无 passphrase 的密钥,可实现自动化部署、定时备份、CI/CD 流水线免密操作,密码认证做不到。细粒度权限控制:在 authorized_keys 中可限制单个密钥的权限:# 限制只能执行 git 操作command="/usr/bin/git-shell" ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI...# 限制来源 IPfrom="10.0.0.0/8" ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI...# 禁用端口转发和 X11no-port-forwarding,no-X11-forwarding ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI...# 组合限制:只允许从内网 SCP 文件command="/usr/libexec/openssh/sftp-server",from="10.0.0.0/8",no-port-forwarding ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI...多因素叠加:密钥本身是「你有的」,加上 passphrase 就是「你知道的」,再加 FIDO2 硬件密钥就是「你有的物理设备」,三因素认证也轻松实现。ssh-agent 管理多个密钥管理多台服务器或多个 Git 平台时,需要不同的密钥:# 启动 agenteval "$(ssh-agent -s)"# 添加密钥(会提示输入 passphrase)ssh-add ~/.ssh/id_ed25519ssh-add ~/.ssh/deploy_key# 查看已加载的密钥ssh-add -l# 删除所有密钥ssh-add -D配置 ~/.ssh/config 让不同主机使用不同密钥:Host github.com IdentityFile ~/.ssh/id_ed25519 User gitHost production-server IdentityFile ~/.ssh/deploy_key User deploy Port 2222Host jump-server IdentityFile ~/.ssh/id_ed25519 ProxyJump noneHost internal-* ProxyJump jump-server IdentityFile ~/.ssh/id_ed25519常见排错密钥登录失败时,按以下顺序排查:1. 用 verbose 模式看详细日志ssh -vvv user@hostname重点看 Offering public key 和 Server accepts key 两行。2. 检查权限# 客户端ls -la ~/.ssh/# 私钥必须是 600,目录必须是 700# 服务器端ls -la ~/.ssh/ ~/.ssh/authorized_keys# authorized_keys 必须 600,~/.ssh 必须 7003. 检查服务器端日志# 查看 SSH 服务日志sudo journalctl -u sshd --since "10 minutes ago"# 或sudo tail -f /var/log/auth.log常见错误原因:| 现象 | 原因 | 解决 ||------|------|------|| Permission denied | authorized_keys 权限 644 | chmod 600 || Permission denied | .ssh 目录权限 755 | chmod 700 || Permission denied | 家目录被组可写 | chmod 750 ~ || Connection refused | sshd 未运行 | systemctl start sshd || 密钥不被接受 | 公钥内容被截断/换行 | 重新复制 || SELinux 阻止 | 文件安全上下文不对 | restorecon -Rv ~/.ssh |安全加固清单使用 ED25519 或 RSA 4096,不用 RSA 2048 及以下私钥设置 passphrase,用 ssh-agent 避免反复输入服务器端禁用密码认证:PasswordAuthentication no禁用 root 远程登录:PermitRootLogin no 或 prohibit-password限制 SSH 端口,不用默认 22定期轮换密钥,移除 authorized_keys 中的旧公钥敏感服务器使用 FIDO2 硬件密钥(ed25519-sk)配置 fail2ban 防止扫描探测审计 SSH 登录日志,关注异常 IP
服务端阅读 05月28日 02:03

TensorFlow Serving是什么?如何用它部署模型?

TensorFlow Serving 是什么?TensorFlow Serving 是 Google 开源的高性能模型服务系统,用 C++ 编写,专门为生产环境设计。它的核心能力是把训练好的 TensorFlow 模型以 REST API 或 gRPC 接口对外提供推理服务,同时支持模型版本管理、热更新和多模型并行托管。跟 Flask 封一个模型接口相比,TFS 的优势在于:gRPC 协议带来的低延迟(通常比 REST 快 3-10 倍)、内置的版本策略(支持同时服务多个版本做 A/B 测试)、以及自动模型加载/卸载机制。简单说,Flask 能做的 TFS 都能做,而且更适合高并发场景。TFS 的架构核心是 Servable 抽象——模型、词表、查找表都可以是 Servable。Manager 负责管理 Servable 的生命周期,Source 监控文件系统发现新版本,Loader 负责加载和估算资源。这种解耦设计让 TFS 可以在不中断服务的情况下完成模型切换。怎么用 TensorFlow Serving 部署模型?部署流程分三步:导出模型 → 启动服务 → 调用推理接口。第一步:导出 SavedModel 格式TFS 只认 SavedModel 格式,不支持 Checkpoint。导出时需要指定签名(SignatureDef),告诉 TFS 输入输出分别叫什么、是什么类型。import tensorflow as tf# 假设 model 是你训练好的 Keras 模型model.save("/models/my_model/1") # 数字 1 是版本号# 也可以用 tf.saved_model.save 手动控制签名tf.saved_model.save(model, "/models/my_model/1", signatures={ 'serving_default': model.__call__.get_concrete_function( tf.TensorSpec(shape=[None, 3], dtype=tf.float32) ) })导出后用 saved_model_cli 检查签名是否正确:saved_model_cli show --dir /models/my_model/1 --all输出会列出签名的输入输出名称、dtype 和 shape。这一步很关键——调用时字段名必须和签名一致,否则报错。导出后的目录结构:/models/my_model/ └── 1/ # 版本号(必须是整数) ├── saved_model.pb # 模型结构和元数据 └── variables/ # 模型权重关键点:版本号必须是整数,TFS 按数字大小判断最新版本。热更新时只需在同级目录新建 2/ 文件夹放入新模型,TFS 会自动检测并加载。第二步:启动 TFS 服务最简单的方式是 Docker:docker run -d --name tfs \ -p 8501:8501 \ -p 8500:8500 \ -v /models/my_model:/models/my_model \ -e MODEL_NAME=my_model \ tensorflow/serving端口说明:8501:REST API(/v1/models/{model}:predict)8500:gRPC也可以用二进制直接启动,适合需要精细控制的场景:tensorflow_model_server \ --model_config_file=models.conf \ --rest_api_port=8501 \ --grpc_port=8500 \ --enable_batching=true \ --batching_parameters_file=batcningenning_config.txt多模型配置文件 models.conf:model_config_list { config { name: "model_a" base_path: "/models/model_a" model_platform: "tensorflow" model_version_policy { specific { versions: 1 versions: 2 } } } config { name: "model_b" base_path: "/models/model_b" model_platform: "tensorflow" }}第三步:调用推理接口REST API 调用(更简单,适合调试):curl -X POST http://localhost:8501/v1/models/my_model:predict \ -H "Content-Type: application/json" \ -d '{"instances": [[1.0, 2.0, 3.0]]}'注意 instances 字段对应的是 SignatureDef 中定义的输入名。如果签名中输入名不是默认的,需要用 inputs 字段显式指定:{ "inputs": { "input_tensor": [[1.0, 2.0, 3.0]] }}gRPC 调用(性能更好,适合生产):import grpcimport numpy as npimport tensorflow as tffrom tensorflow_serving.apis import predict_pb2, prediction_service_pb2_grpcchannel = grpc.insecure_channel('localhost:8500')stub = prediction_service_pb2_grpc.PredictionServiceStub(channel)request = predict_pb2.PredictRequest()request.model_spec.name = 'my_model'request.model_spec.signature_name = 'serving_default'request.inputs['input_tensor'].CopyFrom( tf.make_tensor_proto(np.array([[1.0, 2.0, 3.0]]), dtype=tf.float32))response = stub.Predict(request, 10.0) # 10秒超时result = tf.make_ndarray(response.outputs['output_tensor'])gRPC 比 REST 快的核心原因是使用 Protocol Buffers 序列化,省去了 JSON 解析开销,且支持长连接多路复用。模型版本管理怎么配?TFS 支持三种版本策略:可用性优先(默认):新版本加载完成后才切换,旧版本继续服务直到新版本就绪,零停机资源优先:先卸载旧版本再加载新版本,节省内存但会有短暂不可用指定版本:固定使用某个版本号,适合回滚场景通过 model_version_policy 配置:model_version_policy { specific { versions: 1 versions: 2 }}A/B 测试场景下,可以同时加载多个版本,调用时通过 URL 参数 ?version=2 或 gRPC 的 model_spec.version 指定调用哪个版本。热更新操作:在模型目录下新建版本号文件夹放入新模型即可。TFS 的 Source 模块会定期轮询文件系统(默认 2 秒),发现新版本后自动触发加载。也可以通过 gRPC 调用 ReloadConfig API 手动触发。TFS 和其他部署方案怎么选?| 方案 | 适用场景 | 协议 | 多框架支持 | 生产成熟度 ||------|---------|------|-----------|-----------|| TensorFlow Serving | TF 模型、高并发 | gRPC + REST | 仅 TensorFlow | 高 || TorchServe | PyTorch 模型 | REST + gRPC | 仅 PyTorch | 中(已归档) || NVIDIA Triton | 多框架混合 | HTTP + gRPC | TF/PyTorch/ONNX/TensorRT | 高 || FastAPI/Flask | 快速验证、自定义逻辑 | REST | 任意框架 | 低 |选型建议:纯 TF 生态用 TFS 就够了;多框架混合部署考虑 Triton;快速原型验证用 FastAPI 更灵活。注意 TorchServe 已于 2025 年 8 月归档,如果之前在用建议迁移到 Triton。生产环境要注意什么?性能优化:开启 batching:TFS 内置请求批处理,设置 --enable_batching 和 --batching_parameters_file 可以把多个请求合并成一个大 batch 再推理,显著提升吞吐。典型配置下吞吐可提升 3-5 倍,但 P99 延迟会增加用 TensorRT 优化:--model_platform: "tensorflow_tensorrt" 可以把模型转为 TensorRT 格式,推理速度提升 2-8 倍,适合 GPU 部署调整 inter_op_parallelism 和 intra_op_parallelism 线程数,通常设为 CPU 核心数监控:Prometheus 指标:TFS 默认暴露 http://localhost:8501/monitoring/prometheus 端点,包含请求延迟、QPS、模型加载状态、批处理统计等指标健康检查:GET /v1/models/my_model 返回模型状态,可配合 Kubernetes liveness/readiness probe高可用:多副本部署 + 负载均衡,避免单点故障Kubernetes 集成:官方提供 TF Serving 的 Helm Chart,支持 HPA 自动扩缩容模型存储建议用 NFS 或对象存储挂载,配合 CI/CD 管道自动推送新版本常见坑:模型签名不匹配是最常见的报错原因,部署前务必用 saved_model_cli 验证Docker 镜像分 CPU 和 GPU 版本,GPU 版本需要安装 NVIDIA Container Toolkit大模型首次加载耗时较长,建议预热(启动后发几条测试请求触发懒加载)追问:TFS 能服务非 TensorFlow 模型吗?不能直接服务。TFS 只支持 SavedModel 格式,也就是说只认 TensorFlow 模型。如果需要服务 PyTorch 或 ONNX 模型,要么先转换格式(ONNX → TF),要么换用 NVIDIA Triton 这种多框架服务系统。不过在实际生产中,模型格式转换往往引入精度损失,不建议这么做。更实际的做法是按框架选择对应的服务系统,或者直接上 Triton 统一托管。
服务端阅读 05月28日 02:02

Nuxt.js 的布局系统是如何工作的?如何创建和使用自定义布局?

布局系统的核心机制Nuxt.js 的布局系统本质上是一个页面级别的"外壳组件"。每个页面都会被包裹在某个布局中,布局负责渲染导航栏、侧边栏、页脚等公共结构,页面内容通过插槽注入。在 Nuxt 3 中,布局文件放在 layouts/ 目录下,使用 <slot /> 渲染页面内容(Nuxt 2 使用 <Nuxt />,已废弃)。页面通过 definePageMeta 指定布局名称,框架自动完成匹配。整个流程是:app.vue 中的 <NuxtLayout> 读取当前页面的 layout 元信息,加载对应的布局组件,再通过 <slot /> 把页面内容插入布局的指定位置。默认布局的工作方式layouts/default.vue 是所有页面的默认外壳。只要这个文件存在,未被显式指定其他布局的页面都会自动使用它。<!-- layouts/default.vue --><template> <div> <nav> <NuxtLink to="/">首页</NuxtLink> <NuxtLink to="/dashboard">控制台</NuxtLink> </nav> <main> <slot /> </main> <footer>© 2026 MyApp</footer> </div></template>app.vue 中需要用 <NuxtLayout> 包裹 <NuxtPage />,布局系统才会生效:<!-- app.vue --><template> <NuxtLayout> <NuxtPage /> </NuxtLayout></template>如果 app.vue 中直接写 <NuxtPage /> 而没有 <NuxtLayout> 包裹,即使页面声明了 layout: 'auth',布局也不会渲染——这是初学者最容易踩的坑。创建自定义布局在 layouts/ 下新建 .vue 文件即创建了一个布局。文件名(去掉扩展名)就是布局名称,支持嵌套目录,名称会自动转为 kebab-case。例如 layouts/admin-panel.vue 对应布局名 admin-panel。以一个认证页面布局为例:<!-- layouts/auth.vue --><template> <div class="auth-wrapper"> <div class="auth-card"> <slot /> </div> </div></template><style scoped>.auth-wrapper { min-height: 100vh; display: flex; align-items: center; justify-content: center; background: #f5f5f5;}.auth-card { background: #fff; padding: 2rem; border-radius: 8px; box-shadow: 0 2px 12px rgba(0,0,0,0.1); width: 400px;}</style>在页面中指定布局Nuxt 3 使用 definePageMeta 编译器宏来声明布局,它在编译时处理,没有运行时开销:<!-- pages/login.vue --><script setup>definePageMeta({ layout: 'auth'})</script><template> <form @submit.prevent="handleLogin"> <input v-model="email" type="email" placeholder="邮箱" /> <input v-model="password" type="password" placeholder="密码" /> <button type="submit">登录</button> </form></template>Nuxt 2 的写法是 export default { layout: 'auth' },Nuxt 3 已不推荐。definePageMeta 的好处是它不参与响应式系统,不会出现在最终的客户端 bundle 中。动态切换布局某些场景下需要根据运行时条件切换布局,比如管理员和普通用户看到不同外壳。有两种方式:方式一:在页面模板中使用 NuxtLayout 动态 name<!-- pages/dashboard.vue --><script setup>const route = useRoute()const layoutName = computed(() => route.path.startsWith('/admin') ? 'admin' : 'default')</script><template> <NuxtLayout :name="layoutName"> <div>控制台内容</div> </NuxtLayout></template>方式二:在路由中间件中修改 to.meta.layout// middleware/layout.jsexport default defineNuxtRouteMiddleware((to) => { if (to.path.startsWith('/admin')) { to.meta.layout = 'admin' }})方式一适合页面内部的条件判断,方式二适合全局性的布局策略(比如在 nuxt.config.ts 中配置全局中间件)。注意:方式一在页面中又嵌了一层 NuxtLayout,实际上会产生双重布局包裹,需要同时在 definePageMeta 中设置 layout: false 来禁用自动布局。具名插槽传递区域内容布局支持 Vue 的具名插槽,页面可以向布局的特定区域注入内容。这在需要灵活控制头部或侧边栏时特别有用:<!-- layouts/custom.vue --><template> <div> <header> <slot name="header"> <h1>默认标题</h1> </slot> </header> <div class="content-wrapper"> <aside> <slot name="sidebar" /> </aside> <main> <slot /> </main> </div> </div></template>页面中使用 template #slotName 传入内容:<!-- pages/profile.vue --><script setup>definePageMeta({ layout: 'custom' })</script><template> <template #header> <h1>用户中心</h1> </template> <template #sidebar> <ul> <li>基本信息</li> <li>安全设置</li> </ul> </template> <div>个人资料编辑区域</div></template>具名插槽的 <slot name="header"> 中可以写默认内容,页面不传 #header 时自动展示默认值,传了则覆盖——这比在布局里用 v-if 判断灵活得多。禁用布局与自定义页面过渡某些页面(如全屏展示页、打印页)不需要任何布局外壳,用 layout: false 关闭:<script setup>definePageMeta({ layout: false})</script><template> <div class="fullscreen">全屏内容,不受任何布局包裹</div></template>布局切换时还可以配置过渡动画。在 nuxt.config.ts 中设置 layoutTransition:// nuxt.config.tsexport default defineNuxtConfig({ app: { layoutTransition: { name: 'layout', mode: 'out-in' } }})/* assets/css/main.css */.layout-enter-active,.layout-leave-active { transition: opacity 0.3s ease;}.layout-enter-from,.layout-leave-to { opacity: 0;}布局与页面的生命周期顺序了解执行顺序对调试很重要。Nuxt 3 中布局和页面的生命周期执行顺序为:布局的 setup() 执行布局的 onMounted 注册(此时 DOM 未挂载)页面的 setup() 执行页面的 onMounted 注册页面的 onMounted 回调触发布局的 onMounted 回调触发布局的 onMounted 反而在页面之后触发,因为布局要等插槽内容(即页面)渲染完毕才算挂载完成。如果需要在布局中访问插槽内容的 DOM,要等 onMounted 而非 setup 阶段。常见坑与排查布局不生效? 检查 app.vue 是否用 <NuxtLayout> 包裹了 <NuxtPage />。没有这一层,布局系统不会启动,页面声明的 layout 会被忽略。页面内容空白? 布局文件中必须包含 <slot />(Nuxt 3)或 <Nuxt />(Nuxt 2),否则页面内容无处渲染。布局名称不匹配? layouts/custom-layout.vue 的布局名是 custom-layout,不是 customLayout。definePageMeta 中的 layout 值要与 kebab-case 名称一致。Nuxt 2 项目迁移? 三件事:把布局中的 <Nuxt /> 替换为 <slot />;把页面中的 layout 选项改为 definePageMeta({ layout: 'xxx' });把错误页面从 layouts/error.vue 移到项目根目录的 error.vue。布局中获取页面数据? 布局无法直接调用 useAsyncData 或 useFetch 获取数据(它不是路由组件),但可以通过 useRoute 获取路由参数,或通过 provide/inject 与页面通信。
服务端阅读 05月28日 02:00

Elasticsearch 如何实现近实时搜索?

Elasticsearch 实现近实时搜索的核心机制是 refresh 操作:写入的数据先进入内存缓冲区(Memory Buffer),默认每秒执行一次 refresh,将缓冲区中的文档生成新的 Lucene Segment 并写入文件系统缓存(Filesystem Cache),此时数据即可被搜索——无需等待刷盘,这就是"近实时"的来源。为什么 Elasticsearch 是"近实时"而不是"实时"?数据从写入到可搜索,中间存在约 1 秒的延迟(默认 refresh interval),这是性能与实时性的权衡:如果要求完全实时:每次写入都立即 fsync 到磁盘,I/O 开销巨大,写入吞吐量会急剧下降如果延迟过大:数据长时间不可搜索,用户体验差Elasticsearch 选择了 1 秒的折中方案,既保证了写入性能,又让数据几乎"立即可搜"。数据写入的完整流程一条文档从写入到可搜索,经历三个关键阶段:阶段一:写入 Memory Buffer + Translog写入请求到达 → 文档写入 Memory Buffer(此时不可搜索) → 同时写入 Translog(保证可靠性)Memory Buffer 是 JVM 堆内存中的一块区域,暂存未 refresh 的文档Translog(事务日志)同步写磁盘(可配置),防止宕机丢数据此时文档不可被搜索阶段二:Refresh(默认每秒一次)Refresh 触发 → Memory Buffer 中的文档生成新 Segment → Segment 写入 Filesystem Cache(OS 缓存,非磁盘) → 清空 Memory Buffer → 新 Segment 立即可被搜索Segment 是 Lucene 的倒排索引文件,一旦生成就是不可变的关键点:Segment 只需进入 OS 的文件系统缓存即可被读取,不必等到 fsync 刷盘这就是"近实时"的直接原因阶段三:Flush(默认每 30 分钟或 Translog 超 512MB)Flush 触发 → 执行 commit,将所有 Segment fsync 到磁盘 → 清空 Translog → 生成新的 commit pointFlush 是真正的持久化操作,但频率远低于 Refresh,避免频繁磁盘 I/O。Translog 如何保证数据不丢?Translog 是 Elasticsearch 数据可靠性的核心保障,有两种同步策略:| 配置 | 行为 | 可靠性 | 性能 ||------|------|--------|------|| async(默认) | 每 5 秒 fsync 一次 | 可能丢 5 秒数据 | 高 || request | 每次写请求都 fsync | 不丢数据 | 较低 |// 配置每次请求都同步 TranslogPUT /my_index/_settings{ "index.translog.durability": "request", "index.translog.sync_interval": "5s"}大多数场景用默认的 async 即可。如果业务对数据丢失零容忍(如金融交易),建议设为 request。Refresh Interval 调优实战场景一:批量导入数据时关闭 refresh// 批量导入前关闭 refreshPUT /my_index/_settings{ "index.refresh_interval": "-1"}// 导入完成后恢复PUT /my_index/_settings{ "index.refresh_interval": "1s"}关闭 refresh 后,批量导入速度可提升 2-5 倍,因为省去了每秒生成 Segment 的开销。场景二:对实时性要求不高的场景增大间隔日志分析场景中,数据延迟 30 秒可搜索完全可接受:PUT /log_index/_settings{ "index.refresh_interval": "30s"}这能显著降低 Segment 生成频率,减少 Segment Merge 压力。Segment Merge:不可忽视的后台开销每次 refresh 都会生成一个新 Segment。Segment 数量过多会严重影响搜索性能,Elasticsearch 后台会自动执行 Segment Merge:小 Segment 合并为大 Segment,减少文件数Merge 过程消耗 CPU 和磁盘 I/O如果 refresh 过于频繁(如 100ms),Segment 数量暴增,Merge 压力大这就是为什么 refresh interval 不能无限缩短——看似搜索更快了,实际上 Merge 开销可能拖垮整个集群。常见踩坑1. 写入后立即搜索不到这是最常见的问题。解决方案:// 手动触发 refreshPOST /my_index/_refresh// 或在写入时指定 refresh 参数PUT /my_index/_doc/1?refresh=true{ "title": "test"}?refresh=true 会在写入后立即执行 refresh,但会显著降低写入性能,不建议在批量写入时使用。2. Translog 过大导致恢复慢如果 Translog 积累过多(如关闭了 flush),节点重启时回放 Translog 会很慢。监控 Translog 大小:GET /my_index/_stats?filter_path=indices.*.translog3. Refresh 间隔设太短有人将 refresh_interval 设为 100ms 追求"更实时",结果 Segment 数量暴增,搜索性能反而下降。1 秒已经是工程实践的最佳平衡点。追问:能否做到真正的实时搜索?Elasticsearch 8.x 引入了索引排序(index sorting)等优化,但 refresh 机制的本质没有变。如果业务确实需要毫秒级延迟,可以考虑:使用 _refresh API 手动刷新(牺牲写入吞吐量)使用 Elasticsearch 的 Point-in-Time (PIT) API 保证查询一致性对实时性要求极高的场景,考虑用 Redis 等内存数据库做前置缓存但归根结底,Elasticsearch 的定位就是"近实时"搜索引擎,1 秒延迟是架构层面的权衡结果,并非缺陷。理解 refresh、translog、flush 三者的协作关系,是掌握 Elasticsearch 写入机制的关键。