跳到主要内容

SWAI 面试高频问题与回答思路

按面试场景组织,从"项目介绍"到"技术深挖"到"系统设计"逐步递进。每个问题附回答思路和可展开方向。

使用建议

一、项目介绍类(必问)

Q1:简单介绍一下这个项目?

回答框架:一句话定位 → 核心功能 → 商业模式 → 你的角色

SWAI 是一款面向海外用户的 AI 换脸应用,提供模板换脸(选模板 + 上传照片 → AI 生成)和自定义换脸两种核心功能,采用积分制免费增值 + VIP 订阅的变现模式。

我独立负责前端管理后台、两个 NestJS 后端服务(业务服务 + 事件埋点服务)的开发,以及主站的技术选型与基础设施搭建。

可展开方向

  • 支持 13 种语言的国际化
  • 日活跃用户规模、内容生成量级
  • Monorepo 架构(Turborepo + pnpm workspaces)

Q2:你在这个项目里具体做了什么?你负责的模块有哪些?

分四个部分回答

模块职责一句话亮点
Admin 管理后台8 个功能模块:数据看板、素材管理(4类)、女神内容生产、反馈审核、评分运营1100 行的女神管理页实现了完整的 AI 内容生产工作流
Event Server全端事件采集 + 25 项 BI 指标计算 + 数据看板 API600 条/批的内存缓冲写入 + 并发查询优化
Nest Server认证鉴权、评分审核、反馈工单、素材库、配置管理、定时任务、AI 对话三层认证体系 + 分布式锁定时任务 + 跨服务积分发放
SWAI Site技术选型 + 基础设施搭建(i18n/主题/组件库/API 层/DevOps)Next.js 16 + React 19 + 13 语言 + Docker 多阶段构建

Q3:项目的整体架构是什么样的?

追问准备
  • "为什么要同时有 NestJS 和 Java?" → Java 团队负责核心 AI 推理和用户/交易系统,Node 层做 BFF 处理前端定制化需求、新业务快速迭代
  • "Monorepo 怎么组织的?" → Turborepo + pnpm workspaces,apps/ 放应用,packages/ 放公共包(constant/request/function/codegen 等)

二、后端技术深挖类

Q4:说一下你的事件埋点系统是怎么设计的?

回答结构:采集 → 缓冲 → 写入 → 分析

客户端批量上报事件到 EventOpenController,事件进入内存缓冲池。缓冲池采用双触发机制——数量达到 600 条或定时器 3 秒到期,批量 INSERT 到 PostgreSQL。API 响应不等待入库,异步非阻塞。写入失败的事件回写缓冲区,超过 10000 条上限才丢弃。

必问追问与回答

"为什么是 600 条一批?"

PostgreSQL 单次 INSERT 的最佳批量在 500-1000 之间——太小 IO 开销大(每次 INSERT 都有网络往返 + WAL 写入),太大事务锁定时间长影响并发查询。600 是实测的平衡点。

"为什么用内存缓冲而不是消息队列?"

成本和复杂度考虑。引入 Kafka/RabbitMQ 会增加运维成本和系统复杂度。当前日事件量级在百万级别,单机内存缓冲足够。如果量级增长到千万级,会考虑引入消息队列做削峰。

"服务崩溃了缓冲区数据怎么办?"

设计取舍

会丢失。这是 AP 系统(可用性优先)的设计取舍——埋点数据允许少量丢失,换来的是极低的写入延迟。如果需要严格不丢失,可以改为先写 WAL 文件再异步入库,或者引入消息队列持久化。

"IP 地理位置查询怎么处理的?"

只有缓冲区积压量低于 45 条时才查询 ip-api.com(它的免费版限制 45 req/s)。高压力时跳过 IP 解析,优先保证写入吞吐。本质是自适应降级策略——系统忙时主动放弃非核心功能。


Q5:25 个 BI 指标怎么计算的?性能怎么样?

25 项指标分属用户(4)、内容生成(6)、付费(10)、留存(5)四大类。每个指标对应一条独立 SQL 查询,通过 Promise.all 并发执行 20+ 条查询。

"并发查询的效果?"

串行执行约 4000ms(20 × 200ms),并发后约 200-300ms(取决于最慢的那条查询)。提速 10-20 倍

"有哪个指标的 SQL 最复杂?"

"充值新用户数"——需要识别当日注册且当日付费的用户。用了相关子查询:外层查充值事件,内层子查询按同一天匹配注册事件,子查询引用外层的 event_time 做日期对齐。这是 Correlated Subquery,性能上每条外层记录都要执行一次子查询。

"这个 SQL 性能有什么问题?怎么优化?"

相关子查询是 O(M×N)O(M \times N) 复杂度。可以改用 CTE + JOIN:先用 CTE 算出每天的注册用户集合,再 JOIN 充值事件表,降为 O(M+N)O(M+N)。或者建一张物化视图 daily_new_users,定时刷新。

"留存率怎么算的?"

当前是逐日循环查询:对每一天查注册用户集合 → 查这些用户次日/7日是否活跃。30天数据需要 60 次 DB 查询。可以优化为一条窗口函数 SQL,LEFT JOIN 注册表和活跃表,用 CASE WHEN 判断留存,一次查出。


Q6:时区问题是怎么处理的?

所有 BI 查询统一使用 UTC+8。核心是三层对齐

  1. 输入层:TypeScript 中用 timestamp + 28800000 偏移后 setUTCHours(0,0,0,0) 再减回偏移量,对齐到 UTC+8 的零点
  2. SQL 层DATE_TRUNC('day', TO_TIMESTAMP(event_time/1000) AT TIME ZONE 'UTC' AT TIME ZONE '+08:00')
  3. 输出层:数据库返回的时间戳减去偏移量还原为绝对时间

"为什么不用 dayjs.tz() 或 moment-timezone?"

容器时区陷阱

容器化部署中服务器时区不可控——可能是 UTC,也可能是宿主机时区。setHours() 这类方法使用运行时本地时区,行为不可预测。固定偏移量 28800000ms 是纯数学运算,零依赖、零歧义、零时区库开销。


Q7:认证鉴权是怎么做的?

三层守卫链:

  1. AuthAppGuard:验证 x-app 请求头,确认来源应用
  2. AuthUserGuard / AuthAdminGuard:调用外部 Java 用户服务验证 Token,结果缓存 60 秒到 Redis
  3. Permission Check:通过 @Auth({ permissionId: 'xxx' }) 装饰器注入权限标识,Guard 中匹配用户的 permissionList

"为什么自己不做认证,要调用 Java 服务?"

统一用户中心的设计——多个前端应用(Web、App、管理后台)和多个后端服务共享同一套用户体系。NestJS 作为 BFF 层不应该自建用户系统,而是作为 Java 认证服务的消费方。缓存 60 秒减少跨服务调用频率。

"如果 Java 认证服务挂了怎么办?"

当前设计中,缓存命中的请求不受影响(60 秒窗口)。缓存未命中的请求会失败。改进方向是加熔断器——连续失败 N 次后进入半开状态,定期探测恢复,避免大量请求打到已挂的服务上。


Q8:分布式锁是怎么实现的?用在了什么场景?

用在定时任务场景。K8s 部署多个 Pod 副本,每个 Pod 都有 @Cron('0 0 * * * *') 每小时执行,但只应执行一次。通过 Redis SET key NX EX 300 实现互斥——NX 保证原子性,EX 300 表示 5 分钟后自动释放防死锁。

分布式锁核心逻辑
const acquired = await redis.set(lockKey, podId, 'NX', 'EX', 300);
if (!acquired) return; // 其他 Pod 已获取锁

try {
await executeTask();
} finally {
await redis.del(lockKey); // 主动释放,TTL 仅作兜底
}

"为什么不用 Redlock?"

Redlock 适用于 Redis 集群环境,需要在多数节点上都获取锁。我们用的是单 Redis 实例,SET NX EX 已经是原子操作,足够可靠。引入 Redlock 是过度设计。

"任务执行超过 5 分钟会怎样?"

锁自动释放,另一个实例可能重复执行。解决方案:1) 延长 TTL 到覆盖最大执行时间;2) 使用 watchdog 机制(类似 Redisson 的 leaseRenewal),任务运行期间定期续期锁。

"不释放锁、只靠过期,有什么问题?"

问题是锁持有时间不精确——任务 10 秒完成但锁持有 5 分钟。这段"空锁期"内如果有突发任务需要执行会被误拒。改进:任务完成后主动 DEL key 释放锁,TTL 仅作兜底。


Q9:素材库的树形结构怎么实现的?

使用 TypeORM 的 @Tree('materialized-path') 策略。每条记录有一个 mpath 字段存储从根到当前节点的路径,如 1.2.3.。查找后代用 mpath LIKE '1.2.%',查找祖先可以解析路径。

"除了 Materialized Path,还有什么方案?各自优劣?"

方案读性能写性能移动节点适用场景
Adjacency List (parent_id)差(递归查询)好(改一条记录)层级浅、写多读少
Materialized Path (mpath)好(LIKE 查询)差(改所有后代路径)层级中等、读多写少
Closure Table (ancestor-descendant 关系表)好(JOIN 查询)中(维护关系表)复杂查询、需要层级距离
Nested Sets (left-right 值)好(范围查询)差(插入需重算所有值)静态树、极少修改

选择 Materialized Path 因为:素材库是"读多写少"场景(浏览远多于上传),且 TypeORM 原生支持。

"级联删除怎么做的?"

对每个待删除节点调用 findDescendants()(内部是 mpath LIKE '节点path%' 查询),收集所有后代节点,批量设置 deletedAtdeletedBy 实现软删除。


三、前端技术深挖类

Q10:数据看板的图表怎么做的?有什么技术难点?

@ant-design/plots 的 DualAxes 双轴图。难点在于同一图表要同时展示数量(柱状图,万级)转化率(折线图,百分比),两者数据量级差异百倍。

关键配置

两个 children 共享 xField(时间),但使用独立的 yField 和 scale。折线图设置 axis.y.position: 'right' 放右侧 Y 轴,并设置 scale.series.independent: true 让两个系列的数值范围独立计算。

"Excel 导出怎么做的?"

指标格式化映射
const formatMap: Record<string, (v: number) => string> = {
count: (v) => v.toLocaleString(), // 千分位: 12,345
duration: (v) => `${Math.floor(v/60000)}m ${Math.floor(v%60000/1000)}s`, // 时长
rate: (v) => `${(v * 100).toFixed(2)}%`, // 百分比
amount: (v) => `$${v.toFixed(2)}`, // 金额
};
const formatted = formatMap[metricKey](value);

xlsx 库。22 个指标需要不同格式化。导出时用 formatMap[metricKey](value) 动态选择格式化函数。文件名包含日期范围用于区分。


Q11:女神管理页为什么这么复杂?你怎么处理的状态管理?

因为它实现了一个完整的 AI 内容生产工作流

状态管理用了 16 个 useState。坦诚说这是一个 技术债——如果重新做会用 useReducer 把状态收敛到一个对象中,用 dispatch action 管理转换,避免"关闭抽屉时忘记重置某个状态"的问题。

"图片比例验证是怎么做的?"

用 FileReader 读取图片 → new Image() 加载获取宽高 → 计算 width/height 是否在 3/4 ± 2% 范围内。这是客户端预验证,后端也有校验。

"16 个 useState 的问题在哪里?你会怎么重构?"

  1. 状态间有隐式依赖:关闭任务抽屉需要同时重置 selectedMaterialsselectedImageactiveMaterialType
  2. 容易遗漏:新增一个状态时可能忘记在某个关闭逻辑中重置它
  3. 重构为 useReducer:定义清晰的 Action 类型(如 CLOSE_ALL_DRAWERS 一次性重置所有关联状态),状态转换逻辑集中可审查
useReducer 重构示例
type Action =
| { type: 'OPEN_TASK_DRAWER'; goddess: Goddess }
| { type: 'CLOSE_ALL_DRAWERS' }
| { type: 'SELECT_MATERIALS'; ids: string[] };

function reducer(state: State, action: Action): State {
switch (action.type) {
case 'CLOSE_ALL_DRAWERS':
return {
...state,
taskDrawerOpen: false,
materialDrawerOpen: false,
selectedMaterials: [],
selectedImage: null,
activeMaterialType: 'video',
};
// ...
}
}

Q12:四个素材管理页面代码有重复吗?怎么处理的?

有重复,这是另一个技术债。四个页面共享相同的 CRUD 逻辑和 UI 结构(ProTable + ModalForm + OSS 上传),只是 materialType 枚举值和 Tab 配置不同。

"你会怎么优化?"

抽象为配置驱动的通用 MaterialPage 组件。每个页面只需传入一个配置对象(tabs 定义、是否显示跳转类型等)。代码量从约 2800 行降到 400 行。好处是 Bug 修复一处生效四处、新增素材类型只需加配置。

配置驱动方案示例
interface MaterialPageConfig {
materialType: MaterialType;
tabs: { key: string; label: string; subType: string }[];
showJumpType?: boolean;
}

// 每个页面只需一行配置
const videoConfig: MaterialPageConfig = {
materialType: MaterialType.VIDEO,
tabs: [
{ key: 'template', label: '模板视频', subType: 'template' },
{ key: 'result', label: '生成结果', subType: 'result' },
],
showJumpType: true,
};

Q13:SWAI Site 的国际化方案是怎么设计的?

基于 next-intl + App Router 的方案。核心设计:

  • 路由策略localePrefix: 'as-needed',英文(默认语言)无 URL 前缀,其他 12 种语言有前缀如 /zh/about
  • 语言检测:Middleware 四级优先级——URL path → Cookie → Accept-Language → 默认英文
  • 双端翻译:Server Components 用 await getTranslations(),Client Components 用 useTranslations()
  • 构建时预生成generateStaticParams() 为 13 种语言生成静态路由变体

"为什么默认语言不加前缀?"

SEO 和用户体验。英文是全球默认语言,/about/en/about 更简洁、对搜索引擎更友好。其他语言用户通过 Accept-Language 头自动检测跳转。

"翻译文件怎么管理的?"

按语言存放在 i18n/messages/ 目录下,每种语言一个 JSON 文件。如果项目变大,可以按 namespace 拆分(首页、设置页、错误页各一个文件),配合 next-intl 的按需加载减小首屏 bundle。


Q14:说一下 Orval 代码生成的方案?

从 4 个 Java 微服务的 Swagger 文档自动生成 TypeScript API 客户端。每个微服务一个 orval 配置文件,执行 pnpm openapi 一条命令更新所有 API。

生成的函数只负责参数组装和类型定义,实际请求通过 Custom Mutator 统一处理——认证头注入、超时控制(AbortController)、三级错误分类(HttpError/BusinessError/TimeoutError)、Next.js ISR 缓存集成。

"后端改了接口前端怎么知道?"

重新执行 pnpm openapi 更新类型定义。如果后端删除了字段或改了类型,TypeScript 编译会直接报错——这就是类型安全的价值。

"跟手写 API 相比有什么优劣?"

Orval 自动生成手写 API
开发效率零手写,一键更新逐个编写,手动维护
类型安全后端变更编译时报错依赖人工同步
可读性一般(生成代码命名风格固定)可自定义命名和结构
学习成本需要理解 Orval 配置无额外学习成本

四、系统设计 & 架构类

Q15:如果让你重新设计这个埋点系统,你会怎么做?

当前方案(内存缓冲 + 批量写入 PostgreSQL)适合日百万级事件量。如果量级增长,我会分阶段演进:

关键改进
  • 引入 Kafka 做事件持久化,解决服务崩溃丢数据的问题
  • ClickHouse 替代 PostgreSQL 做分析查询,列式存储对聚合查询性能提升 10-100 倍
  • 物化视图预计算常用指标,避免每次查询实时聚合

Q16:积分系统的一致性你是怎么保证的?

当前设计:评分审核通过 → 调用 Java 交易 API systemTrade() 发放积分 → 更新本地评分状态。

已有保障

bizOrderId: rating.id 保证幂等——同一 rating 重复调用交易 API 不会重复发放。

存在的风险

systemTrade() 成功但 save() 失败 → 积分已发但本地状态未更新。

改进方案

方案原理优劣
本地事务表先标记 PENDING → 异步调用 API → 成功后更新 ISSUED → 失败则定时重试实现简单,推荐
TCC 模式Try(冻结)→ Confirm(发放)→ Cancel(解冻)一致性最强,但对交易 API 改动大
最终一致性定时对账任务比对本地状态和交易系统记录兜底方案,延迟较高

Q17:你的缓存策略是怎么设计的?

两级缓存架构

按场景区分 TTL

缓存场景TTL原因
认证缓存60s平衡安全性和性能
公开配置接口180s低频变更
BI 概览数据60s准实时即可
HTTP Cache-Control服务端 TTL / 2浏览器缓存更早失效,降低脏数据风险

"缓存击穿怎么办?"

热点 key 过期时大量请求打到 DB。解决:1) 互斥锁重建(Redis SETNX);2) 热点 key 永不过期 + 后台异步刷新。当前系统热点不明显,暂未做特殊处理。

"缓存和数据库数据不一致怎么办?"

BI 数据场景允许 60 秒的延迟,不是强一致性场景。如果需要更强的一致性,可以在写入新事件时主动 invalidate 相关缓存 tag。


Q18:你的可观测性体系是怎么建设的?

五级 SLS 日志

级别来源内容
ErrorAllExceptionsFilter 自动捕获完整堆栈、请求上下文、traceId
AccessResponseInterceptor 自动记录方法/路径/耗时/状态码
Business手动埋点审批操作、积分发放等关键节点
Debug按需添加开发调试信息
PerformancePerformanceTracker请求内各阶段耗时

TraceId 全链路追踪:TraceMiddleware 为每个请求生成 UUID → 注入到 response header → 所有日志携带 → 一条 traceId 串联完整调用链。

"如果某个接口突然变慢了,你怎么排查?"


五、工程实践 & 方法论类

Q19:Monorepo 用的什么方案?遇到什么问题?

Turborepo + pnpm workspaces。apps/ 放 6 个应用,packages/ 放共享包(constant、request、function、codegen 等)。

好处

共享 TypeScript 配置、ESLint 配置、组件和工具函数。改一个公共包立即在所有应用中生效。

遇到的问题

问题解决方案
packages/ 里有早期创建但未使用的包定期清理无引用的包
公共包版本不一致所有应用强制使用 workspace:*
Docker 构建上下文过大turbo prune 裁剪依赖树

Q20:你怎么保证代码质量?有什么规范?

维度实践
类型安全TypeScript strict mode,API 类型自动生成
统一响应格式所有接口返回 { code, data, msg, traceId, path, date }
统一错误处理自定义 CommonError + 20+ 业务错误码 + 全局 ExceptionFilter
性能埋点规范db_query_xxx / external_api_xxx / calculate_xxx 统一命名
软删除审计所有实体 deletedAt + deletedBy 可追溯

六、挑战 & 反思类

Q21:这个项目中你遇到的最大技术挑战是什么?

推荐选两个讲

挑战 1:BI 指标的时区对齐

一开始用 setHours() 在本地开发没问题,部署到容器后数据全部错位——因为容器时区是 UTC。排查了半天才发现问题,最终改为固定偏移量的纯 UTC 算术方案,从输入到 SQL 到输出三层统一对齐。教训是永远不要依赖运行时时区

挑战 2:女神管理页的复杂度控制

一个页面 1100+ 行,16 个 useState,3 层嵌套交互。开发到后期每加一个功能都怕破坏已有逻辑。意识到应该更早地做状态抽象(useReducer/自定义 Hook),而不是等复杂度膨胀后再重构。


Q22:如果让你重做一遍,你会做什么不同的选择?

方面当时的选择现在会改进的
素材管理4 个页面各自实现抽象为配置驱动的通用组件
女神页状态16 个 useStateuseReducer + Context 或 zustand
埋点缓冲纯内存,崩溃丢数据先写本地 WAL 文件再异步入库
留存查询逐日循环 60 次查询一条窗口函数 SQL 搞定
配置版本只存 current + last版本历史表支持任意回滚
AI API Key硬编码在代码中环境变量 + ConfigService
积分发放无事务保障本地事务表 + 幂等重试

七、高频快问快答

问题简答
PostgreSQL vs MySQL 怎么选的?埋点用 PG(JSONB + GIN 索引适合灵活属性过滤),业务用 MySQL(TypeORM Tree 生态更成熟)
为什么用 NestJS 不用 Express?模块化架构、装饰器 DI、Guard/Interceptor/Filter 管线天然支持中间件分层
为什么用 UmiJS 不用 Vite?Admin 是现有项目,UmiJS 的约定式路由和 ProComponents 适合后台管理场景
为什么 Site 用 Next.js?面向 C 端需要 SEO/SSR,App Router 是 React 官方推荐的全栈方案
为什么用 Shadcn/ui?源码可控不被第三方库绑架、Radix 无障碍基础、Tailwind 风格一致
Docker 镜像怎么优化的?Turbo Prune 裁剪 Monorepo + standalone 输出 + alpine 基础镜像,1.2GB → ~200MB
限流怎么做的?@nestjs/throttler,Event Server 200 req/s,Nest Server 60 req/60s
日志怎么做的?阿里云 SLS 五级日志 + traceId 全链路 + PerformanceTracker 分阶段耗时
如何部署的?Docker + K8s (Kustomize),base + overlay 多环境配置

相关链接