SWAI 面试高频问题与回答思路
按面试场景组织,从"项目介绍"到"技术深挖"到"系统设计"逐步递进。每个问题附回答思路和可展开方向。
本文档配合以下四篇技术总结阅读效果更佳:
一、项目介绍类(必问)
Q1:简单介绍一下这个项目?
回答框架:一句话定位 → 核心功能 → 商业模式 → 你的角色
SWAI 是一款面向海外用户的 AI 换脸应用,提供模板换脸(选模板 + 上传照片 → AI 生成)和自定义换脸两种核心功能,采用积分制免费增值 + VIP 订阅的变现模式。
我独立负责前端管理后台、两个 NestJS 后端服务(业务服务 + 事件埋点服务)的开发,以及主站的技术选型与基础设施搭建。
可展开方向:
- 支持 13 种语言的国际化
- 日活跃用户规模、内容生成量级
- Monorepo 架构(Turborepo + pnpm workspaces)
Q2:你在这个项目里具体做了什么?你负责的模块有哪些?
分四个部分回答:
| 模块 | 职责 | 一句话亮点 |
|---|---|---|
| Admin 管理后台 | 8 个功能模块:数据看板、素材管理(4类)、女神内容生产、反馈审核、评分运营 | 1100 行的女神管理页实现了完整的 AI 内容生产工作流 |
| Event Server | 全端事件采集 + 25 项 BI 指标计算 + 数据看板 API | 600 条/批的内存缓冲写入 + 并发查询优化 |
| 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 性能有什么问题?怎么优化?"
相关子查询是 复杂度。可以改用 CTE + JOIN:先用 CTE 算出每天的注册用户集合,再 JOIN 充值事件表,降为 。或者建一张物化视图
daily_new_users,定时刷新。
"留存率怎么算的?"
当前是逐日循环查询:对每一天查注册用户集合 → 查这些用户次日/7日是否活跃。30天数据需要 60 次 DB 查询。可以优化为一条窗口函数 SQL,LEFT JOIN 注册表和活跃表,用 CASE WHEN 判断留存,一次查出。
Q6:时区问题是怎么处理的?
所有 BI 查询统一使用 UTC+8。核心是三层对齐:
- 输入层:TypeScript 中用
timestamp + 28800000偏移后setUTCHours(0,0,0,0)再减回偏移量,对齐到 UTC+8 的零点 - SQL 层:
DATE_TRUNC('day', TO_TIMESTAMP(event_time/1000) AT TIME ZONE 'UTC' AT TIME ZONE '+08:00') - 输出层:数据库返回的时间戳减去偏移量还原为绝对时间
"为什么不用 dayjs.tz() 或 moment-timezone?"
容器化部署中服务器时区不可控——可能是 UTC,也可能是宿主机时区。setHours() 这类方法使用运行时本地时区,行为不可预测。固定偏移量 28800000ms 是纯数学运算,零依赖、零歧义、零时区库开销。
Q7:认证鉴权是怎么做的?
三层守卫链:
- AuthAppGuard:验证
x-app请求头,确认来源应用 - AuthUserGuard / AuthAdminGuard:调用外部 Java 用户服务验证 Token,结果缓存 60 秒到 Redis
- Permission Check:通过
@Auth({ permissionId: 'xxx' })装饰器注入权限标识,Guard 中匹配用户的permissionList
"为什么自己不做认证,要调用 Java 服务?"
统一用户中心的设计——多个前端应用(Web、App、管理后台)和多个后端服务共享同一套用户体系。NestJS 作为 BFF 层不应该自建用户系统,而是作为 Java 认证服务的消费方。缓存 60 秒减少跨服务调用频率。
"如果 Java 认证服务挂了怎么办?"
当前设计中,缓存命中的请求不受影响(60 秒窗口)。缓存未命中的请求会失败。改进方向是加熔断器——连续失败 N 次后进入半开状态,定期探测恢复,避免大量请求打到已挂的服务上。
Q8:分布式锁是怎么实现的?用在了什么场景?
用在定时任务场景。K8s 部署多个 Pod 副本,每个 Pod 都有
@Cron('0 0 * * * *')每小时执行,但只应执行一次。通过 RedisSET 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%'查询),收集所有后代节点,批量设置deletedAt和deletedBy实现软删除。
三、前端技术深挖类
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 的问题在哪里?你会怎么重构?"
- 状态间有隐式依赖:关闭任务抽屉需要同时重置
selectedMaterials、selectedImage、activeMaterialType - 容易遗漏:新增一个状态时可能忘记在某个关闭逻辑中重置它
- 重构为
useReducer:定义清晰的 Action 类型(如CLOSE_ALL_DRAWERS一次性重置所有关联状态),状态转换逻辑集中可审查
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 日志:
| 级别 | 来源 | 内容 |
|---|---|---|
| Error | AllExceptionsFilter 自动捕获 | 完整堆栈、请求上下文、traceId |
| Access | ResponseInterceptor 自动记录 | 方法/路径/耗时/状态码 |
| Business | 手动埋点 | 审批操作、积分发放等关键节点 |
| Debug | 按需添加 | 开发调试信息 |
| Performance | PerformanceTracker | 请求内各阶段耗时 |
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 个 useState | useReducer + 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 多环境配置 |