小红书 后端开发 一面(几乎全是场景题)
- 轮次
- 一面
- 时间
- 2026-09
- 来源
- 牛客网
《面试题目》
- 自我介绍。
- 请再具体介绍一下你在字节跳动实习期间主要做了哪些事情。
- 场景题:判断下面订单支付的代码有没有哪里写得不对。
public synchronized void settleOrder(int orderId) {
if (!paid(orderId)) {
deductBalance(orderId);
markPaid(orderId);
}
}
- 场景题:给一条 SQL 语句,看有什么问题以及怎么解决(经典的深度分页问题)。
- 场景题:review 订单场景的三个方法:
// 1. 统计用户每个订单的订单总金额,返回一个列表
// 2. 统计 VIP 用户每个订单的订单总金额,返回一个列表
// 3. 输入 user 对象,返回该用户的用户级别(根据年龄、购买总数等等属性值给出很多个用户级别并返回)
- 场景题:链路报警排查——线上监控显示某个下游服务的报警时,应该如何排查?
- 算法题:给你一个整数数组 nums,请找出数组中乘积最大的非空连续子数组,并返回该子数组所对应的乘积。
《参考解析》
synchronized settleOrder的问题在哪:至少三处。第一,锁的粒度和作用域不对——方法上的synchronized锁的是this,同一笔订单的并发请求如果落在不同实例或不同 service 对象上就完全锁不住,多实例部署下等于没加锁。要锁应该按 orderId 分段锁,或者干脆交给数据库和分布式锁。第二,「检查—执行」不是原子操作,paid(orderId)判断完到markPaid之间有空窗,两个线程可能都通过检查,于是重复扣款;正确做法是把状态判断和状态变更收进一条带条件的 SQL(update ... set status = 'PAID' where id = ? and status = 'UNPAID'),用影响行数决定谁赢。第三,顺序也值得说:先扣余额再标记已支付,如果中间抛异常而事务没兜住,钱扣了单没标记;反过来先标记再扣,失败则单已支付钱没扣。这题答完这三点,再补一句「还要有业务幂等键防重试重复扣」就是完整答案。- 深度分页为什么慢:
limit 100000, 10并不是只扫 10 行,而是要先取出前 100010 行再丢掉前 100000 行,回表成本全花在被丢掉的数据上。三种改法按适用面排:一是延迟关联,先用覆盖索引把主键查出来再 join 回原表,把回表次数压到只跟返回行数相关;二是游标分页,前端把上一页最后一条的 id 或排序键传回来,where id > ? order by id limit 10,这是最优解,代价是不能跳页;三是业务上限制可翻页深度,或把深翻页交给搜索引擎。要注意游标分页要求排序键唯一且稳定,用create_time这种会重复和变更的字段做游标会漏数据,得配合 id 做二级排序。 - 三个方法的 review 思路:先看重复——前两个方法只有「过滤 VIP」这一处差异,可以用模板方法或把条件抽成参数,避免一份逻辑改两遍漏一遍;再看查询次数——如果实现是「先查订单列表、再循环查每单金额」,那就是典型 N+1,要改成一条
group by order_id的聚合或批量in查询;接着看聚合放在哪——sum这类计算能下推到数据库就别捞到内存里循环加,数据量大时要考虑预计算和缓存;最后看返回结构,直接返回实体容易把内部字段暴露出去也容易让分页失效,要返回明确的 DTO。第三个方法是最典型的「一堆 if-else 堆等级」的坏味道,改法是抽规则表或策略模式——把「年龄区间、购买总数区间 → 等级」做成配置,加等级时只改配置不改代码。 - 下游服务报警的排查顺序:先把「谁的锅」定位清楚,顺序是从外到内。看入口指标:是流量真的涨了,还是耗时涨了、错误率涨了,还是只有超时变多。再看依赖:用调用链(trace)把这一次请求经过的下游逐个列出来,看是哪一跳开始变慢或报错,同时对比下游服务自己的指标——如果下游自身的 RT 和错误率都正常,那问题在调用方(连接池被打满、超时设置太短、线程池排队);如果下游自己也在抖,就看它的资源(CPU、GC、连接数、慢查询)和最近的变更。第三步是止血而不是找根因:能回滚就回滚最近一次发布,不能回滚就先限流、降级非核心链路、扩大连接池或超时。最后才是复盘,把「为什么监控没提前发现」也当成一个问题记下来。这题的加分点是主动提到「先看最近有没有发布和配置变更」,真实故障里这个命中率很高。
- 乘积最大子数组:和「最大子数组和」的差别在于负号会让最小值翻成最大值,所以只维护一个 max 是不够的。
public int maxProduct(int[] nums) {
int max = nums[0], min = nums[0], res = nums[0];
for (int i = 1; i < nums.length; i++) {
// 当前数为负时,max 和 min 的角色互换,所以先存下来
if (nums[i] < 0) {
int t = max;
max = min;
min = t;
}
max = Math.max(nums[i], max * nums[i]);
min = Math.min(nums[i], min * nums[i]);
res = Math.max(res, max);
}
return res;
}
状态定义是「以 i 结尾的子数组的最大 / 最小乘积」,两个都要留是因为最小值随时可能因为乘上一个负数变成最大值。转移就是「自己单独成段」和「接上前一个」二选一。时间 O(n)、空间 O(1),符合题目对 32 位整数结果的约束(中间乘积用 int 足够,因为题目保证答案在 int 范围,但工程上写成 long 更保险)。