面试题手册

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

服务端阅读 05月28日 09:25

GraphQL 项目开发有哪些最佳实践

GraphQL 项目开发有哪些最佳实践GraphQL 在实际项目落地时,如果缺乏规范约束,很容易演变成「写起来爽,维护起来痛」的局面。N+1 查询、Schema 膨胀、错误处理不统一、权限漏洞——这些问题在代码量增长后会被迅速放大。以下是经过大量项目验证的关键实践,覆盖 Schema 设计、性能、安全、工程化四个维度。Schema 设计:从源头控制复杂度Schema-First 还是 Code-FirstSchema-First 先写 GraphQL Schema 文件,再实现 Resolver。好处是前后端可以基于 Schema 文件对齐接口契约,评审时聚焦于数据模型而非实现细节。Code-First 用代码生成 Schema,适合快速迭代但可读性稍弱。对于团队协作项目,Schema-First 更利于维护一致性。模块化 Schema 拆分把一个大 Schema 拆成多个子模块,每个模块独立管理自己的类型、查询和变更:# user.graphqltype User { id: ID! name: String! email: String!}extend type Query { user(id: ID!): User users(limit: Int, offset: Int): [User!]!}extend type Mutation { createUser(input: CreateUserInput!): User!}关键点:用 extend type 扩展 Query 和 Mutation,避免所有定义堆积在一个文件里。最终通过工具(如 @graphql-tools/schema 的 mergeTypeDefs)合并成完整 Schema。命名约定类型名用 PascalCase:User、CreateUserInput字段名用 camelCase:firstName、createdAtMutation 以动词开头:createUser、updatePost、deleteComment枚举值用 SCREAMINGSNAKECASE:ADMIN、ACTIVEInput 类型以 Input 后缀结尾:CreateUserInput一致性命名降低团队沟通成本,也让 Code Generator 产出的类型更规整。字段废弃策略不要直接删除字段,用 @deprecated 标注并提供替代方案:type User { id: ID! name: String! fullName: String @deprecated(reason: "使用 name 字段替代")}给客户端至少一个大版本的迁移窗口,等监控显示废弃字段调用归零后再移除。性能:解决 N+1 是第一优先级DataLoader 批量加载N+1 问题是 GraphQL 最常见的性能陷阱。一个查询用户的列表,每个用户的 posts 字段都会触发一次数据库查询,10 个用户就是 11 次查询。DataLoader 通过批量化和缓存机制将 11 次合并为 2 次:import DataLoader from 'dataloader';const userLoader = new DataLoader(async (ids: string[]) => { const users = await User.findByIds(ids); const userMap = new Map(users.map(u => [u.id, u])); return ids.map(id => userMap.get(id));});// 在 Resolver 中使用const resolvers = { Post: { author: (post, args, { loaders }) => { return loaders.user.load(post.authorId); } }};每个请求创建新的 DataLoader 实例,避免跨请求缓存污染。查询复杂度限制恶意客户端可以构造深度嵌套查询,把服务器打挂。必须设置限制:import { createComplexityLimitRule } from 'graphql-validation-complexity';const complexityLimit = createComplexityLimitRule(1000, { onCost: (cost) => console.log(`Query cost: ${cost}`), formatErrorMessage: (cost) => `Query complexity ${cost} exceeds limit of 1000`});const server = new ApolloServer({ typeDefs, resolvers, validationRules: [complexityLimit]});同时限制查询深度(通常 7-10 层)和别名数量,防止资源耗尽攻击。分页设计列表查询必须分页。推荐使用游标分页(Cursor-based Pagination),比偏移量分页更稳定:type Query { users(first: Int!, after: String): UserConnection!}type UserConnection { edges: [UserEdge!]! pageInfo: PageInfo! totalCount: Int!}type UserEdge { cursor: String! node: User!}type PageInfo { hasNextPage: Boolean! hasPreviousPage: Boolean! startCursor: String endCursor: String}游标分页在数据插入或删除后不会出现重复或遗漏,适合实时性要求高的场景。持久化查询生产环境建议启用 Automatic Persisted Queries(APQ),客户端发送查询哈希而非完整查询字符串,减少网络传输体积并提高缓存命中率:import { ApolloServerPluginCacheControl } from 'apollo-server-core';const server = new ApolloServer({ typeDefs, resolvers, plugins: [ ApolloServerPluginCacheControl({ defaultMaxAge: 60 }) ]});安全:认证、授权与输入校验认证放在 Context 层在 Context 初始化阶段完成身份认证,而非每个 Resolver 重复校验:const server = new ApolloServer({ context: async ({ req }) => { const token = req.headers.authorization?.replace('Bearer ', ''); if (!token) throw new AuthenticationError('未提供认证令牌'); const user = await verifyToken(token); return { user, loaders: createLoaders() }; }});授权逻辑放在业务层不要在 Resolver 里写权限判断,委托给 Service 层:// 不要这样做const resolvers = { Mutation: { deletePost: (_, { id }, context) => { if (context.user.role !== 'ADMIN') throw new Error('无权限'); // ... } }};// 应该这样做const resolvers = { Mutation: { deletePost: (_, { id }, { user }) => { return postService.delete(id, user); // 授权逻辑在 Service 内 } }};这样 Resolver 保持薄层,权限规则集中管理,方便审计和测试。输入校验所有客户端输入必须校验,不能只依赖 GraphQL 类型系统:import { z } from 'zod';const CreateUserSchema = z.object({ name: z.string().min(2).max(50), email: z.string().email(), age: z.number().int().min(0).max(150).optional()});const resolvers = { Mutation: { createUser: (_, { input }) => { const validated = CreateUserSchema.parse(input); return userService.create(validated); } }};GraphQL 的类型系统只做结构校验,不做值域校验。用 Zod 或 Yup 补上这一层。错误处理:统一格式,不泄露内部细节自定义错误分类class AppError extends Error { constructor( message: string, public code: string, public statusCode: number = 500 ) { super(message); }}class ValidationError extends AppError { constructor(message: string, public field?: string) { super(message, 'VALIDATION_ERROR', 400); }}class NotFoundError extends AppError { constructor(resource: string, id: string) { super(`${resource}(${id}) 不存在`, 'NOT_FOUND', 404); }class AuthError extends AppError { constructor(message = '认证失败') { super(message, 'AUTH_ERROR', 401); }}错误格式化中间件const formatError = (err: GraphQLFormattedError) => { const original = err.originalError as AppError; if (original instanceof AppError) { return { message: original.message, code: original.code, field: original.field }; } // 生产环境隐藏内部错误 if (process.env.NODE_ENV === 'production') { return { message: '服务器内部错误', code: 'INTERNAL_ERROR' }; } return { message: err.message, code: 'UNKNOWN' };};生产环境绝不向客户端暴露堆栈信息或数据库错误,这是基本的安全底线。工程化:项目结构、测试与监控推荐项目结构src/├── graphql/│ ├── schema/ # .graphql 文件,按领域拆分│ ├── resolvers/ # Resolver,与 Schema 一一对应│ ├── directives/ # 自定义指令(@auth, @cache 等)│ ├── scalars/ # 自定义标量(DateTime, JSON 等)│ └── context.ts # Context 定义与初始化├── services/ # 业务逻辑层├── models/ # 数据模型层├── loaders/ # DataLoader 实例├── errors/ # 错误类定义└── utils/ # 工具函数核心原则:Resolver 只做参数提取和结果返回,业务逻辑下沉到 Service,数据访问下沉到 Model。Resolver 测试测试 Resolver 不需要启动完整服务器,直接测试函数即可:describe('User Resolvers', () => { it('按 ID 查询用户', async () => { const mockUser = { id: '1', name: '张三', email: 'zhang@test.com' }; jest.spyOn(userService, 'findById').mockResolvedValue(mockUser); const result = await userResolvers.Query.user( null, { id: '1' }, { user: { id: 'admin' }, loaders } ); expect(result).toEqual(mockUser); }); it('用户不存在时抛出 NotFoundError', async () => { jest.spyOn(userService, 'findById').mockResolvedValue(null); await expect( userResolvers.Query.user(null, { id: '999' }, context) ).rejects.toThrow('User(999) 不存在'); });});集成测试则验证完整的查询链路:const { query } = createTestClient(server);it('查询用户列表', async () => { const { data, errors } = await query({ query: GET_USERS, variables: { first: 10 } }); expect(errors).toBeUndefined(); expect(data.users.edges).toHaveLength(10);});监控与可观测性在 Apollo Server 的插件钩子中记录查询耗时和错误率:const server = new ApolloServer({ plugins: [{ requestDidStart: () => ({ didResolveOperation: (ctx) => { ctx.requestedAt = Date.now(); }, willSendResponse: (ctx) => { const duration = Date.now() - ctx.requestedAt; const op = ctx.request.operationName || 'anonymous'; metrics.record(op, duration, ctx.errors?.length > 0); } }) }]});关注 P99 耗时和错误率两个核心指标,设置告警阈值。日志规范使用结构化日志,每条日志包含 requestId、operationName、userId 等上下文字段,方便日志平台检索和关联:logger.info('查询完成', { operationName: ctx.request.operationName, duration: elapsed, userId: ctx.context.user?.id});面试追问Q: GraphQL 的 N+1 问题怎么解决?DataLoader 是标准方案。它利用事件循环的微任务队列,在同一轮事件循环中收集所有对同一资源的 load 调用,然后批量执行一次数据库查询。每个请求新建 DataLoader 实例,避免跨请求缓存污染。Q: 怎么防止恶意查询打挂服务器?三层防线:查询深度限制(通常 7-10 层)、查询复杂度评分(如 graphql-validation-complexity)、服务端超时(如 5 秒)。生产环境还应启用持久化查询,只允许预注册的查询执行。Q: GraphQL 和 REST 怎么选?核心判断依据是数据获取的复杂度。客户端需要从多个关联资源聚合数据的场景(如首页信息流),GraphQL 的按需获取优势明显。CRUD 为主的简单场景,REST 更直白。很多团队采用混合方案:核心聚合接口用 GraphQL,独立资源操作用 REST。Q: Schema 如何做版本管理?GraphQL 官方立场是不做版本号,通过 @deprecated 和新增字段实现向前兼容演进。删除字段必须经过废弃周期:先标记 @deprecated 并写明替代方案,至少一个大版本后监控调用归零再移除。
服务端阅读 05月28日 09:24

GraphQL 错误处理有哪些最佳实践?

核心回答GraphQL 错误处理的最佳实践可以归纳为五个关键维度:规范化的错误结构、自定义错误类体系、统一格式化与日志、优雅降级与重试、实时监控与告警。核心原则是——永远不要让客户端收到无法理解的错误,也不要在生产环境中泄露内部实现细节。为什么 GraphQL 的错误处理和 REST 不一样?REST 靠 HTTP 状态码传达错误语义,而 GraphQL 无论成功失败都返回 200,错误信息放在响应体的 errors 数组中。这意味着 GraphQL 需要一套独立的错误表达体系,不能照搬 REST 的思维。标准的 GraphQL 错误响应结构如下:{ "data": { "user": null }, "errors": [ { "message": "User not found", "locations": [{ "line": 2, "column": 3 }], "path": ["user"], "extensions": { "code": "NOT_FOUND", "timestamp": "2024-01-01T12:00:00Z" } } ]}其中 extensions 是最值得利用的字段——它允许你携带自定义的错误码、时间戳、请求 ID 等上下文,是结构化错误处理的基础。如何设计自定义错误类体系?一套清晰的错误类层次结构是所有后续实践的前提。建议按业务语义划分,而非按技术层划分:class AppError extends Error { constructor(message, code, extensions = {}) { super(message); this.code = code; this.extensions = { ...extensions, timestamp: new Date().toISOString() }; }}class NotFoundError extends AppError { constructor(resource, id) { super(`${resource} not found`, 'NOT_FOUND', { resource, id }); }}class ValidationError extends AppError { constructor(message, field) { super(message, 'VALIDATION_ERROR', { field }); }}class AuthError extends AppError { constructor(message = 'Authentication required') { super(message, 'AUTH_ERROR'); }}class RateLimitError extends AppError { constructor(retryAfter) { super('Rate limit exceeded', 'RATE_LIMIT', { retryAfter }); }}在 Resolver 中直接抛出语义明确的错误:const resolvers = { Query: { user: async (_, { id }) => { const user = await User.findById(id); if (!user) throw new NotFoundError('User', id); return user; } }};如何统一错误格式化?自定义错误类定义了"抛什么",formatError 决定了"返回什么"。两者配合才能确保客户端收到一致且安全的错误响应:const formatError = (error) => { const original = error.originalError; // 自定义业务错误:透传结构化信息 if (original instanceof AppError) { return { message: error.message, extensions: { code: original.code, ...original.extensions } }; } // 生产环境:屏蔽内部错误细节 if (process.env.NODE_ENV === 'production') { return { message: 'Internal server error', extensions: { code: 'INTERNAL_ERROR' } }; } // 开发环境:返回完整堆栈 return { message: error.message, extensions: { code: 'INTERNAL_ERROR', stack: error.stack } };};关键点:生产环境绝不暴露堆栈信息或数据库错误原文,这是 GraphQL 安全的第一条铁律。怎么处理部分成功和降级?GraphQL 的一个独特优势是部分成功——某个字段报错不影响其他字段正常返回。利用这一点可以设计降级策略:const resolvers = { Query: { userProfile: async (_, { id }, { dataSources }) => { try { return await dataSources.userAPI.getUser(id); } catch (error) { // 优先返回缓存数据 const cached = await redis.get(`user:${id}`); if (cached) return JSON.parse(cached); // 缓存也没有则返回降级数据,标记为 fallback return { id, name: 'Unknown', isFallback: true }; } } }};对于批量操作,推荐使用 错误结果类型(Error Result Type)模式,在 Schema 层面表达"部分成功":type UserResult { user: User errors: [FieldError!]! success: Boolean!}这样客户端可以明确处理每个字段的错误,而不是面对一个笼统的 errors 数组。如何实现错误日志与监控?错误格式化解决的是"客户端看到什么",日志和监控解决的是"团队看到什么"。推荐使用 Apollo Server 插件机制:const server = new ApolloServer({ typeDefs, resolvers, plugins: [{ requestDidStart: () => ({ didEncounterErrors: (ctx) => { ctx.errors.forEach(error => { logger.error({ message: error.message, code: error.extensions?.code, path: error.path, operation: ctx.request.operationName }); // 同步上报到 Sentry Sentry.captureException(error, { tags: { graphql: true }, extra: { query: ctx.request.query } }); }); } }) }]});告警方面,建议监控两个指标:错误率(errors / total requests)和 P99 延迟。当错误率超过 5% 或 P99 延迟突增时自动触发告警,这比逐条看日志高效得多。重试机制怎么设计才合理?不是所有错误都该重试。只有网络超时、服务暂时不可用等瞬态错误适合重试,业务逻辑错误(如验证失败、资源不存在)重试毫无意义:async function withRetry(operation, maxRetries = 3, baseDelay = 1000) { for (let i = 0; i < maxRetries; i++) { try { return await operation(); } catch (error) { if (!isRetryable(error) || i === maxRetries - 1) throw error; await new Promise(r => setTimeout(r, baseDelay * Math.pow(2, i))); } }}function isRetryable(error) { const retryable = ['NETWORK_ERROR', 'TIMEOUT', 'SERVICE_UNAVAILABLE']; return retryable.includes(error.code);}使用指数退避(exponential backoff)而非固定间隔,避免在服务端压力最大时雪崩式重试。追问:GraphQL 错误处理和 REST 相比有什么本质区别?GraphQL 统一返回 HTTP 200,错误语义完全由响应体中的 errors 数组承载,支持部分成功——这是最大的区别。REST 每个请求只有一个状态码,要么成功要么失败;GraphQL 一个请求中多个字段可以各自成功或失败,客户端需要逐字段处理错误。这意味着 GraphQL 的错误处理更细粒度,但也要求开发者在 Schema 设计阶段就考虑好错误类型定义,不能事后补救。
计算机基础阅读 05月28日 09:24

CDN 的负载均衡策略有哪些?如何实现 CDN 的高可用?

面试核心结论CDN 负载均衡的核心策略分为两层:全局调度层(GSLB/DNS)决定用户访问哪个边缘节点,本地均衡层(L4/L7 LB)决定节点内部请求分发到哪台服务器。主要策略包括地理位置路由、就近性路由、轮询、加权轮询、最少连接和一致性哈希。实现高可用的关键在于:健康检查 + 故障自动转移 + 熔断降级 + 多活冗余,缺一不可。追问方向:GSLB 的 DNS 调度流程是怎样的?一致性哈希在 CDN 缓存中为什么重要?Anycast 和 DNS 调度有什么区别?CDN 负载均衡的两层架构理解 CDN 负载均衡,首先要分清两层调度:全局调度(GSLB):当用户发起请求时,DNS 解析通过 CNAME 指向 CDN 的 GSLB,GSLB 根据用户 IP、节点负载、网络质量等选择最优边缘节点,返回该节点的 IP。这一层决定"用户去哪个机房"。本地负载均衡(SLB):请求到达边缘节点后,节点内部的负载均衡器(Nginx、HAProxy 等)将请求分发到后端的缓存服务器集群。这一层决定"请求去哪台机器"。面试时很多候选人只讲本地负载均衡,忽略了 GSLB 这一层,这是不够的。CDN 的核心优势——就近接入,正是由 GSLB 实现的。六种核心负载均衡策略地理位置路由(Geo-based Routing)根据用户 IP 所属地域,将请求调度到最近的边缘节点。这是 CDN 最基础也最常用的全局调度策略。实现机制:GSLB 维护一张 IP 地址段到地理位置的映射表(GeoIP 数据库),用户 DNS 请求到达后,查询映射表确定用户所在区域,返回该区域对应节点的 IP。局限性:地理位置近不等于网络延迟低。比如跨运营商访问时,物理距离近但网络绕行严重。因此通常和就近性路由配合使用。就近性路由(Proximity-based Routing)不依赖地理数据,而是通过实际网络测量选择最优节点。测量方式:主动探测:GSLB 定期从各节点向探测点发送 ICMP/TCP 探测,收集 RTT 数据被动测量:分析实际用户请求的响应时间,统计各节点的真实服务质量混合模式:主动探测提供基线数据,被动测量做实时修正实际生产中混合模式最常见。纯主动探测有探测盲区,纯被动测量冷启动阶段没数据。一致性哈希(Consistent Hashing)在 CDN 场景中,一致性哈希的核心价值是提高缓存命中率。为什么重要:如果用轮询策略,同一 URL 的请求可能落在不同缓存服务器上,导致重复回源。用 URL 做一致性哈希,相同 URL 始终路由到同一台服务器,缓存只需存一份。关键参数:虚拟节点数。虚拟节点越多,数据分布越均匀,但管理开销也越大。通常设置 100-200 个虚拟节点。节点变更影响:当节点增减时,一致性哈希只影响相邻虚拟节点上的数据,不会全盘重新分配。相比取模哈希(节点变化时全部重分布),这是巨大优势。# Nginx 一致性哈希配置upstream cdn_cache { hash $request_uri consistent; server 10.0.0.1:8080; server 10.0.0.2:8080; server 10.0.0.3:8080;}加权轮询(Weighted Round Robin)为不同性能的服务器分配不同权重,高性能服务器承载更多请求。upstream cdn_nodes { server 10.0.0.1:8080 weight=5; # 32核 64G 高配 server 10.0.0.2:8080 weight=3; # 16核 32G 中配 server 10.0.0.3:8080 weight=1; # 8核 16G 低配}权重不是一成不变的。成熟的 CDN 系统会根据服务器实时负载(CPU、内存、连接数)动态调整权重,这叫动态权重调整。最少连接(Least Connections)将新请求分发给当前活跃连接数最少的服务器。相比轮询,它适合请求处理时间差异大的场景——轮询只看"分了几个",最少连接看"还剩多少余力"。upstream cdn_nodes { least_conn; server 10.0.0.1:8080; server 10.0.0.2:8080; server 10.0.0.3:8080;}IP 哈希(IP Hash)用客户端 IP 做哈希,保证同一用户的请求始终落到同一台服务器。主要用于会话保持场景,但在 CDN 中不如 URL 哈希常用,因为 CDN 主要是无状态的内容分发。健康检查:高可用的基础没有可靠的健康检查,负载均衡就是瞎指挥。健康检查分两种:主动健康检查LB 定期向后端发送探测请求,判断服务是否正常:# Nginx Plus 主动健康检查upstream cdn_nodes { zone health 64k; server 10.0.0.1:8080; server 10.0.0.2:8080; health_check interval=5s fails=3 passes=2 uri=/health;}关键参数:interval:探测间隔,正常节点 5-10s,异常节点缩短到 1-2sfails:连续失败几次标记为不健康passes:连续成功几次恢复为健康被动健康检查基于实际业务请求的响应判断,不额外发探测:upstream cdn_nodes { server 10.0.0.1:8080 max_fails=3 fail_timeout=30s; server 10.0.0.2:8080 max_fails=3 fail_timeout=30s;}max_fails=3 fail_timeout=30s 表示 30 秒内失败 3 次,该节点被标记不可用 30 秒。生产实践:两种方式配合使用。主动检查发现硬故障(进程挂了、端口不通),被动检查发现软故障(响应变慢、错误率升高)。高可用架构的关键机制故障自动转移当节点被标记不健康后,负载均衡器自动将流量切到健康节点。关键问题是切换速度:DNS 级别的 GSLB 切换依赖 TTL,即使设置很短的 TTL(如 30s),仍有本地 DNS 缓存问题,实际切换可能需要数分钟Anycast 方式切换更快,因为 BGP 路由变化可在秒级生效本地 SLB 切换最快,通常在 1-2 个探测周期内完成熔断与降级当后端持续异常时,快速失败比慢慢等更好:熔断:错误率超过阈值(如 50%),直接断开,不再向该节点发请求,等一段时间后半开探测降级:返回缓存内容(stale-while-revalidate)、返回简化版本、或者直接拒绝(比超时等待体验更好)# Nginx 熔断配置思路proxy_connect_timeout 2s;proxy_read_timeout 5s;proxy_next_upstream error timeout http_502 http_503;proxy_next_upstream_timeout 10s;proxy_next_upstream_tries 2;proxy_next_upstream 实现了请求级别的自动重试和故障转移。多活与冗余单点永远不可靠。CDN 的高可用需要多层次的冗余:节点级冗余:每个区域至少部署 2 个边缘节点,互为备份服务器级冗余:节点内部至少 2 台缓存服务器网络级冗余:多运营商接入(BGP 多线),避免单运营商故障DNS 级冗余:多组权威 DNS,避免 DNS 服务本身成为单点Anycast 与 DNS 调度的对比这是面试中经常被追问的进阶问题:| 维度 | DNS 调度(GSLB) | Anycast ||------|-------------------|---------|| 切换速度 | 分钟级(受 TTL 和缓存影响) | 秒级(BGP 路由收敛) || 精度 | 基于 IP 库,有偏差 | 基于网络拓扑,更精准 || 运维复杂度 | 相对简单 | 需要 ASN 和 BGP 配置 || 适用场景 | 大多数 CDN | 对延迟敏感、需快速故障转移 || 缓存问题 | 本地 DNS 缓存导致调度不准 | 无此问题 |大型 CDN(Cloudflare、Akamai)通常两种方式结合:Anycast 做全局入口,DNS 调度做精细化区域分配。监控指标与告警负载均衡不是配置完就完事了,需要持续监控:核心指标:P50/P95/P99 响应时间:关注长尾延迟,P99 比 P50 更能反映用户体验下限错误率:5xx 比例超过 0.1% 就需要告警缓存命中率:低于 80% 说明负载均衡策略或缓存策略有问题节点可用性:目标 99.99%(年停机约 52 分钟)告警分级:P1:节点完全不可用 → 立即处理,自动故障转移P2:错误率 > 1% 或 P99 > 5s → 5 分钟内响应P3:缓存命中率下降 > 10% → 工单跟进面试回答要点回答这个问题的正确姿势:先说两层架构:全局调度(GSLB/Anycast)+ 本地均衡(L4/L7),展示你对 CDN 整体架构的理解策略选讲 3-4 个:地理位置路由、一致性哈希、最少连接、加权轮询,每个说清楚适用场景和取舍高可用讲机制链:健康检查 → 故障转移 → 熔断降级 → 多活冗余,形成完整链路举实际例子:比如"我们线上遇到过 DNS 缓存导致 GSLB 切换慢的问题,后来加了 Anycast 做补充",比纯理论更有说服力提 Anycast:很多人会忽略这个,提了就是加分项
计算机基础阅读 05月28日 09:23

什么是CDN回源?如何减少回源请求?

CDN 回源是什么?回源(Origin Pull)是指 CDN 边缘节点没有缓存用户请求的内容时,向源站(Origin Server)请求资源的过程。简单来说:用户请求 → CDN 没有缓存 → CDN 去源站拿 → 缓存一份再返回给用户。回源是 CDN 机制中不可避免的环节,但回源率过高会直接拖慢响应速度、压垮源站、推高成本。面试中考察这个点,核心是看你能不能从"理解机制"到"控制回源"形成完整闭环。回源在什么情况下触发?缓存未命中最常见的回源原因,包含三种典型场景:首次访问:资源从未被任何边缘节点缓存过,冷启动必然回源缓存过期:资源超过 TTL(Time To Live)过期时间,CDN 必须重新验证或拉取缓存被清除:主动刷新(Purge)或被动淘汰(LRU 内存不足)导致缓存消失缓存键不匹配URL 相同但参数不同,CDN 可能将其视为不同资源:https://example.com/api/data?version=1https://example.com/api/data?version=2// 若未配置忽略 version 参数,两次请求各自缓存,回源翻倍生产环境中,UTM 参数、时间戳、随机 token 是导致缓存键膨胀的三大元凶。请求类型或头部触发回源POST/PUT/DELETE:写操作通常不缓存,直接回源携带 Authorization/Cookie:CDN 默认不缓存带鉴权头的响应动态内容:业务规则标记为不可缓存的路径(如 /api/、/user/)回源对系统有什么影响?延迟:从毫秒到秒级的跨越缓存命中:用户 → 边缘节点,通常 < 50ms回源请求:用户 → 边缘节点 → 源站 → 边缘节点 → 用户,延迟 200ms~1s+回源率每升高 10%,P99 延迟可能翻倍。高并发场景下,回源还会引发排队效应,延迟雪崩式恶化。源站压力:回源风暴的风险当热点资源同时过期,多个边缘节点会同时向源站发起请求,这就是回源风暴(Thundering Herd)。极端情况下,10 个边缘节点 × 每秒 1000 次 = 源站瞬间承受 10000 QPS,可能导致源站宕机。成本:回源带宽是真金白银CDN 回源流量通常按带宽计费,价格高于普通 CDN 出流量源站需要更高配置承受回源压力超出套餐配额的回源流量费用更高如何减少回源?六大核心策略策略一:合理设置缓存 TTL不同类型资源设置不同的过期时间:// 静态资源:长 TTL + immutableCache-Control: public, max-age=31536000, immutable// 半静态内容:中等 TTLCache-Control: public, max-age=3600// 动态 API:短 TTL + stale-while-revalidateCache-Control: public, max-age=10, stale-while-revalidate=60stale-while-revalidate 是一个容易被忽视的关键指令:它允许 CDN 在后台异步刷新缓存的同时,先返回过期的旧内容给用户。既保证响应速度,又保证内容最终一致。配合 URL 版本化更新静态资源,彻底避免因内容更新导致的缓存失效:// 版本化文件名,内容变更即更换 URL/app.v1.a1b2c3.js/app.v2.d4e5f6.js策略二:缓存预热(Prefetch)在用户访问前,主动将内容推送到 CDN 节点:# 通过 CDN API 预热 URLcurl -X POST "https://cdn-api.example.com/prefetch" \ -H "Authorization: Bearer <token>" \ -d "{\"urls\": [\"https://example.com/new-page.html\", \"https://example.com/bundle.js\"]}"预热适用场景:新版本发布前预热静态资源大促活动前预热商品页面热门内容预测性预热(基于历史访问模式)策略三:配置缓存键优化忽略无关查询参数,让更多请求命中同一缓存:// 配置忽略 utm_source、timestamp 等参数https://example.com/data?utm_source=wechat&ts=123https://example.com/data?utm_source=weibo&ts=456// 忽略参数后,两个请求命中同一份缓存规范化请求头:统一 Vary 头,避免因 Accept-Encoding 差异产生重复缓存。策略四:多级缓存架构CDN 本身就有多级缓存结构,合理利用可以大幅减少回源:边缘缓存(L1):离用户最近,容量小,响应最快区域缓存(L2):覆盖一个地理区域,容量中等中心缓存 / Origin Shield(L3):所有边缘节点的回源汇聚点Origin Shield 是 AWS CloudFront 的叫法,Akamai 称为 Origin Shielding,阿里云叫集中回源。核心原理:在源站前再加一层缓存,即使所有边缘节点都 miss,也只需一次回源到 Shield 层,避免多节点直接打源站。Akamai 的数据显示,Origin Shield 可以减少 95% 的源站请求。策略五:请求合并(Request Coalescing)当多个用户同时请求同一份未缓存的资源时,CDN 只向源站发一次请求,其余请求排队等待同一份响应。这个机制叫做请求合并或 Request Collapsing。这是解决回源风暴的核心手段。没有请求合并,1000 个并发 miss 请求 = 1000 次回源;有了请求合并,= 1 次回源。策略六:边缘计算将简单逻辑下沉到 CDN 边缘节点执行,避免回源:A/B 测试分流:在边缘节点根据 Cookie 分配实验组URL 重写:边缘节点直接改写 URL,无需回源简单 API 聚合:边缘节点拼接多个缓存片段返回地理定向:根据用户 IP 返回不同区域的缓存内容面试追问与回答追问 1:缓存穿透、缓存击穿、缓存雪崩分别是什么?和回源有什么关系?穿透:请求的数据源站也不存在,每次都回源且永远无法缓存。解决:缓存空值或用 Bloom Filter 拦截击穿:热点 key 过期瞬间,大量请求同时回源。解决:请求合并 + 互斥锁回源雪崩:大批 key 同时过期,回源量暴增。解决:TTL 加随机偏移 + stale-while-revalidate追问 2:如何监控回源率?合理范围是多少?核心指标:回源率 = 回源请求数 / 总请求数,目标 < 5%回源带宽占比 = 回源流量 / 总出流量回源延迟 P99通过 CDN 控制台或日志分析工具(如 ELK)监控,设置回源率 > 10% 的告警阈值。追问 3:CDN 回源和源站宕机如何处理?配置 stale-if-error 指令,源站不可用时返回过期缓存设置源站健康检查,自动切换到备用源站多源站负载均衡 + 故障自动摘除关键业务配置 Origin Shield 作为缓冲层
服务端阅读 05月28日 08:30

Kafka 出现消息重复消费怎么解决?

面试官为什么爱问这个问题Kafka 默认提供的是 at-least-once 语义,消息至少被消费一次,但可能重复。在金融支付、订单处理等场景下,重复消费意味着重复扣款、重复发货,后果严重。面试官问这道题,考察的是你对 Kafka 消费语义的理解深度,以及在实际业务中如何保证 exactly-once。重复消费是怎么产生的根本原因只有一个:Consumer 消费了消息,但 Offset 没有成功提交。下次重启或 Rebalance 后,Kafka 认为这条消息没消费过,重新投递。常见触发场景:Rebalance 导致 Offset 丢失:Consumer 处理消息耗时超过 max.poll.interval.ms(默认 5 分钟),Kafka 认为该 Consumer 已死,触发 Rebalance,未提交的 Offset 对应的消息会被重新分配给其他 Consumer 消费Consumer 异常宕机:启用了自动提交(enable.auto.commit=true,默认开启),但提交间隔内宕机,最近一次提交之后消费的消息都会被重复消费手动提交失败:关闭自动提交后调用 commitSync() 或 commitAsync(),提交请求因网络问题失败,消息被重复消费Producer 重试导致重复写入:Producer 发送消息后未收到 ACK,触发重试,实际上第一次已经写入成功,造成 Broker 端消息重复解决方案:从 Kafka 机制到业务层,逐层防御第一层:Producer 幂等性——防止重复写入Kafka 0.11 引入幂等 Producer,原理是为每个 Producer 分配一个 PID(Producer ID),为每条消息分配递增的 SequenceNumber。Broker 端维护每个 <PID, Partition> 的最新 SequenceNumber,收到消息时比对:如果新消息的 SequenceNumber ≤ 已记录的值,判定为重复写入,直接丢弃。# 开启幂等性(Kafka 3.0 后默认开启)enable.idempotence=true# 等价于同时设置:# acks=all# retries=Integer.MAX_VALUE# max.in.flight.requests.per.connection<=5注意限制:幂等性只保证单 Partition 内的去重,跨 Partition 或 Producer 重启(PID 变化)后无法去重。第二层:Kafka 事务——跨分区 Exactly-Once当需要将消费 Offset 提交和消息发送放在同一个事务中时(典型的 consume-transform-produce 模式),需要 Kafka 事务支持:// 事务型 Producer 配置props.put("transactional.id", "order-tx-001");producer.initTransactions();try { producer.beginTransaction(); // 消费-处理-发送,放在同一个事务中 producer.send(new ProducerRecord<>("topic", key, value)); // 将消费 Offset 也提交到事务中 producer.sendOffsetsToTransaction(offsets, consumerGroupId); producer.commitTransaction();} catch (Exception e) { producer.abortTransaction();}事务保证:要么消费 Offset 提交和消息发送同时成功,要么同时回滚,从源头杜绝重复。第三层:Consumer 端幂等——业务层最后一道防线无论 Producer 和 Broker 做了多少保障,Consumer 端的幂等性是必须实现的,因为 Rebalance 场景下 Kafka 无法完全避免重复投递。方案一:数据库唯一约束利用数据库主键或唯一索引天然去重,最可靠:-- 以消息 ID 作为唯一索引INSERT INTO orders (order_id, user_id, amount, status)VALUES ('msg-123', 1001, 99.9, 'PAID')ON DUPLICATE KEY UPDATE status = status; -- MySQL 写法,重复则跳过方案二:Redis SET 去重适合高吞吐场景,利用 Redis Set 的去重特性:// 消费前先判断是否已处理String dedupeKey = "kafka:processed:" + topic + ":" + partition;Boolean isNew = redisTemplate.opsForSet().add(dedupeKey, messageId);if (Boolean.TRUE.equals(isNew)) { processMessage(message); // 设置过期时间避免 Key 无限膨胀 redisTemplate.expire(dedupeKey, 24, TimeUnit.HOURS);}方案三:状态机控制适用于有明确状态流转的业务,如订单从"待支付"到"已支付",重复消费时状态已变更,业务逻辑自然跳过:// 利用业务状态天然幂等Order order = orderDao.getById(orderId);if (order.getStatus() == OrderStatus.PAID) { log.info("订单已支付,跳过重复消息: {}", orderId); return; // 已处理,直接跳过}order.setStatus(OrderStatus.PAID);orderDao.update(order);第四层:Offset 提交策略优化# 关闭自动提交,掌控提交时机enable.auto.commit=false手动提交的选择:commitSync():同步提交,阻塞等待 Broker 确认,可靠但吞吐低commitAsync():异步提交,不阻塞,但可能丢失提交确认推荐做法:正常消费用 commitAsync() 保证吞吐,Consumer 关闭时用 commitSync() 兜底确保最后一次提交成功try { while (true) { ConsumerRecords<String, String> records = consumer.poll(Duration.ofMillis(100)); for (ConsumerRecord<String, String> record : records) { processMessage(record); } consumer.commitAsync(); // 异步提交,追求吞吐 }} finally { consumer.commitSync(); // 关闭前同步提交,确保不丢 consumer.close();}面试追问与加分回答追问:Kafka 幂等 Producer 的 PID 在重启后会变吗?会变。PID 是 Broker 在 Producer 启动时分配的,重启后获得新的 PID,之前的 SequenceNumber 也会重置,所以幂等性无法跨会话去重。跨会话去重需要配合 transactional.id 使用事务机制。追问:为什么不在 Broker 端做全局去重?Broker 端全局去重需要维护所有消息的索引,存储和计算开销极大,与 Kafka 追求高吞吐的设计目标冲突。Kafka 选择在 Producer 端做有限去重(单 Partition 内),把跨 Partition、跨会话的去重责任交给业务层。
服务端阅读 05月28日 08:28

Kafka 消息积压如何处理?

Kafka 消息积压如何处理?Kafka 消息积压是生产环境最常见的故障之一,也是面试高频考点。核心表现为 Consumer 消费速度跟不上 Producer 生产速度,消息在 Broker 端持续堆积。处理思路是:先定位原因,再分层治理——短期应急止血,长期架构优化。积压原因定位消息积压不是单一问题,通常由以下几类原因叠加导致:消费端瓶颈消费逻辑耗时过长,单条消息处理耗时超过生产间隔单线程消费,未能充分利用分区并行度外部依赖(数据库、RPC)响应慢,拖慢整体消费速率生产端突增业务高峰(大促、秒杀)导致消息量激增Producer 批量发送配置不当,瞬时流量过大上游系统异常重试导致消息重复涌入数据倾斜消息 Key 分布不均,部分分区积压严重,其他分区空闲典型场景:用 userId 做 Key 时,大客户的消息集中在少数分区系统故障Consumer 宕机或频繁 Rebalance依赖服务宕机导致消费持续失败重试网络抖动引起消费超时监控与快速诊断定位积压的第一步是看 Consumer Lag:# 查看 Consumer Group 的 Lag 情况kafka-consumer-groups --bootstrap-server localhost:9092 \ --describe --group my-group# 输出关键字段:CURRENT-OFFSET、LOG-END-OFFSET、LAG# LAG = LOG-END-OFFSET - CURRENT-OFFSET关键监控指标:| 指标 | 含义 | 告警建议 ||------|------|----------|| Consumer Lag | 积压消息数 | > 10万触发 P2,> 100万触发 P1 || 消费速率 (msg/s) | 每秒消费消息数 | 持续低于生产速率触发告警 || Rebalance 频率 | 消费者组重平衡次数 | 5分钟内超过3次需排查 || 分区 Lag 分布 | 各分区积压差异 | 最大分区 Lag > 平均值3倍需关注数据倾斜 |短期应急方案1. 紧急扩容:临时 Topic 分流这是处理百万级以上积压最有效的短期方案,核心思路是将积压数据快速分散到更多分区并行消费:操作步骤:# 第一步:创建临时 Topic,分区数为原来的 N 倍kafka-topics --bootstrap-server localhost:9092 \ --create --topic my-topic-temp --partitions 50 \ --replication-factor 3// 第二步:写一个分发 Consumer,消费积压消息并轮询写入临时 Topic// 关键:不做业务处理,只做数据搬运while (true) { ConsumerRecords<String, String> records = consumer.poll(Duration.ofMillis(500)); for (ConsumerRecord<String, String> record : records) { // 轮询写入临时 Topic 的各分区,保证均匀分布 ProducerRecord<String, String> pr = new ProducerRecord<>( "my-topic-temp", partitionCounter % 50, record.key(), record.value()); producer.send(pr); partitionCounter++; } consumer.commitSync();}# 第三步:部署 N 倍的 Consumer 消费临时 Topic# 第四步:积压消费完毕后,恢复原架构,下线临时 Consumer注意:此方案会打破消息分区内的顺序性。如果业务要求顺序消费,需要将相同 Key 的消息路由到同一临时分区。2. 增加消费者实例最直接的方式,但受限于分区数:# 先查看当前分区数kafka-topics --bootstrap-server localhost:9092 \ --describe --topic my-topic# Consumer 数量不能超过 Partition 数量# 如果 Consumer 已满,需要先增加分区kafka-topics --bootstrap-server localhost:9092 \ --alter --topic my-topic --partitions 20关键限制:一个分区同一时刻只能被同一个 Consumer Group 中的一个 Consumer 消费。Consumer 数量 = 分区数时并行度最大,超过则空闲。3. 消费端快速优化批量处理替代逐条处理:ConsumerRecords<String, String> records = consumer.poll(Duration.ofMillis(100));List<Record> batch = new ArrayList<>();for (ConsumerRecord<String, String> record : records) { batch.add(mapRecord(record)); if (batch.size() >= 500) { // 批量写入数据库,而非逐条插入 db.batchInsert(batch); batch.clear(); }}if (!batch.isEmpty()) { db.batchInsert(batch);}多线程消费(单 Consumer 多 Worker):ExecutorService executor = Executors.newFixedThreadPool(8);ConsumerRecords<String, String> records = consumer.poll(Duration.ofMillis(100));for (ConsumerRecord<String, String> record : records) { executor.submit(() -> processMessage(record));}// 注意:提交 Offset 必须在所有 Worker 处理完成后调整消费配置:# 增大单次拉取量max.poll.records=1000# 延长拉取间隔,给处理留更多时间max.poll.interval.ms=300000# 适当缩短会话超时,加快故障感知session.timeout.ms=25000heartbeat.interval.ms=8000处理数据倾斜数据倾斜是积压的隐蔽原因,表现为少数分区 Lag 远高于其他分区:# 查看各分区 Lag 分布kafka-consumer-groups --bootstrap-server localhost:9092 \ --describe --group my-group# 如果 PARTITION-0 Lag=500万,其他分区 Lag=10万,就是数据倾斜解决方案:修改分区策略:将热点 Key 加随机后缀打散,如 userId_123 → userId_123_0 ~ userId_123_9自定义 Partitioner:在 Producer 端实现更均匀的分区分配逻辑临时方案:对热点分区单独部署 Consumer 消费消息过期的特殊处理如果消息设置了 retention.ms,积压期间消息可能被 Broker 清理,导致数据丢失:# 紧急延长消息保留时间kafka-configs --bootstrap-server localhost:9092 \ --entity-type topics --entity-name my-topic \ --alter --add-config retention.ms=604800000 # 7天如果消息已经被清理,需要从数据源重新回放:// 从业务数据库或备份系统重新生成消息// 写入一个新的补偿 Topic,单独消费处理丢弃非关键消息仅适用于日志采集、指标上报等可容忍丢失的场景:// 方式一:跳到最新 Offset,丢弃积压消息consumer.seekToEnd(partitions);// 方式二:按时间跳转,只消费最近 N 小时的消息long timestamp = System.currentTimeMillis() - Duration.ofHours(2).toMillis();Map<TopicPartition, Long> timestamps = partitions.stream() .collect(Collectors.toMap(tp -> tp, tp -> timestamp));Map<TopicPartition, OffsetAndTimestamp> offsets = consumer.offsetsForTimes(timestamps);offsets.forEach((tp, offsetAndTimestamp) -> { if (offsetAndTimestamp != null) { consumer.seek(tp, offsetAndTimestamp.offset()); }});严禁在订单、支付等业务场景使用,必须先备份再处理。保证消费顺序性扩容和并行消费会打破分区内的消息顺序。如果业务要求局部有序:// 方案:按 Key 分队列,每个队列单线程处理Map<String, BlockingQueue<Record>> keyQueues = new ConcurrentHashMap<>();ExecutorService[] singleThreadExecutors = new ExecutorService[16];for (ConsumerRecord<String, String> record : records) { String key = record.key(); int queueIndex = Math.abs(key.hashCode()) % 16; singleThreadExecutors[queueIndex].submit(() -> processMessage(record));}长期预防机制容量规划:根据业务峰值 QPS 评估所需 Consumer 数量,预留 30% 冗余分区数按预估峰值设定,宁多勿少(增加分区不影响已有数据,但减少分区不支持)监控告警体系:Consumer Lag 分级告警:P2(10万)、P1(100万)、P0(500万)消费速率持续低于生产速率超5分钟触发预警Rebalance 频率异常告警限流与降级:// Producer 端限流,防止突发流量冲垮消费端RateLimiter rateLimiter = RateLimiter.create(5000); // 5000 msg/srateLimiter.acquire();producer.send(record);高峰期降级非核心功能的消费逻辑关闭非必要的数据同步消费任务应急预案:提前准备临时扩容脚本,5分钟内可完成 Topic 创建和 Consumer 部署定期演练积压场景,验证扩容方案的有效性建立消息备份机制,支持从数据源回放面试回答模板30秒核心回答:"Kafka 消息积压处理分三步:第一步定位原因——看 Consumer Lag 和分区分布,区分是消费慢、流量突增还是数据倾斜;第二步短期应急——如果积压量大,创建临时 Topic 扩大分区数并行消费,同时优化消费逻辑做批量处理和异步化;第三步长期治理——做好容量规划、监控告警和限流降级,从根本上预防积压。"追问方向:数据倾斜怎么发现、怎么处理?—— 看各分区 Lag 差异,热点 Key 加随机后缀打散消息过期被清理了怎么办?—— 延长 retention 时间,从数据源回放补偿如何保证消费顺序性?—— 按 Key 分队列单线程处理,或使用 Coordinated Rebalance 减少顺序中断Consumer 数量能无限增加吗?—— 不能超过分区数,一个分区只能被一个 Consumer 消费
计算机基础阅读 05月28日 08:26

CDN 成本过高如何优化?

CDN 账单为什么这么高?一家中型互联网公司,业务量稳定增长,但 CDN 月费从 8 万涨到了 28 万,涨幅远超业务增速。复盘后发现:缓存命中率只有 72%,图片未做格式转换,视频全部用 H.264 编码,没有做任何成本管控。经过一轮系统优化,月费降回了 11 万。CDN 成本优化不是省钱,是花该花的钱。下面从成本构成、六大核心优化策略、监控体系三个层面讲清楚。CDN 成本由哪些部分组成?理解成本结构是优化的前提。CDN 的费用主要来自五个方面:| 费用类型 | 计费方式 | 占比(典型场景) ||---------|---------|--------------|| 流量/带宽 | 按流量(GB)或按带宽峰值(Mbps) | 60%-70% || 请求次数 | 按千次请求计费 | 10%-15% || HTTPS 证书 | 按证书数量或域名数 | 3%-5% || 安全防护(WAF/DDoS) | 按规则数或防护流量 | 5%-10% || 边缘计算/视频处理 | 按调用次数或处理时长 | 5%-10% |流量/带宽是绝对大头,优化重心应该放在这里。计费方式的选择也很关键:流量平稳的业务选按流量计费更划算,有明显峰值波动的(如直播、促销)选按带宽峰值第五计费(95 带宽)更合适。策略一:缓存命中率提升到 95% 以上缓存命中率每提升 1 个百分点,回源流量大约减少 3%-5%。命中率从 72% 提升到 95%,仅此一项就能节省 30% 以上的流量成本。关键配置:# 静态资源:长缓存 + immutableCache-Control: public, max-age=31536000, immutable# API 响应:短缓存,CDN 侧可缓存更久Cache-Control: public, max-age=60, s-maxage=300# 用户敏感数据:禁止缓存Cache-Control: no-store缓存键优化也很容易被忽略。默认缓存键会包含所有查询参数,但 ?utm_source=xxx 这类追踪参数不影响内容,应该忽略:# 忽略不影响内容的查询参数proxy_cache_key "$scheme$request_method$host$uri";资源版本化是避免缓存失效的常用手段。不要用 style.css?v=2,改成 style.v2.css,旧版本自然过期,不需要手动刷新缓存。缓存预热适合内容发布场景。新页面上线前,主动将关键资源推送到边缘节点,避免用户首次访问时回源。大部分 CDN 厂商都提供了预热 API,可以在 CI/CD 发布流程中自动调用。缓存一致性是面试常问的追问点。常见方案有三种:一是 TTL 自然过期(最简单,适合容忍短暂不一致的场景);二是主动 Purge(适合内容更新后必须立即生效的场景,但频繁 Purge 会降低命中率);三是版本化 URL(改 URL 不改内容,最推荐)。实际生产中通常组合使用:静态资源用版本化 URL,动态内容用短 TTL + 关键更新时 Purge。策略二:内容压缩和格式转换图片优化:单此一项可减少 50%-70% 流量| 格式 | 适用场景 | 相比 JPEG 节省 ||-----|---------|-------------|| WebP | 照片、复杂图形 | 25%-35% || AVIF | 对兼容性要求不高的场景 | 40%-50% || PNG → WebP | 含透明通道的图 | 60%-70% |实际操作中,用 CDN 的图片处理服务做实时转换是最省事的方案。Cloudflare 的 Polish、阿里云的图片处理、七牛的 imageView2 都支持按请求参数自动转换格式和压缩质量。这种方式不需要改源站图片,CDN 边缘实时转换,用户请求时自动返回最优格式。<!-- 通过 URL 参数请求 WebP 格式 --><img src="https://cdn.example.com/photo.jpg?format=webp&q=80">也可以通过 Accept 请求头自动协商:浏览器发送 Accept: image/webp,CDN 自动返回 WebP 格式,无需改业务代码。文本压缩:Brotli 比 Gzip 再省 20%-30%gzip on;gzip_types text/plain text/css application/json application/javascript;# Brotli 压缩效果更好,主流浏览器已支持brotli on;brotli_types text/plain text/css application/json application/javascript;brotli_comp_level 6;Brotli 在压缩比上优于 Gzip,但压缩速度稍慢。对于静态资源可以预压缩,动态内容用 Gzip 即可。Cloudflare 默认开启 Brotli,阿里云 CDN 需要在控制台手动开启。视频优化:编码格式选择影响巨大H.264 是兼容性最好的选择,但从成本角度看,H.265/HEVC 比 H.264 节省约 50% 的码率,AV1 节省约 60%。如果目标用户主要在移动端,可以优先推送 H.265 版本。自适应码率(ABR)也是必须做的:根据用户带宽动态选择清晰度,避免 4K 视频推给 3G 网络,既浪费流量又卡顿。HLS 和 DASH 协议都原生支持 ABR,配置好码率阶梯即可。策略三:计费方式优化很多人只关注 CDN 的单价,但计费方式选错了,再低的价格也省不了钱。按流量 vs 按带宽峰值:流量平稳、可预测 → 按流量计费有明显高峰(直播、促销、游戏更新)→ 按 95 带宽峰值计费流量波动大但不确定 → 先按流量计费,积累一个月数据再判断预留带宽/流量包: 各厂商都提供预付费流量包,通常比按量付费便宜 20%-40%。适合流量稳定的业务。阿里云的 CDN 流量包、腾讯云的预付费带宽包都是这个思路。跨区域费用差异: 国内流量和海外流量价格差 2-5 倍。如果海外用户不多,不需要开全球加速。只开需要的区域,能省不少钱。一个常见误区是开了"全球加速"却 90% 的流量来自国内,多花了好几倍的钱。主流 CDN 厂商价格对比(国内流量,2026 年参考价):| 厂商 | 按流量(元/GB) | 按带宽(元/Mbps/天) | 特点 ||-----|-------------|-----------------|-----|| 阿里云 | 0.20-0.24 | 0.96-1.36 | 生态完善,流量包折扣大 || 腾讯云 | 0.20-0.25 | 0.96-1.20 | 免费额度多,适合中小业务 || 七牛云 | 0.18-0.29 | - | 图片处理能力强 || Cloudflare | 免费起步 | Pro $20/月 | 海外节点多,国内速度一般 |策略四:多 CDN 架构单一 CDN 供应商存在两个问题:一是议价空间有限,二是可用性风险集中。多 CDN 不是大公司才需要,日流量超过 10TB 就值得考虑。实现方式:DNS 层面:用 CNAME 指向多个 CDN,做加权轮询。缺点是切换延迟较高(DNS 缓存 TTL)智能调度:根据用户位置、CDN 节点负载、实时成本选择最优路径。DNSPod、NS1 等支持按策略解析按内容类型分配:静态资源走便宜 CDN,动态内容走高性能 CDN,视频走专用 CDN// 简化的 CDN 选择逻辑function selectCDN(contentType, userRegion) { const policy = { 'image': { cdn: 'low-cost-cdn', regions: ['cn-east', 'cn-south'] }, 'video': { cdn: 'video-cdn', regions: ['global'] }, 'api': { cdn: 'high-perf-cdn', regions: ['cn-east'] } }; return policy[contentType]?.cdn || 'default-cdn';}按内容类型分配 CDN 的成本差异示例: 一家视频平台把图片流量切到低成本的通用 CDN(单价低 40%),视频流量留在专用 CDN,整体成本下降约 25%。多 CDN 的难点在于缓存一致性管理和流量调度精细化。面试中如果追问,可以从 DNS 切换的延迟问题、缓存预热策略、流量分配比例调整这几个角度展开。策略五:请求次数优化请求次数费用容易被忽略,但在高并发场景下占比不低。每千次请求 0.01 元,日请求 10 亿次就是每天 1 万元。主要优化手段:资源合并:把多个小文件合并成一个,减少请求数。CSS/JS 打包是基本操作雪碧图:小图标合并为一张大图,用 CSS 定位显示。在 HTTP/1.1 下效果明显,HTTP/2 下收益降低HTTP/2 多路复用:升级到 HTTP/2 后,多个请求可以复用一个连接,但请求次数仍然计费减少无效请求:404 请求也计费,定期清理失效链接和过期资源API 响应合并:多个接口调用合并为一个批量接口,减少 API 请求次数策略六:成本监控与告警不做监控的优化都是盲目的。需要关注的指标:| 指标 | 告警阈值 | 说明 ||-----|---------|-----|| 缓存命中率 | 20% | 短期突增可能是攻击或配置错误 || 带宽峰值/均值比 | > 3:1 | 峰值过高说明可以优化计费方式 || 4xx/5xx 比例 | > 1% | 错误请求也在花钱 |成本归因分析也很重要。用 SQL 分析 CDN 日志,找出流量 Top 10 的 URL,看看是不是某个大文件没做压缩,或者某个接口的缓存 TTL 设置太短。-- 查询流量最大的 URLSELECT url, SUM(bytes) as total_bytes, COUNT(*) as requestsFROM cdn_logsWHERE date >= CURRENT_DATE - INTERVAL 7 DAYGROUP BY urlORDER BY total_bytes DESCLIMIT 10;-- 查询缓存命中率低的 URLSELECT url, COUNT(*) as total, SUM(CASE WHEN cache_status = 'HIT' THEN 1 ELSE 0 END) / COUNT(*) * 100 as hit_rateFROM cdn_logsWHERE date >= CURRENT_DATE - INTERVAL 7 DAYGROUP BY urlHAVING hit_rate < 80ORDER BY hit_rate;优化效果汇总| 优化手段 | 典型节省幅度 | 实施难度 ||---------|-----------|--------|| 缓存命中率提升(72%→95%) | 30%-40% 流量 | 低 || 图片格式转换(JPEG→WebP) | 25%-35% 图片流量 | 低 || 视频编码升级(H.264→H.265) | 40%-50% 视频流量 | 中 || 计费方式调整 | 10%-30% 总成本 | 低 || 多 CDN 架构 | 15%-25% 总成本 | 高 || Brotli 替代 Gzip | 20%-30% 文本流量 | 低 |优化优先级建议:先做缓存命中率和图片优化(投入小、见效快),再做计费方式调整和视频编码升级(需要一定技术投入),最后考虑多 CDN 架构(架构改动大)。面试中回答这个问题,核心要传达三点:一是理解 CDN 成本的结构性差异(流量是大头,优化重心在这里),二是掌握分层优化思路(缓存 → 内容 → 计费 → 架构),三是能给出量化的优化预期(不是笼统的"能省钱",而是"缓存命中率从 X 提升到 Y 可以节省 Z% 的流量成本")。追问方向通常涉及缓存一致性如何保证、多 CDN 的流量调度怎么实现、如何平衡成本和性能——这三个问题想清楚,这个话题基本就过了。
服务端阅读 05月28日 08:26

Kafka 的副本机制是如何工作的?

副本机制的核心作用Kafka 的副本机制解决的是分布式环境下的两个根本问题:数据可靠性和服务可用性。每个 Partition 可以配置多个副本(Replica),分布在不同 Broker 上。当某个 Broker 宕机,其他副本可以继续提供服务,保证消息不丢失、服务不中断。副本因子(replication.factor)决定了每个 Partition 有多少个副本。生产环境通常设置为 3,意味着每个 Partition 有 3 份相同数据,允许最多 2 个节点故障而不丢数据。Leader 与 Follower 的分工Kafka 的副本采用 Leader-Follower 模型:Leader 副本:每个 Partition 只有 1 个 Leader,负责处理该 Partition 的所有读写请求。Producer 写消息、Consumer 读消息都直接与 Leader 交互。Follower 副本:被动地从 Leader 拉取数据并写入本地日志,不对外提供读写服务。Follower 的唯一职责是保持与 Leader 的数据同步,以便在 Leader 故障时接管。这种设计的优势在于读写都走 Leader,避免了多副本写入的一致性问题,同时也简化了 Consumer 的消费逻辑——不需要关心从哪个副本读取。ISR 机制:同步副本的动态管理ISR(In-Sync Replicas)是 Kafka 副本机制中最关键的概念之一。它不是静态的副本列表,而是一个由 Leader 动态维护的同步副本集合。ISR 的判定标准Follower 是否留在 ISR 中,取决于它是否在规定时间内与 Leader 保持同步。判定依据是时间而非消息条数——早期 Kafka 用 replica.lag.max.messages 判定(已在 0.9.0 版本移除),现在只用 replica.lag.time.max.ms(默认 10 秒)。如果一个 Follower 超过这个时间没有发送拉取请求或虽然发送了但还没追上 Leader 的 LEO,就会被移出 ISR,进入 OSR(Out-of-Sync Replicas)。AR、ISR、OSR 的关系AR(Assigned Replicas)= ISR + OSRAR 是 Partition 分配的所有副本集合,ISR 是与 Leader 同步的副本,OSR 是落后于 Leader 的副本。理想状态下 ISR = AR,即所有副本都在同步。当 ISR 缩小时,说明集群出现了同步延迟。ISR 与消息可靠性min.insync.replicas 配合 Producer 的 acks=all 使用时,能提供强可靠性保证。当 ISR 中的副本数小于 min.insync.replicas 时,Broker 会拒绝写入,抛出 NotEnoughReplicasException。这是一种宁可不可用也不丢数据的策略。典型配置:replication.factor=3 + min.insync.replicas=2 + acks=all,允许 1 个节点故障仍能正常写入。HW 与 LEO:副本同步的位置标记理解副本同步,必须搞清楚两个核心位移标记:LEO(Log End Offset):每个副本(包括 Leader 和 Follower)各自维护的日志末端位移,表示下一条待写入消息的位置。每个副本的 LEO 可能不同。HW(High Watermark):所有 ISR 副本中最小的 LEO。Consumer 只能消费到 HW 之前的消息,HW 之后的消息对 Consumer 不可见。同步过程中 HW 和 LEO 的变化Producer 向 Leader 写入消息,Leader 的 LEO 递增Follower 从 Leader 拉取消息,Follower 的 LEO 递增Leader 收到所有 ISR 副本的 LEO 更新后,推进 HW(取所有 ISR 副本 LEO 的最小值)Follower 在下一次拉取时获取 Leader 的 HW,更新自己的 HW这个过程确保了:HW 之前的消息已经被所有 ISR 副本确认,是安全可消费的。Leader 选举:故障恢复的核心流程当 Leader 所在 Broker 宕机,Controller 会从 ISR 中选出一个新的 Leader。选举过程不是"投票",而是 Controller 直接指定。选举策略Kafka 的 Leader 选举策略根据触发场景不同分为四种:| 策略 | 触发场景 | 选举逻辑 ||------|---------|---------|| OfflinePartition | Leader Broker 宕机 | 优先从 ISR 中选第一个存活的副本 || ReassignPartition | 分区副本重分配 | 从新 AR 中选第一个在线且在 ISR 中的副本 || PreferredReplica | 自动均衡 | 选 AR 中的第一个副本(如果在线且在 ISR 中) || ControlledShutdown | Broker 优雅关闭 | 选 ISR 中不在关闭 Broker 上的第一个副本 |Unclean 选举:可用性与一致性的权衡当 ISR 为空(所有副本都不同步)时,是否允许从 OSR 中选举 Leader?这由 unclean.leader.election.enable 控制:开启(默认 false):允许从 OSR 选 Leader,保证可用性,但可能丢数据——因为 OSR 副本的消息落后于原 Leader关闭:分区不可用直到原 Leader 恢复,保证数据一致性金融场景通常关闭此选项,宁可短暂不可用也不冒数据丢失的风险。Leader Epoch 解决的问题早期 Kafka 依赖 HW 截断日志来保证副本一致性,但这会导致数据不一致问题。Kafka 0.11 引入了 Leader Epoch 机制:每个 Partition 维护一个单调递增的 Epoch 编号,新 Leader 产生时 Epoch 递增。Follower 用 Leader Epoch 而非 HW 来判断截断位置,避免了 HW 截断导致的数据丢失和分歧。典型场景:两个 Follower 先后重启,旧 HW 截断可能导致先重启的 Follower 把已提交的消息截掉,而后重启的 Follower 又从前者拉取到不完整数据。Leader Epoch 通过记录每个 Epoch 对应的起始位移,让 Follower 精确知道从哪里截断。副本同步的完整流程Producer 发送消息到 Leader:消息写入 Leader 的本地日志,Leader LEO 递增Follower 拉取消息:Follower 主动向 Leader 发送 FetchRequest,携带自己的 LEOLeader 返回消息:Leader 根据 Follower 的 LEO 返回对应数据,同时返回 Leader 当前的 HWFollower 写入并更新:Follower 将消息写入本地日志,更新 LEO,然后更新 HW(取 min(LEO, Leader HW))Leader 推进 HW:Leader 在收到 Follower 的下一次拉取请求时,根据所有 ISR 副本的 LEO 更新 HW注意:Follower 是主动拉取而非 Leader 推送,这是 Kafka 副本同步与很多其他系统(如 MySQL 主从)的区别。拉取模式让 Follower 自己控制同步节奏,避免被 Leader 压垮。副本分配与机架感知创建 Topic 时,Kafka 自动分配副本到不同 Broker。分配算法考虑两个原则:同一 Partition 的副本分布在不同 Broker 上开启机架感知(broker.rack 配置)后,副本尽量分布在不同机架机架感知的意义在于:如果整个机架故障(如电源故障),其他机架上的副本仍可用。不配置机架信息时,Kafka 只保证 Broker 级别分布,无法防御机架级故障。# Broker 机架配置broker.rack=rack1# 副本因子default.replication.factor=3# 最小同步副本数min.insync.replicas=2关键配置参数一览| 参数 | 默认值 | 说明 ||------|-------|------|| replication.factor | 1 | 副本数,生产环境建议 ≥ 3 || min.insync.replicas | 1 | 最小同步副本数,配合 acks=all 使用 || acks | 1 | Producer 确认级别:0/1/all || replica.lag.time.max.ms | 10000 | Follower 落后超时时间 || unclean.leader.election.enable | false | 是否允许非 ISR 副本当选 Leader || auto.leader.rebalance.enable | true | 是否自动均衡 Leader 到 Preferred 副本 |监控核心指标排查副本相关问题时,重点关注以下 JMX 指标:UnderReplicatedPartitions:ISR 副本数 < AR 副本数的 Partition 数量,大于 0 说明有副本同步滞后OfflinePartitionsCount:没有 Leader 的 Partition 数量,大于 0 说明有分区不可用IsrShrinksPerSec / IsrExpandsPerSec:ISR 缩减和扩张速率,频繁变动说明集群不稳定ActiveControllerCount:应该始终为 1,大于 1 说明有脑裂风险生产环境实践建议副本数不是越多越好。3 副本能满足大多数场景的可靠性要求,增加到 5 或 7 会显著降低写入吞吐(更多副本需要同步)并增加存储成本。只在极少数场景(如金融核心链路)才需要更高副本数。务必开启 min.insync.replicas。只配 acks=all 不够——如果 ISR 缩减到只剩 Leader,acks=all 等于 acks=1,此时 Leader 宕机仍会丢数据。min.insync.replicas=2 确保至少 2 个副本确认才算写入成功。关注 ISR 抖动。ISR 频繁缩扩通常不是正常波动,往往暗示网络延迟、磁盘 IO 瓶颈或 GC 问题。收到 ISR 缩减告警时,先排查 Follower 所在 Broker 的负载和延迟。优雅下线优于故障下线。使用 kafka-reassign-partitions 先迁移 Leader 和副本,再下线 Broker,可以避免不必要的 Leader 选举和数据恢复开销。
服务端阅读 05月28日 08:26

RxJS Marble Testing 怎么写?弹珠测试核心用法与面试要点

什么是 Marble TestingRxJS 的异步数据流测试一直是前端开发中的难点——回调嵌套、定时器模拟、异步断言让测试代码既冗长又脆弱。Marble Testing 是 RxJS 官方提供的一种解决方案:用简短的字符串(称为 marble 弹珠字符串)可视化地描述 Observable 的时间线和事件,再由 TestScheduler 在虚拟时间中同步执行,把原本需要等待真实异步的测试变成瞬时可验证的同步断言。一句话概括:Marble Testing = 弹珠字符串 + TestScheduler = 用可视化语法写同步的异步测试。Marble 语法速查核心符号| 符号 | 含义 | 示例 ||------|------|------|| - | 时间流逝(1 帧,约 10ms) | --- 表示 30ms || a-z | 发出的值 | -a-b- 发出 a 和 b || \| | 完成 | -a-b-\| 发出后完成 || # | 错误 | -a-# 发出 a 后抛错 || () | 同步分组 | (abc\|) 同步发出 a、b、c 后完成 || ^ | 订阅点(hot Observable) | ^-a-b- 从订阅点开始接收 || ! | 取消订阅 | ^-a-! 订阅后收到 a 就取消 |常见 marble 字符串解读// 冷 Observable:从订阅时开始cold('-a-b-c-|')// → 10ms 发出 a,20ms 发出 b,30ms 发出 c,40ms 完成cold('-a-b-#')// → 10ms 发出 a,20ms 发出 b,30ms 报错cold('(abc|)')// → 同步发出 a、b、c,然后立即完成// 热 Observable:从 ^ 标记处开始接收hot('--a--b--c--|', { a: 1, b: 2, c: 3 })// → ^ 之前的历史值对新订阅者不可见TestScheduler 基本用法初始化 TestSchedulerimport { TestScheduler } from 'rxjs/testing';let testScheduler: TestScheduler;beforeEach(() => { testScheduler = new TestScheduler((actual, expected) => { // 深比较实际输出与期望输出 expect(actual).toEqual(expected); });}); 关键点:run() 回调内提供的 cold、hot、expectObservable、expectSubscriptions 是测试的四大工具,不要在 run() 外部使用它们。测试 map 操作符it('应将每个值转为大写', () => { testScheduler.run(({ cold, expectObservable }) => { const source$ = cold('-a-b-c-|', { a: 'hello', b: 'world', c: 'rxjs' }); const expected = '-A-B-C-|'; const result$ = source$.pipe(map(x => x.toUpperCase())); expectObservable(result$).toBe(expected, { A: 'HELLO', B: 'WORLD', C: 'RXJS' }); });}); 为什么要传值映射?当 marble 字符串中的字母与实际值不同时,必须通过第二个参数映射,否则默认值就是字母本身。面试高频:时间类操作符测试时间相关操作符是 Marble Testing 最核心的应用场景,因为传统方式很难精确控制时间。delayit('应延迟 30ms 发出值', () => { testScheduler.run(({ cold, expectObservable }) => { const source$ = cold('-a-b-|'); const expected = '---a-b-|'; const result$ = source$.pipe(delay(30, testScheduler)); expectObservable(result$).toBe(expected); });});debounceTimeit('应在 20ms 无新值后才发出', () => { testScheduler.run(({ cold, expectObservable }) => { const source$ = cold('-a--b--c---|'); const expected = '-----b--c---|'; const result$ = source$.pipe(debounceTime(20, testScheduler)); expectObservable(result$).toBe(expected); });});throttleTimeit('应每 30ms 最多发出一个值', () => { testScheduler.run(({ cold, expectObservable }) => { const source$ = cold('-ab-cde-f-|'); const expected = '-a---d--f-|'; const result$ = source$.pipe(throttleTime(30, testScheduler)); expectObservable(result$).toBe(expected); });}); 面试追问:debounceTime 和 throttleTime 的区别?前者等"安静期"再发,后者等"冷却期"再放行——两者在 marble 图上表现为截然不同的输出模式。组合操作符的测试merge:交错合并it('应交错合并两个流', () => { testScheduler.run(({ cold, expectObservable }) => { const source1$ = cold('-a---b-|'); const source2$ = cold('--c-d---|'); const expected = '-a-c-b-d-|'; const result$ = merge(source1$, source2$); expectObservable(result$).toBe(expected); });});concat:顺序拼接it('应顺序拼接两个流', () => { testScheduler.run(({ cold, expectObservable }) => { const source1$ = cold('-a-b-|'); const source2$ = cold('--c-d-|'); const expected = '-a-b--c-d-|'; const result$ = concat(source1$, source2$); expectObservable(result$).toBe(expected); });});combineLatest:取最新组合it('应在任一流发出时组合最新值', () => { testScheduler.run(({ cold, expectObservable }) => { const source1$ = cold('-a---b-|', { a: 1, b: 2 }); const source2$ = cold('--c-d---|', { c: 10, d: 20 }); const expected = '----xy-z|'; const result$ = combineLatest([source1$, source2$]); expectObservable(result$).toBe(expected, { x: [1, 20], y: [2, 20], z: [2, 20] }); });}); 面试追问:combineLatest 为什么第一个输出是 [1, 20] 而不是 [1, 10]?因为 combineLatest 要求每个源至少发出一次后才开始组合——source1$ 发出 a=1 时 source2$ 还没发出过值,直到 source2$ 发出 d=20 时两个流才都有值,此时组合的是 source1$ 的最新值 1 和 source2$ 的最新值 20。错误处理测试catchErrorit('应捕获错误并返回替代值', () => { testScheduler.run(({ cold, expectObservable }) => { const source$ = cold('-a-b-#'); const expected = '-a-b-(d|)'; const result$ = source$.pipe( catchError(() => of('d')) ); expectObservable(result$).toBe(expected); });});retryit('应在出错时重试一次', () => { testScheduler.run(({ cold, expectObservable }) => { const source$ = cold('-a-#'); const expected = '-a-a-#'; const result$ = source$.pipe(retry(1)); expectObservable(result$).toBe(expected); });}); 注意 (d|) 的括号:catchError 返回的 of('d') 是同步发出再完成的,在 marble 中必须用括号分组。订阅与取消订阅验证expectSubscriptions 专门验证 Observable 何时被订阅、何时被取消订阅,这是面试中区分"会用"和"理解原理"的分水岭。it('应在 take(2) 后自动取消订阅', () => { testScheduler.run(({ cold, expectObservable, expectSubscriptions }) => { const source$ = cold('-a-b-c-d-|'); const sub = '^---!'; const result$ = source$.pipe(take(2)); expectObservable(result$).toBe('-a-b-|'); expectSubscriptions(source$.subscriptions).toBe(sub); });}); ^---! 表示:订阅开始(^),经历 3 帧(---),取消订阅(!)。这验证了 take(2) 在收到第二个值后确实取消了上游订阅。实战场景搜索防抖it('应对输入做防抖后发起搜索', () => { testScheduler.run(({ cold, expectObservable }) => { const input$ = cold('-a--b---c-|'); const expected = '-----b---c-|'; const result$ = input$.pipe( debounceTime(20, testScheduler), distinctUntilChanged(), switchMap(q => search(q)) ); expectObservable(result$).toBe(expected); });});轮询与停止it('应按间隔轮询并在获取足够数据后停止', () => { testScheduler.run(({ expectObservable }) => { const expected = '-a-b-c-d-e-|'; const result$ = interval(10, testScheduler).pipe( take(5), map(i => String.fromCharCode(97 + i)) ); expectObservable(result$).toBe(expected); });});Hot Observable 的测试Hot Observable 在订阅前就已经开始发出值,测试时用 ^ 标记订阅起点,之后才能收到值。it('应只接收订阅后的值', () => { testScheduler.run(({ hot, expectObservable }) => { const source$ = hot('--a--b--c--|'); const sub = '---^-------!'; const expected = '--b--c--|'; const result$ = source$.pipe(take(2)); expectObservable(result$, sub).toBe(expected); });}); 面试追问:cold 和 hot 的本质区别?cold 每次订阅都重新开始,数据对每个订阅者独立;hot 共享同一个数据源,新订阅者只能收到订阅后的值。常见陷阱1. marble 字符串长度必须对齐// 错误:长度不一致const source$ = cold('-a-b-|');const expected = '-A-B-C-|'; // 多了 C,长度不匹配// 正确:每个位置一一对应const source$ = cold('-a-b-|');const expected = '-A-B-|';2. 不要忘记传 TestScheduler// 错误:使用了真实的 setTimeoutsource$.pipe(debounceTime(100));// 正确:传入 testScheduler 使用虚拟时间source$.pipe(debounceTime(100, testScheduler));3. run() 内部不要使用真实异步// 错误:run() 内用了 setIntervaltestScheduler.run(() => { setInterval(() => {}, 100); // 会干扰虚拟时间});4. 值映射与默认值当 marble 字母就是你想表达的值时,可以省略映射对象;但当字母与实际值不同(如 a 代表 1),必须显式传入。
服务端阅读 05月28日 08:25

Nginx 如何优化静态资源?有哪些优化策略?

Nginx 如何优化静态资源?有哪些优化策略?Nginx 处理静态资源的能力是面试高频考点。优化的核心思路是:减少磁盘 I/O、压缩传输体积、利用缓存避免重复请求、将负载推到边缘节点。下面从内核层到架构层逐级展开。sendfile 与零拷贝:从内核直接发送传统文件读取流程:磁盘 → 内核缓冲区 → 用户空间 → Socket 缓冲区 → 网卡,经历两次用户态拷贝。sendfile 让数据直接在内核态完成传输,省掉这两次拷贝,这是 Nginx 静态服务高性能的底层基础。http { sendfile on; # 启用零拷贝,数据在内核态直接从文件描述符传输到 socket tcp_nopush on; # 在包头积累到最大后才发送,减少网络帧数 tcp_nodelay on; # 禁用 Nagle 算法,小包立即发送}面试追问:tcpnopush 和 tcpnodelay 看起来矛盾,为什么要同时开启?tcpnopush 在 sendfile 阶段生效:把 HTTP 响应头和文件内容拼成一个大数据块再发送,减少碎片帧;当最后一块数据不足 MSS 时,tcpnodelay 接管,确保尾部数据不延迟发出。两者作用阶段不同,互不冲突。这是 Nginx 面试的经典追问。Gzip 压缩:减小传输体积文本类资源(HTML/CSS/JS/JSON/SVG)压缩收益显著,通常可减少 60%-80% 体积。但图片和视频本身已是压缩格式,再做 Gzip 反而浪费 CPU 且体积几乎不变。http { gzip on; gzip_vary on; # 添加 Vary: Accept-Encoding,帮助 CDN 区分压缩/非压缩版本 gzip_min_length 1024; # 小于 1KB 不压缩,压缩收益抵不过开销 gzip_comp_level 6; # 压缩级别 1-9,6 是速度与压缩率的最佳平衡点 gzip_types text/plain text/css text/javascript application/json application/javascript application/xml image/svg+xml; gzip_static on; # 优先发送预压缩的 .gz 文件,避免实时压缩消耗 CPU}gzip_static 的意义: 生产环境建议在构建阶段预生成 .gz 文件,Nginx 直接发送预压缩文件,零 CPU 开销。gzip on 作为兜底,当 .gz 文件不存在时实时压缩。浏览器缓存:避免重复请求缓存策略的核心是根据资源更新频率设置不同的过期时间。带哈希的静态资源(如 app.a3b2c1.js)可以长期缓存并标记 immutable,HTML 入口文件则需要短缓存或必须重新验证。server { # 带哈希的静态资源:长期缓存 + immutable location ~* \.(css|js|jpg|jpeg|png|gif|ico|svg|woff|woff2|ttf|eot)$ { expires 1y; add_header Cache-Control "public, immutable"; access_log off; # 静态资源不写日志,减少磁盘 I/O } # HTML 文件:短期缓存,必须重新验证 location ~* \.html$ { expires 1h; add_header Cache-Control "public, must-revalidate"; }}面试追问:immutable 是什么意思?告诉浏览器,资源在过期前不会变化,不需要发条件请求(If-Modified-Since / If-None-Match)验证。配合文件名哈希使用,用户刷新页面也不会产生 304 请求,直接从本地缓存读取。这在 HTTP RFC 8246 中被标准化。文件描述符缓存:减少系统调用Nginx 对频繁访问的文件可以缓存文件描述符(fd),避免每次请求都执行 open() / stat() 系统调用。在高并发场景下,这个优化效果显著。http { open_file_cache max=10000 inactive=20s; open_file_cache_valid 30s; # 每 30s 验证缓存项是否仍有效(检查文件是否被修改) open_file_cache_min_uses 2; # 20s 内访问少于 2 次则移除,避免冷文件占用缓存 open_file_cache_errors on; # 缓存文件不存在等错误,防止同一 404 反复穿透磁盘}内存开销:每个缓存项约占 256 字节,10000 项约 2.5MB,对现代服务器可忽略。但 open_file_cache_errors on 要注意,如果频繁请求不存在的文件,错误缓存会占用大量条目。动静分离与 CDN将静态资源部署到独立域名或 CDN 有三个核心收益:突破浏览器同域并发限制——浏览器对同一域名通常限制 6 个并发连接,独立域名可以并行下载更多资源减少主站 Cookie 污染——静态资源请求不携带主站 Cookie,减少请求体积边缘节点就近分发——CDN 将资源缓存到离用户最近的节点,大幅降低延迟server { listen 80; server_name example.com; # 方案一:静态资源独立目录 location /static/ { root /var/www/static; expires 1y; add_header Cache-Control "public, immutable"; access_log off; } # 方案二:直接重写到 CDN location ~* \.(css|js|jpg|jpeg|png|gif|ico|svg|woff2)$ { return 301 https://cdn.example.com$request_uri; }}HTTP/2 与资源预加载HTTP/2 多路复用已大幅降低多请求的开销,但资源预加载仍然有效。需要注意:Chrome 106+ 已移除 HTTP/2 Server Push 支持,推荐使用 <link rel="preload"> 替代。server { listen 443 ssl http2; # 使用 Link header 预加载关键资源 location = / { add_header Link "</css/style.css>; rel=preload; as=style, </js/app.js>; rel=preload; as=script"; }}preload vs prefetch: preload 告诉浏览器当前页面一定会用到该资源,立即下载;prefetch 表示下一页可能用到,空闲时下载。面试中常考两者区别。图片与字体优化server { # WebP 自动转换:浏览器支持 WebP 时优先返回 location ~* \.(jpg|png)$ { try_files $uri$webp_suffix $uri =404; expires 1y; add_header Cache-Control "public, immutable"; add_header Vary Accept; # 告诉 CDN 根据 Accept 头缓存不同版本 } # 字体文件:CORS 支持 + 长缓存 location ~* \.(woff2?|ttf|otf|eot)$ { expires 1y; add_header Cache-Control "public, immutable"; add_header Access-Control-Allow-Origin "*"; access_log off; } # 图片防盗链 location ~* \.(jpg|jpeg|png|gif)$ { valid_referers none blocked example.com *.example.com; if ($invalid_referer) { return 403; } }}WebP 相比 JPEG/PNG 通常可减少 25%-35% 体积,配合 CDN 的 Vary: Accept 头可以同时缓存原始格式和 WebP 格式。静态资源合并CSS/JS 合并减少请求数,但在 HTTP/2 场景下收益降低。是否合并需根据实际场景权衡:HTTP/1.1 环境:合并有效,减少连接开销HTTP/2 环境:多路复用已解决队头阻塞,合并收益有限,反而影响缓存命中率# 使用 ngx_http_concat_module(淘宝开源模块)# 访问 /static/css/??a.css,b.css,c.css 可合并返回location /static/css/ { concat on; concat_types text/css; concat_max_files 10;}安全与日志优化server { # 禁止访问隐藏文件(.git、.env 等敏感文件) location ~ /\. { deny all; access_log off; log_not_found off; } # 静态资源关闭 access_log,减少磁盘写入 location ~* \.(css|js|jpg|jpeg|png|gif|ico|svg|woff2?|ttf|eot)$ { access_log off; }}隐藏文件泄露是常见的安全漏洞。.git 目录暴露可能导致源码泄露,.env 暴露可能泄露数据库密码和 API Key。生产环境完整配置参考user nginx;worker_processes auto; # 自动匹配 CPU 核心数worker_rlimit_nofile 65535; # 提升进程级文件描述符上限events { worker_connections 10240; # 每个 worker 的最大连接数 use epoll; # Linux 下使用 epoll 事件模型 multi_accept on; # 一次性接受所有新连接}http { # 内核层优化 sendfile on; tcp_nopush on; tcp_nodelay on; # 传输层优化 gzip on; gzip_vary on; gzip_min_length 1024; gzip_comp_level 6; gzip_types text/plain text/css text/javascript application/json application/javascript application/xml image/svg+xml; gzip_static on; # 文件系统缓存 open_file_cache max=10000 inactive=20s; open_file_cache_valid 30s; open_file_cache_min_uses 2; open_file_cache_errors on; # 按内容类型动态设置缓存时间 map $sent_http_content_type $expires { default off; text/html 1h; text/css 1y; application/javascript 1y; ~image/ 1y; ~font/ 1y; } server { listen 80; server_name example.com; root /var/www/html; # 静态资源长缓存 location ~* \.(css|js|jpg|jpeg|png|gif|ico|svg|woff2?|ttf|eot)$ { expires $expires; add_header Cache-Control "public, immutable"; access_log off; } # 字体 CORS location ~* \.(woff2?|ttf|otf|eot)$ { add_header Access-Control-Allow-Origin "*"; } # 禁止隐藏文件 location ~ /\. { deny all; } location / { try_files $uri $uri/ =404; } }}优化策略速查表| 层级 | 策略 | 核心指令 | 收益 ||------|------|----------|------|| 内核层 | 零拷贝传输 | sendfile + tcpnopush + tcpnodelay | 减少 CPU 拷贝和网络碎片帧 || 传输层 | Gzip 压缩 | gzip + gzipstatic | 文本资源体积减少 60%-80% || 缓存层 | 浏览器缓存 | expires + Cache-Control immutable | 避免重复请求,零带宽消耗 || 缓存层 | 文件描述符缓存 | openfilecache | 减少 open/stat 系统调用 || 架构层 | 动静分离 + CDN | 独立域名 + CDN 回源 | 突破并发限制 + 就近分发 || 协议层 | HTTP/2 + Preload | http2 + Link header | 多路复用 + 关键资源预加载 || 格式层 | WebP + WOFF2 | tryfiles + Vary Accept | 图片体积再减 25%-35% || 安全层 | 防盗链 + 隐藏文件 | valid_referers + deny | 防止资源盗用和源码泄露 |面试中回答这类问题,建议按"内核优化 → 传输优化 → 缓存策略 → 架构设计"的层次递进,体现系统思维。每个策略说清原理、配置和收益,比单纯罗列配置更有说服力。如果面试官追问某一项的细节,可以深入到内核原理层面(如 sendfile 的 DMA 实现、epoll 的 LT/ET 模式),展示技术深度。
服务端阅读 05月28日 08:24

Kafka 为什么能够实现高吞吐量?

Kafka 为什么能够实现高吞吐量?Kafka 是目前业界吞吐量最高的消息队列之一,单机每秒可处理数十万条消息。这并非依赖某种银弹技术,而是多个设计决策协同作用的结果。理解这些原理,不仅能帮你在面试中给出有层次的回答,更能指导实际场景中的性能调优。顺序写:磁盘也能很快很多人对磁盘的性能认知停留在"慢",但这只对随机读写成立。顺序写磁盘的速度可以达到 600MB/s 以上,甚至超过随机写内存的效率。Kafka 的做法很直接:所有消息以追加(append)的方式写入日志文件,永远不修改已有数据。Consumer 也按偏移量顺序读取,整个读写路径上几乎没有随机 I/O。这个设计还带来一个额外好处——操作系统对顺序写有天然优化。数据先进入 Page Cache,由 OS 异步刷盘,Kafka 本身不需要调用 fsync(除非配置了强制刷盘),相当于写内存的速度。零拷贝:省掉两次不必要的数据搬运传统的网络数据发送要经历四次拷贝和四次上下文切换:磁盘 → 内核缓冲区 → 用户缓冲区 → Socket缓冲区 → 网卡其中"内核缓冲区 → 用户缓冲区 → Socket缓冲区"这两步是完全可以避免的。Kafka 使用 Linux 的 sendfile 系统调用,数据直接从内核缓冲区传输到网卡:磁盘 → 内核缓冲区 → 网卡拷贝次数从 4 次降到 2 次,CPU 上下文切换也从 4 次降到 2 次。在高吞吐场景下,这个差距会被放大到非常显著的程度。补充一点:Kafka 还用了 mmap(内存映射文件)来处理 Consumer 的偏移量索引文件,让索引查找避免一次用户态拷贝。sendfile 处理数据流,mmap 处理索引,两者配合覆盖了 Kafka 主要的数据路径。Page Cache:让 Kafka 不用自己管缓存很多中间件选择在 JVM 堆内维护缓存,但 JVM 的 GC 是吞吐量杀手——堆越大,GC 暂停越长,对延迟敏感的场景尤其致命。Kafka 反其道而行:不维护堆内缓存,直接依赖操作系统的 Page Cache。写入时数据进入 Page Cache 就算成功,读取时如果命中缓存就直接返回,都没经过 JVM 堆。这样做的好处:避免 GC 问题:Kafka 进程的堆可以设得很小(通常 6GB 足够),GC 暂停极短缓存不随进程重启丢失:进程挂了,Page Cache 还在,重启后数据依然热利用 OS 的 LRU 策略:操作系统比应用层更清楚哪些页面该淘汰批量处理:把零散请求打包网络请求的固定开销很高——一次 TCP 往返的延迟,加上协议解析、线程调度等开销。如果每条消息都单独发送,吞吐量会被网络开销吃掉。Kafka 在 Producer 端做了两层批量:# 一批消息的最大字节数batch.size=16384# 等待多久再发送(即使没凑满一批)linger.ms=5batch.size 控制批量上限,linger.ms 控制等待时间。两者配合,Producer 会攒够一批再发,或者等 5ms 没有新消息也发出去。这种微小的延迟换取的是网络请求次数的大幅减少。Consumer 端同理,fetch.min.bytes 和 fetch.max.wait.ms 也是同样的思路——宁可多等一会,也要一次多拉一些数据。分区并行:水平扩展的基础单个分区只能被一个 Consumer 消费,这就是单分区的吞吐量上限。Kafka 通过分区实现并行:Producer 可以同时向不同分区写入,Broker 端不同分区的写入由不同线程处理Consumer Group 中,每个分区分配给一个 Consumer 实例,多个实例并行消费分区分布在不同 Broker 上,网络 I/O 和磁盘 I/O 都被分散分区数决定了并行度的上限。但分区数也不是越多越好——每个分区在 Broker 上有对应的目录和索引文件,分区过多会增加文件句柄、增大 Leader 选举时间、加重 ZooKeeper/KRaft 负担。一般建议单 Broker 分区数不超过 1000-2000。数据压缩:端到端减少传输量Kafka 支持在 Producer 端压缩、Broker 端保持原样、Consumer 端解压,即端到端压缩。这意味着压缩的收益不仅体现在网络传输,还体现在磁盘存储上。常用压缩算法对比:| 算法 | 压缩比 | 压缩速度 | 适用场景 ||------|--------|----------|----------|| Snappy | 中等 | 快 | 通用场景,延迟敏感 || LZ4 | 中等 | 最快 | 极致低延迟 || Gzip | 高 | 慢 | 带宽受限,对延迟不敏感 || Zstd | 较高 | 较快 | Kafka 2.1+ 推荐 |选择压缩算法本质是 CPU 与带宽的权衡。CPU 有余量、带宽紧张就选高压缩比;延迟敏感就选快压缩。面试追问:如何进一步提升 Kafka 吞吐量?在理解原理的基础上,实际调优时可以从几个方向入手:Producer 侧:增大 batch.size 和 buffer.memory,适当调高 linger.ms,开启压缩,使用异步发送(acks=0 或 acks=1,牺牲部分可靠性换吞吐)。Broker 侧:增加分区数提升并行度,将日志目录挂载到不同磁盘实现 I/O 分散,调整 num.io.threads 匹配磁盘数量。Consumer 侧:增加 Consumer 实例数(不超过分区数),调大 fetch.min.bytes 和 max.poll.records,开启自动提交减少偏移量提交开销。硬件层面:SSD 替换 HDD 对顺序写提升有限(因为顺序写 HDD 也不慢),但对随机读和副本同步有明显帮助;增加内存扩大 Page Cache 命中率;万兆网卡消除网络瓶颈。需要强调的是,高吞吐和强可靠性是矛盾的。acks=all + min.insync.replicas=2 能保证数据不丢,但吞吐量会比 acks=0 低一个量级。生产环境中,金融、订单等关键业务必须优先可靠性,日志采集等场景可以优先吞吐量。
服务端阅读 05月28日 08:24

Kafka 如何保证消息的顺序性?

Kafka 在 Partition 级别保证消息顺序,不保证 Topic 级别的全局顺序Kafka 的顺序性保证是面试高频考点。核心结论:Kafka 只在同一个 Partition 内保证消息的写入和消费顺序一致,跨 Partition 没有顺序保证。 如果业务需要严格顺序,必须把相关消息路由到同一个 Partition。一、Partition 内为什么有序每个 Partition 本质上是一个追加写入的日志(append-only log),每条消息分配一个单调递增的 offset。Consumer 按 offset 顺序拉取,因此分区内天然有序。// Producer 发送时指定 Key,相同 Key 的消息进入同一 PartitionProducerRecord<String, String> record = new ProducerRecord<>( "order-topic", orderId, // Key — 决定分区路由 orderEvent // Value);producer.send(record);Kafka 默认分区策略:有 Key 则 hash(key) % numPartitions,无 Key 则轮询(round-robin)或粘性分区。二、跨 Partition 为什么无序一个 Topic 通常有多个 Partition,不同 Partition 的消息并行写入和消费,无法保证先后关系。示例:订单创建消息进入 Partition-0,支付消息进入 Partition-1,Consumer 可能先读到支付消息。Partition-0: [订单创建] [发货通知]Partition-1: [支付成功] [签收确认] ↓ 并行消费,顺序不可控三、如何保证业务上的顺序性方法 1:用相同的 Key 路由到同一 Partition(最常用)// 同一订单的所有事件使用 orderId 作为 Keyproducer.send(new ProducerRecord<>("order-topic", orderId, event));优点:简单、无需额外代码;缺点:同一 Key 的消息无法并行处理,可能成为热点。方法 2:自定义分区器public class OrderPartitioner implements Partitioner { @Override public int partition(String topic, Object key, byte[] keyBytes, Object value, byte[] valueBytes, Cluster cluster) { // 按业务规则路由:如按用户ID所在区域分区 String userId = (String) key; int regionCode = getUserIdRegion(userId); return regionCode % cluster.partitionCountForTopic(topic); }}配置方式:partitioner.class=com.example.OrderPartitioner方法 3:单分区 Topic# 创建只有一个 Partition 的 Topickafka-topics.sh --create --topic strict-order-topic \ --partitions 1 --replication-factor 3全局有序但吞吐极低,仅适用于顺序性要求极严且流量小的场景。四、容易踩的坑1. Producer 端重试导致乱序Producer 开启重试(retries > 0)时,如果 max.in.flight.requests.per.connection > 1,第一批消息失败重试后可能排在第二批后面,造成乱序。# 严格顺序场景下必须这样配置retries=2147483647max.in.flight.requests.per.connection=1enable.idempotence=true开启幂等(enable.idempotence=true)后,Kafka 2.0+ 可以在 max.in.flight.requests.per.connection <= 5 的情况下保证分区内顺序,因为 Broker 端会按序列号去重排序。2. Consumer Rebalance 导致重复消费Rebalance 发生时,Consumer 可能重复消费已处理的消息。如果业务处理非幂等,就会出现数据不一致。解决方案:消费端做好幂等(数据库唯一键、Redis 去重表等)。3. 多线程消费打乱顺序// 错误:多线程处理同一 Partition 的消息会乱序while (true) { ConsumerRecords<String, String> records = consumer.poll(Duration.ofMillis(100)); for (ConsumerRecord<String, String> record : records) { executor.submit(() -> process(record)); // 顺序无法保证 }}正确做法:按 Key hash 到同一线程处理,保证同 Key 消息的顺序。// 按 Key 分配到固定线程int threadIndex = Math.abs(record.key().hashCode()) % threadCount;executors[threadIndex].submit(() -> process(record));五、性能与顺序的取舍| 方案 | 顺序性 | 吞吐量 | 适用场景 ||------|--------|--------|----------|| 单 Partition | 全局有序 | 低 | 账户流水、状态机 || Key 路由同 Partition | Key 维度有序 | 中 | 订单状态、用户事件 || 多 Partition 无 Key | 无序 | 高 | 日志采集、指标上报 |大多数业务只需要同一业务实体维度有序(如同订单、同用户),通过合理设置 Key 即可兼顾顺序与性能。追问Q: Kafka 能否做到全局有序?为什么不用?技术上可以——单分区 + 单 Producer + 单 Consumer。但吞吐量受限于单机,无法水平扩展,只适合流量极低的场景(如金融账户变更日志)。Q: enable.idempotence 具体怎么保证顺序的?Producer 为每个 <PID, Partition> 维护递增的 Sequence Number,Broker 端按 SN 排序写入。即使请求乱序到达,Broker 也会按 SN 重排后再落盘,保证日志中消息有序。Q: 消费端如何保证 Exactly-Once 语义?Kafka 提供 Consumer 端的 exactly-once 需要配合事务:将消费和写入放在同一个 Kafka 事务中,或者使用幂等 + 手动提交 offset + 业务去重表的组合方案。
服务端阅读 05月28日 08:23

Nginx 如何处理动态内容?有哪些配置方式?

Nginx 如何处理动态内容?有哪些配置方式?Nginx 本身不执行动态脚本,它的角色是请求分发器——根据协议类型将动态请求转给后端应用服务器处理,自己只负责连接管理、缓冲、压缩和缓存等"外围工作"。常见的转发协议有四种:FastCGI、uWSGI、SCGI 和 HTTP 反向代理。核心答案:四种协议的区别与选型| 协议 | 典型后端 | 通信方式 | 适用场景 ||------|---------|---------|---------|| FastCGI | PHP-FPM | Unix socket / TCP | PHP 项目,最常见 || uWSGI | Python (Django/Flask) | Unix socket / TCP | Python Web 应用 || SCGI | Ruby 等 | Unix socket / TCP | 较少使用 || HTTP Proxy | Node.js / Go / Java | HTTP/TCP | 通用,任何 HTTP 服务 |选型原则:后端用什么语言/框架,就用对应协议。如果后端本身就是 HTTP 服务(如 Node.js),直接用 proxy_pass 做反向代理即可。FastCGI 配置(PHP 项目最常用)FastCGI 是 Nginx 处理 PHP 的标准方式。关键指令是 fastcgi_pass,它告诉 Nginx 把请求转发到哪里。location ~ \.php$ { try_files $uri =404; fastcgi_pass unix:/var/run/php/php8.0-fpm.sock; fastcgi_index index.php; # 这个参数必须设置,否则 PHP 找不到要执行的脚本 fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; include fastcgi_params; # 超时:后端响应慢时防止 Nginx 一直等 fastcgi_connect_timeout 60s; fastcgi_send_timeout 60s; fastcgi_read_timeout 60s; # 缓冲区:后端返回内容先缓冲再发给客户端 fastcgi_buffer_size 4k; fastcgi_buffers 8 4k;}几个容易踩的坑:SCRIPT_FILENAME 必须拼成绝对路径,否则 PHP-FPM 报 404Unix socket 比 TCP 快(省掉网络栈开销),但只能本机通信try_files $uri =404 防止 PHP 执行上传目录里的恶意脚本uWSGI 配置(Python 项目)Django、Flask 等 Python 框架常用 uWSGI 部署,Nginx 通过 uwsgi_pass 转发请求。upstream django_backend { server unix:/var/run/uwsgi/app.sock; server 127.0.0.1:8000;}server { listen 80; server_name example.com; location / { uwsgi_pass django_backend; include uwsgi_params; uwsgi_connect_timeout 60s; uwsgi_read_timeout 60s; } # 静态文件直接由 Nginx 处理,不走 uWSGI location /static/ { alias /var/www/html/static/; expires 1y; }}核心区别:uWSGI 用自己的二进制协议,比 HTTP 更紧凑高效,但只有 Nginx 等 Web 服务器能直接对接。HTTP 反向代理(Node.js / Go / Java)如果后端本身就是一个 HTTP 服务,直接用 proxy_pass 转发,这也是微服务架构中最常见的方式。upstream nodejs_backend { server 127.0.0.1:3000; server 127.0.0.1:3001;}server { listen 80; server_name example.com; location / { proxy_pass http://nodejs_backend; # 透传客户端真实信息 proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; # WebSocket 支持 proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; }}为什么需要这些 header:后端拿到的 remote_addr 是 Nginx 的 IP 而非客户端 IP,必须通过 X-Real-IP 和 X-Forwarded-For 透传。WebSocket 升级也需要额外的 Upgrade 头。FastCGI 缓存:让动态内容也变快不是所有动态请求都要实时回源。比如文章详情页、列表页这类"准静态"内容,可以用 FastCGI 缓存大幅降低后端压力。# 在 http 块中定义缓存区fastcgi_cache_path /var/cache/nginx/fastcgi levels=1:2 keys_zone=fastcgi_cache:10m max_size=1g inactive=60m;server { location ~ \.php$ { fastcgi_cache fastcgi_cache; fastcgi_cache_valid 200 60m; # 200 响应缓存 60 分钟 fastcgi_cache_valid 404 1m; # 404 只缓存 1 分钟 fastcgi_cache_key "$scheme$request_method$host$request_uri"; # 登录用户等场景需要跳过缓存 set $skip_cache 0; if ($http_cookie ~* "comment_author|wordpress_logged_in") { set $skip_cache 1; } fastcgi_cache_bypass $skip_cache; fastcgi_no_cache $skip_cache; fastcgi_pass unix:/var/run/php/php8.0-fpm.sock; fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; include fastcgi_params; add_header X-Cache-Status $upstream_cache_status; }}缓存清理:inactive=60m 表示 60 分钟无访问自动清除。如果需要主动清除,可用 proxy_cache_purge 模块或直接删除缓存目录下的文件。动态内容负载均衡后端多实例部署时,Nginx 的 upstream 模块提供多种分发策略:upstream php_backend { least_conn; # 最少连接数优先 server 192.168.1.100:9000 weight=3; # 权重 3 server 192.168.1.101:9000 weight=2; # 权重 2 server 192.168.1.102:9000; # 默认权重 1 keepalive 32; # 保持长连接,减少握手开销}策略选择:round-robin(默认):轮询,适合后端性能一致的场景least_conn:最少连接优先,适合请求处理时间差异大的场景ip_hash:同一 IP 固定到同一后端,适合需要会话保持的场景Gzip 压缩:减少传输体积动态内容通常是 JSON/HTML,压缩率很高,开启 Gzip 可以减少 60%-80% 的传输量。gzip on;gzip_vary on;gzip_min_length 1024; # 小于 1KB 不压缩,压缩反而更大gzip_comp_level 6; # 6 是性能和压缩率的平衡点gzip_types text/plain text/css application/json application/javascript application/xml;安全配置要点动态内容处理中,安全问题主要集中在两个方向:防止恶意脚本执行和限制请求大小。# 禁止访问敏感文件location ~* \.(htaccess|htpasswd|ini|log|sh|sql|bak|swp)$ { deny all;}# 防止上传目录中的 PHP 被执行location ^~ /uploads/ { location ~ \.php$ { deny all; }}# 限制请求体大小client_max_body_size 10m;常见攻击面:上传一个 .php 文件到图片目录,然后直接访问执行。用 ^~ 前缀匹配 + 内部 deny 可以彻底堵住这个口。追问:Nginx 处理动态内容的请求流程是什么?客户端请求到达 Nginx 后,大致经历以下步骤:Nginx 接收请求,根据 location 规则匹配到对应的转发配置按协议(FastCGI/uWSGI/HTTP)将请求转发给后端应用服务器后端处理完成,将响应返回给 NginxNginx 对响应做缓冲、压缩、缓存等处理最终将响应返回给客户端关键点:Nginx 在整个过程中只做"搬运工",不解析脚本内容。缓冲机制确保即使客户端读得慢,后端也能尽快释放连接;缓存机制则让重复请求直接从 Nginx 返回,后端完全不用参与。追问:502 Bad Gateway 和 504 Gateway Timeout 有什么区别?502:Nginx 成功连接到后端,但后端返回了无效响应(进程崩溃、端口未监听、协议不匹配等)504:Nginx 等待后端响应超时(后端处理太慢,超过了 fastcgi_read_timeout 或 proxy_read_timeout)排查思路:先看后端进程是否存活,再看 Nginx error log 中的具体错误信息,最后根据超时或连接失败调整对应参数。
前端阅读 05月28日 08:23

RxJS 中 Hot Observable 和 Cold Observable 有什么区别?

先搞清楚一个核心:数据生产者在哪Cold 和 Hot 的本质区别只有一个:数据生产者(Producer)是在订阅时创建,还是在 Observable 创建时就已经存在?Cold Observable:生产者在订阅时才创建,每个订阅者拿到独立的生产者,这就是"单播"(Unicast)Hot Observable:生产者在 Observable 创建时就已经存在,所有订阅者共享同一个生产者,这就是"多播"(Multicast)理解了这一点,后面所有特性都能推导出来,不需要死记硬背。Cold Observable:按需执行,人手一份Cold Observable 是"惰性"的——没有人订阅,它什么都不做。每次有新订阅者,它都会从头执行一遍逻辑,产生一份独立的数据流。import { Observable } from 'rxjs';const cold$ = new Observable(subscriber => { console.log('执行逻辑'); subscriber.next(Math.random()); subscriber.complete();});cold$.subscribe(v => console.log('订阅者A:', v));// 执行逻辑 → 订阅者A: 0.314159cold$.subscribe(v => console.log('订阅者B:', v));// 执行逻辑 → 订阅者B: 0.271828// 两次订阅各执行一次,随机值不同——因为每个订阅者有独立的生产者用一个生活类比:Cold Observable 像电影院的电影文件——每个观众点播时,影院单独为他播放一份,各看各的进度,互不影响。常见 Cold 操作符:of()、from()、interval()、timer()、ajax()、Angular 的 HttpClient.get()Hot Observable:共享数据流,先到先得Hot Observable 是"主动"的——不管有没有人订阅,生产者都在运作。新订阅者只能收到订阅之后的数据,之前发过的就错过了。import { Subject } from 'rxjs';const subject = new Subject();subject.subscribe(v => console.log('订阅者A:', v));subject.next(1); // 订阅者A: 1subject.subscribe(v => console.log('订阅者B:', v));subject.next(2); // 订阅者A: 2, 订阅者B: 2// 订阅者B 没收到 1,因为订阅晚了类比:Hot Observable 像电视直播——频道一直在播,你打开电视只能看到当前和后续的节目,之前的已经播完了回不来。常见 Hot 来源:Subject 及其变体(BehaviorSubject、ReplaySubject、AsyncSubject)、fromEvent() 绑定的 DOM 事件、WebSocket 连接单播 vs 多播:从源码角度理解Cold Observable 的 subscribe 函数里直接创建生产者:// Cold:每次 subscribe 都执行这个函数,各订阅者独立const cold$ = new Observable(subscriber => { const source = createProducer(); // 每个订阅者创建自己的生产者 source.onData(data => subscriber.next(data));});Hot Observable 的生产者在外部,subscribe 只是注册监听:// Hot:生产者已存在,subscribe 只是往里注册回调const hot$ = new Observable(subscriber => { externalSource.addListener(data => subscriber.next(data)); // 所有订阅者监听同一个 externalSource});所以 Cold → Hot 的本质就是把内部生产者提到外部,让多个订阅者共享。Cold 转 Hot 的三种方式share()——最常用share() 内部使用 Subject 实现多播,并且带 refCount 机制:当订阅者数从 1 降到 0 时自动断开上游,再有新订阅者时重新连接。import { interval } from 'rxjs';import { share, take } from 'rxjs/operators';const source$ = interval(1000).pipe(take(5));const shared$ = source$.pipe(share());shared$.subscribe(v => console.log('A:', v));setTimeout(() => shared$.subscribe(v => console.log('B:', v)), 2000);// A 和 B 共享同一个 interval 计时器// B 在第2秒加入,只能收到 2、3、4shareReplay(n)——缓存最近 n 个值shareReplay 在 share 的基础上缓存最近的 n 个值,新订阅者能立即收到缓存数据,解决"来晚了错过数据"的问题。import { interval } from 'rxjs';import { shareReplay, take } from 'rxjs/operators';const source$ = interval(1000).pipe(take(5), shareReplay(1));source$.subscribe(v => console.log('A:', v));setTimeout(() => { source$.subscribe(v => console.log('B:', v)); // B 立即收到缓存的最新的一个值,然后继续接收后续值}, 3000);关键区别:share() 的 refCount 在订阅者归零后断开上游,而 shareReplay() 默认不会断开(可通过 config.resetOnComplete 等参数调整)。publish() + connect()——手动控制publish() 把 Cold Observable 变成 ConnectableObservable,必须手动调用 connect() 才开始执行。适合需要先注册所有订阅者再启动数据流的场景。import { interval } from 'rxjs';import { publish, take } from 'rxjs/operators';const source$ = interval(1000).pipe(take(5), publish());source$.subscribe(v => console.log('A:', v));source$.subscribe(v => console.log('B:', v));// 此时不执行,等所有订阅者就绪source$.connect(); // 手动启动实际开发中的选择用 Cold 的场景HTTP 请求:每个组件独立获取数据,互不干扰独立计算:每个订阅者需要各自的处理结果可重复执行:每次订阅都希望从头获取完整数据用 Hot 的场景共享 HTTP 结果:多个组件需要同一接口的数据,用 shareReplay(1) 避免重复请求事件监听:DOM 事件、WebSocket 消息天然就是多播的状态管理:BehaviorSubject 持有最新状态,新订阅者立即获取当前值最容易踩的坑坑1:忘记共享导致重复请求// 每次订阅都发新请求——大忌const data$ = http.get('/api/data');data$.subscribe(handle1);data$.subscribe(handle2); // 又发了一次请求// 用 shareReplay 共享const data$ = http.get('/api/data').pipe(shareReplay(1));data$.subscribe(handle1);data$.subscribe(handle2); // 只发一次请求坑2:share() 的 refCount 陷阱const source$ = interval(1000).pipe(share());const sub1 = source$.subscribe(v => console.log('A:', v));const sub2 = source$.subscribe(v => console.log('B:', v));sub1.unsubscribe();sub2.unsubscribe();// 所有订阅者都取消后,上游停止source$.subscribe(v => console.log('C:', v));// 重新订阅,上游重新连接,C 从0开始收数据// 如果这里用 shareReplay(1),行为可能不同坑3:shareReplay 缓存过多// 缓存1000个值,内存会爆interval(1000).pipe(shareReplay(1000));// 通常缓存1个就够了interval(1000).pipe(shareReplay(1));一张表总结| 特性 | Cold Observable | Hot Observable ||------|----------------|----------------|| 生产者创建时机 | 订阅时 | Observable 创建时 || 数据流 | 每个订阅者独立 | 所有订阅者共享 || 传播方式 | 单播(Unicast) | 多播(Multicast) || 错过数据 | 不会,每次从头 | 会,只能收订阅后的数据 || 典型代表 | of、from、HTTP | Subject、DOM 事件 || 转 Hot | share()、shareReplay() | 不可转 Cold |记住核心判断:看生产者——订阅时创建就是 Cold,早就存在就是 Hot。 面试中如果能从生产者角度解释单播/多播的区别,再提到 share 的 refCount 机制和 shareReplay 的缓存策略,基本就能拿到高分。
前端阅读 05月28日 07:28

Prettier 与其他代码格式化工具有什么区别?如何选择?

Prettier 和 ESLint 有什么本质区别?Prettier 是代码格式化工具,ESLint 是代码质量检查工具,二者不是替代关系而是互补关系。核心区别在于工作原理:Prettier 将代码解析为 AST(抽象语法树),然后按照自己的规则重新输出,保证同样的输入永远得到同样的输出;ESLint 则基于规则引擎逐行扫描代码,检测潜在的错误和反模式。实际项目中标准做法是两者结合:用 eslint-config-prettier 关闭 ESLint 中与格式化重叠的规则,让 Prettier 完全负责格式化(缩进、换行、引号风格),ESLint 专注代码质量(未使用变量、潜在 bug、最佳实践)。// .eslintrc.json{ "extends": ["eslint:recommended", "prettier"], "plugins": ["prettier"]}Prettier 相比 Beautify、Standard.js 的优势在哪?vs Beautify: Beautify 基于正则匹配做格式化,不具备 AST 解析能力,对复杂语法结构(如嵌套的三元表达式、链式调用)的格式化效果差,且输出不确定——同一份代码多次格式化可能产生不同结果。Prettier 基于 AST 重新打印代码,输出完全确定性,这是团队协作的基础。vs Standard.js: Standard.js 是"零配置"的代名词,但它不允许任何自定义——分号必须有或必须没有,没有中间地带。Prettier 同样开箱即用,但保留了少量关键配置(单引号/双引号、分号、行宽等),适合需要一定灵活性的团队。| 维度 | Prettier | Beautify | Standard.js ||------|----------|----------|-------------|| 解析方式 | AST | 正则 | AST || 输出确定性 | 完全确定 | 不确定 | 完全确定 || 可配置性 | 少量关键选项 | 丰富 | 几乎为零 || 多语言支持 | JS/TS/CSS/HTML/JSON/MD | JS/CSS/HTML | JS/TS |Biome 等新一代工具会取代 Prettier 吗?2026 年 Biome 成为最值得关注的替代方案。它用 Rust 编写,将格式化和 lint 合并为一个工具,在大型 monorepo 中性能优势显著:10,000+ 文件的项目,格式化+检查不到 200ms,而 ESLint+Prettier 组合需要近 12 秒。但 Prettier 短期内不会被取代,原因有三:生态成熟度: Prettier 拥有大量编辑器插件、预提交钩子、CI 集成方案,Biome 生态仍在追赶插件体系: Prettier 支持插件格式化额外语言(如 Java、Ruby、PHP),Biome 目前语言覆盖有限迁移成本: 已有项目的 .prettierrc 配置和格式化基线,切换工具意味着大量 diff选择建议: 新项目可以尝试 Biome,享受性能提升和简化配置;已有项目不必急于迁移,等 Biome 生态更成熟再说。Prettier 的 AST 重打印机制是什么意思?这是理解 Prettier 行为的关键。Prettier 的工作流程:解析(Parse): 将源代码解析为 AST遍历(Traverse): 遍历 AST 节点打印(Print): 根据行宽限制和自身规则重新输出代码这意味着 Prettier 不是"调整"你的代码,而是"重新生成"你的代码。你写的空行、多余括号、手动对齐——大部分都会被丢弃重写。这也是为什么 Prettier 配置选项少:它不是逐条规则控制,而是整体重打印,只暴露行宽、缩进等顶层参数。这种设计牺牲了灵活性,换来了确定性。实际项目中怎么配置 Prettier + ESLint?完整的工程化配置分三步:第一步:安装依赖npm install -D prettier eslint eslint-config-prettier eslint-plugin-prettier第二步:配置文件// .prettierrc{ "semi": true, "singleQuote": true, "printWidth": 80, "trailingComma": "es5"}// .eslintrc.json{ "extends": ["eslint:recommended", "plugin:prettier/recommended"], "env": { "es2024": true, "node": true }}plugin:prettier/recommended 做了三件事:加载 eslint-plugin-prettier(把 Prettier 规则作为 ESLint 规则运行)、加载 eslint-config-prettier(关闭 ESLint 格式化相关规则)、设置 prettier/prettier 为 error 级别。第三步:编辑器集成// .vscode/settings.json{ "editor.defaultFormatter": "esbenp.prettier-vscode", "editor.formatOnSave": true, "editor.codeActionsOnSave": { "source.fixAll.eslint": "explicit" }}保存时先 Prettier 格式化,再 ESLint 自动修复,分工明确不冲突。第四步:Git 钩子自动化npm install -D husky lint-stagednpx husky initecho "npx lint-staged" > .husky/pre-commit// package.json{ "lint-staged": { "*.{js,ts}": ["eslint --fix", "prettier --write"], "*.{css,html,json,md}": ["prettier --write"] }}提交时自动格式化和检查,不合格的代码进不了仓库。Prettier 有哪些已知局限?配置不够灵活: 行宽以内无法手动换行,printWidth: 80 时超过 80 字符的链式调用会被强制换行,即使你手动排列得更易读。这是"确定性"的代价——不允许个人偏好覆盖工具判断。大项目性能瓶颈: Prettier 是单线程的,超大型项目全量格式化耗时较长。应对方式是用 lint-staged 只格式化变更文件,或引入缓存。版本升级可能产生 diff: Prettier 的格式化结果在不同大版本间可能有差异,团队必须锁定版本号,升级时全量格式化会产生大量无意义 diff。面试追问:什么时候不该用 Prettier?三种场景下 Prettier 不是最佳选择:遗留大型项目: 全量格式化会产生数千行 diff,干扰 code review,建议渐进式引入(只格式化新文件或变更文件)需要精细控制格式的场景: 如代码生成器输出、教学材料中特意安排的缩进,Prettier 的重打印会破坏这些刻意格式纯 Python 项目: Python 有 Black,设计理念与 Prettier 一致但针对 Python 语法优化,混用 Prettier 反而增加复杂度
服务端阅读 05月28日 07:28

Nginx 如何配置虚拟主机?有哪些配置方式?

Nginx 如何配置虚拟主机?有哪些配置方式?虚拟主机(Virtual Host)是 Nginx 最核心的能力之一——一台服务器、一个 Nginx 进程,就能同时服务几十个甚至上百个网站。面试中这道题考察的不只是"怎么配",更是你对其背后路由机制的理解深度。Nginx 虚拟主机的本质就是 server 块。每个 server 块是一个独立的虚拟主机,Nginx 根据请求的域名、端口或 IP 地址,将请求路由到匹配的 server 块处理。三个关键指令决定了路由规则:listen:监听的地址和端口,如 listen 80 或 listen 443 sslserver_name:匹配请求头中的 Host 字段,支持精确匹配、通配符和正则root:该虚拟主机的网站根目录基于域名的虚拟主机这是生产环境最主流的方式。 多个域名共享同一个 IP,Nginx 根据 HTTP 请求头中的 Host 字段决定将请求交给哪个 server 块处理。这也叫 Name-Based Virtual Host:server { listen 80; server_name example.com www.example.com; root /var/www/example.com; index index.html; access_log /var/log/nginx/example.com.access.log; error_log /var/log/nginx/example.com.error.log; location / { try_files $uri $uri/ =404; }}server { listen 80; server_name test.com www.test.com; root /var/www/test.com; index index.html; access_log /var/log/nginx/test.com.access.log; error_log /var/log/nginx/test.com.error.log; location / { try_files $uri $uri/ =404; }}两个 server 块都监听 80 端口,Nginx 收到请求后先匹配 server_name,命中哪个就走哪个配置。server_name 用空格分隔可以写多个域名,www.example.com 和 example.com 都会命中第一个块。面试追问:如果两个 server 块的 servername 都匹配同一个域名会怎样? Nginx 有明确的匹配优先级:精确匹配 > 最长通配符前缀 > 最长通配符后缀 > 第一个正则匹配 > defaultserver。优先级相同则按配置文件加载顺序,先加载的优先。基于端口的虚拟主机通过不同端口区分服务,适合将管理后台、API 服务与主站做物理隔离:# 主站server { listen 80; server_name example.com; root /var/www/example.com; index index.html; location / { try_files $uri $uri/ =404; }}# 管理后台server { listen 8080; server_name example.com; root /var/www/example.com/admin; index index.html; location / { try_files $uri $uri/ =404; }}# HTTPS 安全服务server { listen 8443 ssl; server_name example.com; ssl_certificate /etc/nginx/ssl/example.com.crt; ssl_certificate_key /etc/nginx/ssl/example.com.key; root /var/www/example.com/secure; index index.html; location / { try_files $uri $uri/ =404; }}端口方式的缺点是用户需要显式指定端口号(如 example.com:8080),80 和 443 之外的端口对用户不友好,因此一般只用于内部服务或管理入口。基于 IP 地址的虚拟主机服务器绑定多个 IP 时,按 IP 地址区分站点。这种方式在早期互联网常用,但在现代云环境下已很少使用——公网 IP 资源有限且费用高,基于域名的方式更经济:server { listen 192.168.1.100:80; server_name example.com; root /var/www/example.com; index index.html; location / { try_files $uri $uri/ =404; }}server { listen 192.168.1.101:80; server_name example.com; root /var/www/example.com/mirror; index index.html; location / { try_files $uri $uri/ =404; }}通配符与正则域名匹配子域名多且动态变化时,逐一配置 server_name 不现实。Nginx 提供通配符和正则两种高级匹配:通配符匹配——用 * 匹配子域名:server { listen 80; server_name *.example.com; location / { set $subdomain $host; if ($subdomain ~* ^(.*)\.example\.com$) { set $subdomain $1; } root /var/www/subdomains/$subdomain; index index.html; }}# 默认虚拟主机:兜底处理未匹配的请求server { listen 80 default_server; server_name _; root /var/www/default; return 444; # 直接关闭连接,比 404 更安全}default_server 是安全防线——任何未匹配到 server_name 的请求都会走这个块。建议返回 444(Nginx 特有状态码,直接关闭连接),比返回 404 更能防止信息泄露。正则表达式匹配——用命名捕获组提取子域名:server { listen 80; server_name ~^(?<subdomain>.+)\.example\.com$; root /var/www/example.com/$subdomain; index index.html; location / { try_files $uri $uri/ =404; }}server { listen 80; server_name ~^(?<user>.+)\.users\.example\.com$; root /var/www/users/$user; index index.html; location / { try_files $uri $uri/ =404; }}?<name> 是命名捕获组,提取的值以 $name 变量在配置中引用。通配符只能做简单的 * 匹配,正则则能处理复杂的域名模式——实际项目中正则用得更多。HTTPS 虚拟主机生产环境必须启用 HTTPS,标准做法是 HTTP 301 永久跳转到 HTTPS:# HTTP -> HTTPS 301 跳转server { listen 80; server_name example.com; return 301 https://$server_name$request_uri;}# HTTPS 服务server { listen 443 ssl http2; server_name example.com; ssl_certificate /etc/nginx/ssl/example.com.crt; ssl_certificate_key /etc/nginx/ssl/example.com.key; ssl_protocols TLSv1.2 TLSv1.3; ssl_ciphers HIGH:!aNULL:!MD5; ssl_session_cache shared:SSL:10m; ssl_session_timeout 10m; root /var/www/example.com; index index.html; location / { try_files $uri $uri/ =404; }}http2 参数直接在 listen 指令中启用 HTTP/2,无需额外编译模块。ssl_session_cache 将 SSL 会话缓存 10 分钟,客户端重连时可以复用,避免完整的 TLS 握手,显著提升 HTTPS 性能。SSL 协议只保留 TLSv1.2 和 TLSv1.3,禁用所有不安全的旧版本。反向代理虚拟主机虚拟主机在生产中最常见的用途是反向代理——不同域名转发到不同的后端服务,Nginx 作为网关统一入口:upstream backend1 { server 192.168.1.100:8080; server 192.168.1.101:8080;}upstream backend2 { server 192.168.1.200:8080; server 192.168.1.201:8080;}server { listen 80; server_name api.example.com; location / { proxy_pass http://backend1; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; }}server { listen 80; server_name admin.example.com; location / { proxy_pass http://backend2; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; }}proxy_set_header 三件套必须配置:Host 让后端知道原始域名,X-Real-IP 传递客户端真实 IP,X-Forwarded-For 追加代理链路。不加这些头,后端拿到的全是 Nginx 的内网 IP,日志和鉴权都会出问题。多域名共享配置多个域名配置相似、仅 root 路径不同时,用 map 指令消除重复:map $host $root_path { example.com /var/www/example.com; test.com /var/www/test.com; default /var/www/default;}server { listen 80; server_name example.com test.com; root $root_path; index index.html; location / { try_files $uri $uri/ =404; }}map 在请求处理阶段根据 $host 变量的值映射到对应目录,一个 server 块就能服务多个域名。当域名数量超过 5 个且配置差异仅在路径时,这种方式比逐个写 server 块更易维护。配置文件分离与站点管理生产环境中,千万不要把所有虚拟主机堆在 nginx.conf 一个文件里。用 include 拆分:# /etc/nginx/nginx.confhttp { include /etc/nginx/conf.d/*.conf; include /etc/nginx/sites-enabled/*;}每个虚拟主机一个配置文件,放在 sites-available 目录,通过符号链接启用:# 启用站点ln -s /etc/nginx/sites-available/example.com /etc/nginx/sites-enabled/# 禁用站点rm /etc/nginx/sites-enabled/example.com# 测试配置语法(修改后必做)nginx -t# 重新加载配置(不影响正在处理的请求)nginx -s reloadnginx -t 是生产操作的铁律——修改配置后先检测语法,通过后再 reload。reload 是平滑重载,Nginx 会等旧请求处理完再切换到新配置,不会中断服务。而 restart 会直接杀掉工作进程,正在处理的请求会断开。PHP 应用虚拟主机WordPress、Laravel 等 PHP 应用需要配置 FastCGI 传递给 PHP-FPM:server { listen 80; server_name example.com; root /var/www/example.com; index index.php index.html; location / { try_files $uri $uri/ /index.php?$query_string; } location ~ \.php$ { fastcgi_pass unix:/var/run/php/php8.0-fpm.sock; fastcgi_index index.php; fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; include fastcgi_params; } location ~* \.(css|js|jpg|jpeg|png|gif|ico|svg|woff|woff2)$ { expires 1y; add_header Cache-Control "public, immutable"; }}try_files 中的 /index.php?$query_string 是 PHP 框架的标配写法——先尝试找静态文件,找不到就转发给 index.php 处理,这是 Laravel 等框架 URL 重写的基础。SCRIPT_FILENAME 必须用 $document_root 拼接,否则 PHP-FPM 找不到脚本文件。静态站点虚拟主机纯静态站点可以做更激进的优化——关日志、开压缩、长缓存:server { listen 80; server_name static.example.com; root /var/www/static; index index.html; gzip on; gzip_vary on; gzip_min_length 1024; gzip_types text/plain text/css text/xml text/javascript application/json application/javascript; location ~* \.(css|js|jpg|jpeg|png|gif|ico|svg|woff|woff2|ttf|eot)$ { expires 1y; add_header Cache-Control "public, immutable"; access_log off; } location / { try_files $uri $uri/ =404; }}静态资源关闭 access_log 能显著减少磁盘 IO。高流量站点中,日志写入是主要瓶颈之一,对不需要统计的静态资源关闭日志是常规优化手段。生产环境完整配置模板综合安全、性能和可维护性的完整配置,可作为新项目的起点:server { listen 80; server_name example.com www.example.com; return 301 https://$server_name$request_uri;}server { listen 443 ssl http2; server_name example.com www.example.com; # SSL ssl_certificate /etc/nginx/ssl/example.com.crt; ssl_certificate_key /etc/nginx/ssl/example.com.key; ssl_protocols TLSv1.2 TLSv1.3; ssl_ciphers HIGH:!aNULL:!MD5; ssl_session_cache shared:SSL:10m; ssl_session_timeout 10m; # 站点根目录 root /var/www/example.com; index index.php index.html; # 日志 access_log /var/log/nginx/example.com.access.log; error_log /var/log/nginx/example.com.error.log; # 安全头 add_header X-Frame-Options "SAMEORIGIN" always; add_header X-Content-Type-Options "nosniff" always; add_header X-XSS-Protection "1; mode=block" always; # Gzip 压缩 gzip on; gzip_vary on; gzip_min_length 1024; gzip_types text/plain text/css text/xml text/javascript application/json application/javascript; # 静态资源长缓存 location ~* \.(css|js|jpg|jpeg|png|gif|ico|svg|woff|woff2)$ { expires 1y; add_header Cache-Control "public, immutable"; access_log off; } # PHP-FPM location ~ \.php$ { fastcgi_pass unix:/var/run/php/php8.0-fpm.sock; fastcgi_index index.php; fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; include fastcgi_params; } # 主路由 location / { try_files $uri $uri/ /index.php?$query_string; } # 禁止访问隐藏文件(.git、.env 等) location ~ /\. { deny all; access_log off; log_not_found off; }}配置要点总结三种类型的选用原则:基于域名是首选方案,一个 IP 托管所有站点,配置简单且用户无感知;基于端口适合内部服务隔离,但需要用户感知端口;基于 IP 在公网环境下已基本淘汰。server_name 匹配优先级(面试必考):精确匹配 > 通配符前缀(*.example.com)> 通配符后缀(example.*)> 正则表达式 > default_server。优先级相同的,按配置文件加载顺序决定。配置排错四步法:nginx -t 检查语法 -> nginx -s reload 确认重载 -> 检查 server_name 是否匹配请求域名 -> 查看 error.log 中的具体报错。大部分"配置不生效"的问题,要么是忘记 reload,要么是 server_name 写错了。
前端阅读 05月28日 07:26

如何在 CI/CD 中集成 Prettier 做代码格式检查?

为什么要用 Prettier 拦截代码格式问题代码格式不一致是团队协作中最容易引发无意义争论的问题。Prettier 通过"零配置强制统一"的思路消除了这类争议,但仅靠开发者自觉运行 Prettier 并不可靠——有人会忘记格式化,有人会选择性忽略。把 Prettier 检查嵌入 CI/CD 流水线,是保证代码库格式一致性的最后防线。推荐的两层防护策略:本地 Git Hook 做即时拦截 + CI 流水线做兜底检查。前者让开发者在提交前就能发现问题,后者防止绕过 Hook 的代码进入主分支。本地拦截:Git Hooks 配置Husky + lint-staged 方案这是目前最主流的方案,lint-staged 的核心优势是只格式化本次提交涉及的文件,不会全量扫描,提交速度有保障。安装依赖 npm install --save-dev husky lint-staged prettier npx husky install npm pkg set scripts.prepare="husky install"配置 lint-staged在 package.json 中添加: { "lint-staged": { "*.{js,jsx,ts,tsx}": [ "prettier --write" ], "*.{json,css,scss,md}": [ "prettier --write" ] } }分开配置的好处是后续可以为 JS/TS 文件加入 ESLint 检查,而不影响纯样式或文档文件。创建 pre-commit Hook npx husky add .husky/pre-commit "npx lint-staged"常见坑:Husky 不生效未执行 husky install:克隆仓库后需要手动运行一次 npm preparecorehooks 被覆盖:某些工具(如 Gerrit)会修改 Git hooks 路径,检查 git config core.hooksPathlint-staged 卡住:文件路径含空格或中文时需要用引号包裹 glob 模式CI 流水线集成GitHub Actions创建 .github/workflows/prettier.yml:name: Prettier Checkon: push: branches: [main] pull_request: branches: [main]jobs: prettier: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: 20 - run: npm ci - run: npx prettier --check "src/**/*.{js,jsx,ts,tsx,json,css,scss,md}"关键细节:用 npm ci 而非 npm install,前者严格按照 lock 文件安装,CI 环境更稳定--check 模式只检查不修改,符合 CI "只读" 原则缩小 glob 范围到 src/ 目录,避免扫描 node_modules 或构建产物如果希望 PR 中直接看到哪些文件格式不对,可以用 --list-different 替代 --check,它会列出有问题的文件名,输出更直观:- run: npx prettier --list-different "src/**/*.{js,jsx,ts,tsx,json,css,scss,md}"GitLab CI在 .gitlab-ci.yml 中添加:prettier: stage: test image: node:20-alpine script: - npm ci - npx prettier --check "src/**/*.{js,jsx,ts,tsx,json,css,scss,md}" only: - merge_requests - main cache: key: ${CI_COMMIT_REF_SLUG} paths: - node_modules/用 node:20-alpine 镜像比完整 Node 镜像小 5 倍以上,流水线启动更快。加上 cache 配置避免每次都全量安装依赖。如果想在 GitLab 的 Code Quality 报告中展示 Prettier 错误,可以使用 @studiometa/prettier-formatter-gitlab 将输出转为 GitLab 可识别的格式。Jenkins在 Jenkinsfile 中添加阶段:stage('Format Check') { steps { sh 'npm ci' sh 'npx prettier --check "src/**/*.{js,jsx,ts,tsx}"' }}Bitbucket Pipelines在 bitbucket-pipelines.yml 中添加:pipelines: pull-requests: '**': - step: name: Prettier Check image: node:20-alpine script: - npm ci - npx prettier --check "src/**/*.{js,jsx,ts,tsx,json,css,scss,md}"Prettier 配置文件示例CI 检查的准确性依赖项目中有明确的 Prettier 配置。创建 .prettierrc:{ "semi": true, "singleQuote": true, "trailingComma": "es5", "printWidth": 100, "tabWidth": 2}同时创建 .prettierignore 排除不需要检查的内容:node_modulesdistbuildcoverage*.min.jspackage-lock.json配置必须在本地和 CI 之间保持一致——这也是为什么 Prettier 要作为 devDependencies 安装而非全局安装,npm ci 会确保 CI 环境拿到和本地完全相同的版本。Prettier 与 ESLint 的协作Prettier 只管格式,ESLint 管代码质量,两者配合才是完整方案。核心原则是用 eslint-config-prettier 关闭 ESLint 中与 Prettier 冲突的规则:npm install --save-dev eslint-config-prettier在 .eslintrc.js 中:module.exports = { extends: [ 'eslint:recommended', 'prettier' // 必须放在最后,覆盖前面的格式相关规则 ]}CI 中可以合并为一条检查:script: - npm ci - npx eslint "src/**/*.{js,ts}" - npx prettier --check "src/**/*.{js,ts,json,css,md}"Monorepo 场景的优化策略在 Turborepo 或 Nx 管理的 monorepo 中,全量 npm ci + 全局 Prettier check 会非常慢。两个优化方向:用 Turborepo 的 filter 定位变更包: npx turbo run format:check --filter=...[HEAD^]只检查本次提交影响到的包。用 changesets 圈定范围:在 CI 中先用 git diff 找出变更的包目录,再对对应目录执行 Prettier check。Prettier 的 --cache 选项(Prettier 3.1+ 支持):只检查未缓存的文件,对大型仓库效果显著: npx prettier --check --cache "src/**/*.{js,ts}"缓存默认写入 node_modules/.cache/prettier,CI 中记得把这个目录加入缓存配置。CI 检查失败怎么办当 CI 报告格式不一致时,最快的修复方式是在本地执行:npx prettier --write "src/**/*.{js,jsx,ts,tsx,json,css,scss,md}"如果频繁出现格式不一致,排查以下几点:Prettier 版本不一致:检查 package.json 中的版本,确保本地和 CI 的 npm ci 安装的是同一个版本编辑器格式化插件冲突:VS Code 中可能同时有多个格式化扩展在生效,在设置中指定 Prettier 为默认格式化器.editorconfig 与 .prettierrc 冲突:两者同时存在时 Prettier 优先,但建议统一到 .prettierrc 中管理核心要点总结本地 Git Hook 做即时反馈,CI 流水线做兜底保障,两者缺一不可--check 模式用于 CI,--write 模式用于本地修复,不要在 CI 中用 --write用 npm ci 代替 npm install 保证依赖版本一致性Prettier 与 ESLint 配合时,eslint-config-prettier 必须放在 extends 最后Monorepo 项目用 filter 或 --cache 缩小检查范围,避免全量扫描拖慢流水线
服务端阅读 05月28日 07:26

Nginx 的 location 指令如何匹配?优先级是什么?

Nginx 的 location 指令如何匹配?优先级是什么?location 是 Nginx 中最核心的指令之一,它决定了一个请求由哪个配置块来处理。理解它的匹配规则和优先级,不仅是面试高频考点,更是排查 Nginx 配置问题的基本功。location 的四种匹配方式location 指令的语法:location [=|~|~*|^~] uri { ... }修饰符不同,匹配行为完全不同:| 修饰符 | 匹配方式 | 匹配后是否继续搜索 ||--------|---------|-------------------|| = | 精确匹配 | 否,立即停止 || ^~ | 前缀匹配 | 否,跳过正则检查 || ~ | 正则匹配(区分大小写) | 否,按配置顺序首个命中即停止 || ~* | 正则匹配(不区分大小写) | 否,按配置顺序首个命中即停止 || 无 | 前缀匹配 | 是,继续检查正则 |精确匹配(=)只有请求 URI 与指定路径完全一致时才命中,一旦匹配立即停止搜索,性能最优:location = / { # 仅匹配 /,不匹配 /index.html}前缀匹配(无修饰符)按 URI 前缀匹配,匹配成功后不会立即使用,而是先记住这个最长前缀匹配,继续检查正则表达式。如果正则没有命中,才会回退使用这个前缀匹配:location /docs/ { # 匹配以 /docs/ 开头的所有 URI # 但如果有正则也命中了,正则优先}正则匹配(~ 和 ~*)~:区分大小写~*:不区分大小写正则匹配按配置文件中出现的顺序依次检查,首个命中即停止:location ~ \.php$ { # 区分大小写,匹配 .php 结尾的请求}location ~* \.(jpg|png|gif|css|js)$ { # 不区分大小写,匹配常见静态资源}此外还有 !~ 和 !~*,表示正则不匹配,但它们不能用于 location 指令,只能用在 if 条件判断中。前缀匹配(^~)行为与无修饰符的前缀匹配类似,但关键区别是:如果 ^~ 前缀匹配成功,会跳过后续所有正则检查,直接使用该 location。这在对性能敏感的场景下非常有用:location ^~ /static/ { # 匹配 /static/ 开头的请求 # 即使有正则也匹配,也不检查,直接用这个}Nginx 完整匹配算法面试中光背优先级顺序不够,必须理解 Nginx 的完整匹配流程:Nginx 首先检查所有前缀匹配(包括 =、^~ 和无修饰符),找到最长前缀匹配如果最长前缀是精确匹配(=),立即使用,匹配结束如果最长前缀是 ^~ 匹配,立即使用,匹配结束按配置文件顺序依次检查所有正则表达式(~ 和 ~*)如果正则命中,使用该正则 location,匹配结束如果所有正则都未命中,回退使用步骤 1 中找到的最长前缀匹配这个流程说明一个关键点:正则匹配的优先级高于普通前缀匹配,但低于 = 和 ^~。优先级总结(从高到低)精确匹配 = — 最高优先级,匹配即停前缀匹配 ^~ — 匹配后跳过正则检查正则匹配 ~ / ~* — 按配置顺序,先到先得普通前缀匹配 — 优先级最低,作为兜底注意:正则匹配之间没有优先级之分,完全取决于配置文件中的书写顺序,写在前面的先匹配。配置示例与匹配结果server { listen 80; server_name example.com; location = / { return 200 "1: exact /"; } location / { return 200 "5: prefix /"; } location ^~ /images/ { return 200 "2: ^~ /images/"; } location ~ \.php$ { return 200 "3: regex .php"; } location ~* \.(jpg|png|gif)$ { return 200 "4: regex image"; } location /docs/ { return 200 "6: prefix /docs/"; }}匹配结果验证:| 请求 URI | 命中 location | 原因 ||----------|--------------|------|| / | = / | 精确匹配,优先级最高 || /images/logo.jpg | ^~ /images/ | ^~ 命中后跳过正则 || /api/test.php | ~ \.php$ | 前缀匹配 / 记住后,正则命中 || /photo.JPG | ~* \.(jpg\|png\|gif)$ | 不区分大小写正则命中 || /docs/readme.html | /docs/ | 最长前缀 /docs/ 优先于 /,且无正则命中 || /about | / | 仅前缀 / 命中,无正则匹配 |常见面试陷阱陷阱一:前缀匹配的长度优先多个普通前缀同时匹配时,Nginx 选择最长前缀,而非配置顺序:location /api/ { ... } # 前缀长度 5location /api/v1/ { ... } # 前缀长度 9,优先请求 /api/v1/users 会匹配 /api/v1/,因为前缀更长。陷阱二:正则覆盖普通前缀location /images/ { ... }location ~* \.(jpg|png)$ { ... }请求 /images/logo.jpg 会命中正则 ~*,而非前缀 /images/,因为正则优先级高于普通前缀。如果希望 /images/ 下的请求不被正则抢走,必须使用 ^~。陷阱三:= 只匹配精确路径location = /api { ... }它只匹配 /api,不匹配 /api/、/api/v1。如果需要同时匹配,应该用前缀匹配。实际配置建议高频路径用精确匹配 =,性能最优且语义清晰静态资源目录用 ^~,避免被正则拦截正则匹配控制数量,按命中频率从高到低排列避免在正则中使用复杂回溯表达式,防止 ReDoS 攻击通用兜底 location / 放在最后# 精确匹配首页location = / { proxy_pass http://frontend;}# 静态资源,跳过正则检查location ^~ /static/ { alias /var/www/static/; expires 30d;}# PHP 请求转发location ~ \.php$ { fastcgi_pass unix:/run/php/php-fpm.sock; fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; include fastcgi_params;}# 管理后台,跳过正则检查location ^~ /admin/ { auth_basic "Restricted"; auth_basic_user_file /etc/nginx/.htpasswd; proxy_pass http://admin_backend;}# API 代理location /api/ { proxy_pass http://api_backend;}# 兜底location / { try_files $uri $uri/ /index.html;}追问:location 嵌套怎么用?location 支持嵌套,但只有普通前缀匹配可以嵌套在普通前缀内,正则和精确匹配不能嵌套:location /api/ { proxy_pass http://backend; location /api/internal/ { # 更具体的路径,覆盖外层配置 deny all; }}内层 location 会完全覆盖外层的处理逻辑,而不是继承。如果内层也需要 proxy_pass,必须显式重新声明。追问:location 和 rewrite 的执行顺序?Nginx 处理请求时,rewrite 阶段在 location 匹配之前执行(server 级别的 rewrite)。如果 rewrite 修改了 URI,location 会基于修改后的 URI 重新匹配。但 location 内部的 rewrite 可能触发重新匹配,需要注意避免循环重写。
服务端阅读 05月28日 07:26

Nginx 如何实现缓存?缓存策略怎么配才能防击穿?

Nginx 如何实现缓存?如何配置缓存策略?Nginx 的缓存能力是后端服务性能优化的关键手段。面试中常从"Nginx 有哪几种缓存""proxycache 和 fastcgicache 怎么选""如何防止缓存击穿"这几个角度考察,理解原理比背配置更重要。Nginx 缓存的三大层次Nginx 缓存并不是单一机制,而是分布在请求链路的不同位置:浏览器缓存:通过响应头(Cache-Control、Expires)让客户端自行缓存,Nginx 只负责下发头信息代理缓存(proxy_cache):Nginx 作为反向代理时,缓存后端上游的响应,适用于反向代理场景FastCGI 缓存(fastcgi_cache):缓存 FastCGI 协议上游(如 PHP-FPM)的响应,适用于 PHP 直连场景面试时先说清楚这三层,再深入其中一层的配置细节,逻辑比直接贴配置更清晰。代理缓存配置详解http { proxy_cache_path /var/cache/nginx/proxy levels=1:2 keys_zone=proxy_cache:10m max_size=1g inactive=60m use_temp_path=off; server { listen 80; server_name example.com; location / { proxy_cache proxy_cache; proxy_cache_valid 200 302 10m; proxy_cache_valid 404 1m; proxy_cache_key "$scheme$request_method$host$request_uri"; proxy_cache_bypass $http_cache_control; add_header X-Cache-Status $upstream_cache_status; proxy_pass http://backend; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } }}核心参数解读| 参数 | 作用 | 注意点 ||---|---|---|| levels=1:2 | 缓存目录分两级,避免单目录文件过多 | 值越大层级越深,1:2 是常用配置 || keys_zone=proxy_cache:10m | 共享内存区域,存储缓存键和元数据 | 10m 约可存 8 万条键,按需调大 || max_size=1g | 缓存磁盘上限 | 超出后 Nginx 自动淘汰最久未访问的缓存 || inactive=60m | 60 分钟无访问则淘汰 | 与 proxy_cache_valid 无关,是另一条淘汰链 || use_temp_path=off | 临时文件写入缓存目录而非系统临时目录 | 减少跨磁盘拷贝,生产环境建议开启 || proxy_cache_valid 200 302 10m | 200/302 响应缓存 10 分钟 | 必须显式配置,否则不缓存 || proxy_cache_key | 缓存键的计算方式 | 默认含 scheme + method + host + uri,带参请求需考虑是否加入 $args |FastCGI 缓存配置FastCGI 缓存适用于 Nginx 直连 PHP-FPM 的场景,参数与 proxy_cache 对称:http { fastcgi_cache_path /var/cache/nginx/fastcgi levels=1:2 keys_zone=fastcgi_cache:10m max_size=1g inactive=60m; server { location ~ \.php$ { fastcgi_cache fastcgi_cache; fastcgi_cache_valid 200 60m; fastcgi_cache_methods GET HEAD; fastcgi_cache_bypass $skip_cache; fastcgi_no_cache $skip_cache; add_header X-Cache-Status $upstream_cache_status; fastcgi_pass unix:/var/run/php/php8.0-fpm.sock; include fastcgi_params; } }}proxycache 与 fastcgicache 怎么选?| 对比维度 | proxycache | fastcgicache ||---|---|---|| 适用场景 | 反向代理后端(任何 HTTP 服务) | 直连 PHP-FPM 等 FastCGI 进程 || 协议 | HTTP | FastCGI || 灵活性 | 更通用,后端不限语言 | 仅限 FastCGI 协议 || 生产推荐 | 微服务、多语言后端 | 纯 PHP 架构 |两者不能同时作用于同一 location。如果用了 proxy_pass 就用 proxycache,用了 fastcgi_pass 就用 fastcgicache。缓存风暴与缓存锁定这是面试高频追问点。当缓存过期瞬间,大量并发请求同时穿透到后端,这就是缓存风暴(Cache Stampede)。# 缓存锁定:只放一个请求去后端取数据,其余等待proxy_cache_lock on;proxy_cache_lock_timeout 5s;# 过期缓存兜底:后端异常时返回旧缓存proxy_cache_use_stale error timeout updating http_500 http_502 http_503 http_504;# 后台异步更新:命中过期缓存时异步刷新,不阻塞请求proxy_cache_background_update on;三者配合的执行逻辑:缓存过期 → proxy_cache_lock 放行一个请求 → 其余请求读 proxy_cache_use_stale 返回旧数据 → 新数据写入后所有请求命中。这就是完整的防击穿方案。动态缓存控制不是所有请求都该缓存。用 map 指令按条件跳过缓存:# 按 URI 跳过缓存map $request_uri $skip_cache { default 0; ~*/admin/ 1; ~*/api/ 1; ~*/user/ 1;}# 按后端响应头跳过缓存map $upstream_http_cache_control $skip_cache_by_header { ~*no-cache 1; ~*private 1; default 0;}# 组合条件map $skip_cache$skip_cache_by_header $combined_skip { default 0; ~1 1;}然后在 location 中使用:proxy_cache_bypass $combined_skip;proxy_no_cache $combined_skip;proxy_cache_bypass 和 proxy_no_cache 的区别:bypass 是跳过缓存直接请求后端但可能将响应写入缓存;no_cache 则完全不写缓存。生产环境通常两者配合使用,确保该跳过的请求既不读缓存也不写缓存。静态文件与浏览器缓存静态资源的缓存走另一套逻辑,不经过 proxy_cache,直接由 Nginx 返回文件并设置浏览器缓存头:location ~* \.(css|js|jpg|jpeg|png|gif|ico|svg|woff|woff2|ttf|eot)$ { expires 1y; add_header Cache-Control "public, immutable"; access_log off;}immutable 告诉浏览器:资源不会变,不需要发条件请求验证。搭配文件名加 hash(如 app.3a7b2c.js)效果最佳,更新时换文件名即可让浏览器重新请求。缓存清除方案Nginx 开源版不支持主动清除缓存,三种替代方案:自然过期:通过 proxy_cache_valid 设定 TTL,到期自动淘汰第三方模块 ngxcachepurge:支持按 URL 主动清除,需编译安装删除缓存文件:根据 proxy_cache_key 的 MD5 值定位文件路径并删除,rm -rf /var/cache/nginx/proxy 可全量清除生产环境中,最稳妥的方式是修改 proxy_cache_key 加入版本号参数,发布时更新版本号让旧缓存自然失效。缓存命中率监控通过 X-Cache-Status 响应头可观察缓存命中情况:add_header X-Cache-Status $upstream_cache_status;状态值含义:| 状态 | 含义 ||---|---|| MISS | 未命中,请求穿透到后端 || BYPASS | 命中跳过条件,不走缓存 || EXPIRED | 缓存已过期,需重新获取 || STALE | 后端异常,返回过期缓存 || UPDATING | 缓存正在更新,返回旧内容 || HIT | 命中缓存,直接返回 |监控思路:统计 HIT / (HIT + MISS + EXPIRED) 的比值,低于 80% 就需要调优缓存键或 TTL。缓存配置的常见踩坑1. 后端响应头导致缓存不生效后端返回 Cache-Control: no-cache 或 Set-Cookie 时,Nginx 默认不缓存。需要忽略这些头:proxy_ignore_headers X-Accel-Expires Expires Cache-Control Set-Cookie;2. 缓存键缺少关键参数默认 proxy_cache_key 不含 $args,但 API 请求 ?page=1 和 ?page=2 应该返回不同内容,需要加入查询参数:proxy_cache_key "$scheme$request_method$host$request_uri$is_args$args";3. 缓存最小请求次数导致首次不缓存proxy_cache_min_uses 2 表示请求出现 2 次才缓存,低流量接口可能永远不缓存。生产环境建议设为 1。4. keys_zone 过小导致缓存元数据丢失keys_zone 只存键和元数据,不存响应体。10m 约存 8 万条键,key 较长时需适当增大。生产环境完整配置参考http { proxy_cache_path /var/cache/nginx/proxy levels=1:2 keys_zone=proxy_cache:100m max_size=10g inactive=60m use_temp_path=off; fastcgi_cache_path /var/cache/nginx/fastcgi levels=1:2 keys_zone=fastcgi_cache:100m max_size=10g inactive=60m; map $request_uri $skip_cache { default 0; ~*/admin/ 1; ~*/api/ 1; ~*/user/ 1; } server { listen 80; server_name example.com; location ~* \.(css|js|jpg|jpeg|png|gif|ico|svg|woff|woff2)$ { expires 1y; add_header Cache-Control "public, immutable"; } location / { proxy_cache proxy_cache; proxy_cache_valid 200 302 10m; proxy_cache_valid 404 1m; proxy_cache_key "$scheme$request_method$host$request_uri"; proxy_cache_bypass $skip_cache; proxy_no_cache $skip_cache; proxy_cache_lock on; proxy_cache_lock_timeout 5s; proxy_cache_use_stale error timeout updating http_500 http_502 http_503 http_504; proxy_cache_background_update on; add_header X-Cache-Status $upstream_cache_status; proxy_pass http://backend; } location ~ \.php$ { fastcgi_cache fastcgi_cache; fastcgi_cache_valid 200 60m; fastcgi_cache_bypass $skip_cache; fastcgi_no_cache $skip_cache; add_header X-Cache-Status $upstream_cache_status; fastcgi_pass unix:/var/run/php/php8.0-fpm.sock; } }}
前端阅读 05月28日 07:26

Prettier 命令行工具有哪些常用命令和选项?

Prettier 命令行工具有哪些常用命令和选项?Prettier 的命令行工具是日常开发中格式化代码的核心手段,掌握常用命令和关键选项不仅能提升开发效率,也是前端工程化面试中的高频考点。核心命令格式化文件:--write--write 是最常用的选项,直接修改文件为格式化后的内容:# 格式化单个文件npx prettier --write src/index.ts# 格式化整个项目npx prettier --write .面试追问:--write 会先写入临时文件再原子替换原文件,避免写入中断导致文件损坏。检查格式:--check--check 只检查文件是否符合 Prettier 格式,不修改文件。文件不合规时退出码为 1,因此广泛用于 CI 流水线:npx prettier --check "src/**/*.{js,ts}"# 在 CI 中使用npx prettier --check . || echo "存在未格式化的文件"列出差异文件:--list-different--list-different(简写 -l)只输出格式不一致的文件路径,不输出格式化内容,适合脚本处理:npx prettier --list-different "src/**/*.js"与 --check 的区别:--check 会输出详细的人类可读信息,--list-different 只输出文件路径,更便于后续管道处理。查看差异:--diff--diff 输出格式化前后的 diff 对比,方便在不修改文件的前提下预览变更:npx prettier --diff src/app.ts配置与忽略指定配置文件:--config默认 Prettier 会沿目录向上查找 .prettierrc 等配置文件,使用 --config 可指定自定义配置:npx prettier --config .prettierrc.staging.json --write src/查找配置路径:--find-config-path输出给定文件实际使用的配置文件路径,用于排查配置生效问题:npx prettier --find-config-path src/index.ts# 输出: /project/.prettierrc忽略文件:--ignore-path默认使用 .prettierignore,可通过 --ignore-path 指定自定义忽略文件:npx prettier --ignore-path .gitignore --write .将 .gitignore 复用为忽略规则是一个实用技巧。忽略未知文件类型:--ignore-unknown格式化整个项目时,遇到 Prettier 不支持的文件类型默认会报错,加上此选项会自动跳过:npx prettier --write --ignore-unknown .缓存与性能启用缓存:--cache大型项目格式化耗时较长,--cache 通过缓存未变更文件的格式化结果显著提升速度:npx prettier --write --cache "src/**/*.ts"缓存位置:--cache-location指定缓存文件的存储路径:npx prettier --write --cache --cache-location .prettiercache src/缓存策略:--cache-strategy支持两种策略:metadata(默认):根据文件修改时间判断,速度快但不够精确content:根据文件内容哈希判断,更精确但稍慢npx prettier --write --cache --cache-strategy content src/输出控制输出到标准输出不加 --write 时,Prettier 将格式化结果输出到 stdout,不修改原文件:npx prettier src/index.ts指定输出目录:--out-dir将格式化结果写入指定目录而非原文件,适合生成格式化副本:npx prettier "src/**/*.js" --out-dir formatted/标准输入:--stdin-filepath从标准输入读取代码时,Prettier 无法判断文件类型,通过此选项指定虚拟路径:echo "const x=1" | npx prettier --stdin-filepath index.ts这在编辑器集成和管道场景中非常关键。与工程化工具集成在 package.json 中配置脚本{ "scripts": { "format": "prettier --write "src/**/*.{js,ts,json,css,md}"", "format:check": "prettier --check "src/**/*.{js,ts,json,css,md}"", "format:all": "prettier --write --ignore-unknown ." }}配合 lint-staged 只格式化暂存文件{ "lint-staged": { "*.{js,ts,css,md}": "prettier --write" }}这样配合 husky 的 pre-commit 钩子,每次提交只格式化本次变更的文件,避免全量格式化带来的提交噪音。在 CI 中强制格式检查- name: Check formatting run: npx prettier --check .--check 在文件不合规时返回退出码 1,CI 流水线会因此失败,确保仓库中不会混入未格式化的代码。调试命令调试检查:--debug-check格式化文件并检查格式化是否改变了 AST,用于排查 Prettier 自身的 bug。不能与 --write 同时使用:npx prettier --debug-check src/index.ts查看帮助与版本npx prettier --helpnpx prettier --versionPrettier 命令行工具在日常开发中主要用于格式化和检查,在工程化体系中则通过 --check 与 CI 集成、通过 --list-different 与 lint-staged 配合,理解每个命令的应用场景比记住参数更重要。