恒生电子 后端一面
- 轮次
- 一面
- 时间
- 2026-10
- 来源
- 牛客网
《面试题目》
- 自我介绍。
- 项目介绍,用了哪些技术栈?
- 重试和幂等的策略是怎么设计的?
- 怎么应对大批量的并发资源申请?
- QPS 是多少?为什么不用 Redis?
- Java 的 HashMap 底层实现是什么?它是线程安全的吗?
- String、StringBuilder、StringBuffer 的区别是什么?
- 优化 SQL 的几个角度有哪些?
- 分库分表的概念是什么,怎么实现?
- 反问。
《参考解析》
重试与幂等要成对设计,不能只做重试。重试的前提是「操作可安全重复」,所以先把不幂等的写操作改造成幂等:客户端生成全局唯一请求 id(业务幂等键),服务端用唯一索引落一张去重表或 INSERT ... ON DUPLICATE KEY UPDATE,命中重复直接返回首次结果。重试策略上要点有:① 只重试可重试的错误——网络超时、连接被拒、5xx、限流(429)可以重试,参数错误(400/422)和明确的业务失败重试没有意义;② 退避 + 抖动——指数退避(1s、2s、4s、8s…)加随机抖动,避免重试风暴同时打回来;③ 重试次数与总时间上限要有,超过就进补偿队列或转人工,别无限重试;④ 区分超时语义——调用超时不等于对方没执行,所以幂等键是必需品,否则重试会产生重复订单;⑤ 如果重试的是「调用第三方」,最好用异步 + 本地消息表而不是同步循环重试,避免把请求线程耗死。重试的观测也要有:记录每次重试的原因和最终结果,便于判断是对方不稳定还是我方逻辑有 bug。
大批量并发资源申请的核心是「把无序竞争改成有序分配」。分几层处理:① 资源池化 + 名额控制——把资源抽象成池(数据库连接、容器、配额),用信号量/令牌桶限制并发度,超出的请求排队而不是直接打到底层;② 原子性申请——用数据库的 UPDATE ... WHERE available > 0 影响行数判断,或 Redis 的 Lua 脚本保证「检查 + 扣减」原子,避免超卖;③ 按业务键分片串行——同一资源/同一用户的请求路由到同一队列串行处理(类似 actor 模型),既避免锁竞争又天然保证顺序,能显著减少死锁;④ 批量合并——把 N 个小申请合并成一次批量获取,降低单位成本;⑤ 超时与释放——申请的锁/配额必须带 TTL 和兜底释放(防止持有者崩溃导致资源永久占用),并做对账把泄漏的资源回收;⑥ 公平性——需要时用加权公平队列,避免大客户的批量请求把小请求饿死。答题时把「QPS 上限不是靠加机器解决,而是先把并发度控制住」说出来,顺便接上下一题的 QPS 计算。
**QPS 估算与「为什么不用 Redis」**要拿数字说话。QPS 的算法是「日均请求数 ÷ 有效时长 × 峰值系数」,例如日均 1000 万次、按 10 小时有效时长算平均约 278 QPS,峰值系数取 3–5 就是千级 QPS;再对比单机能力(一次简单 MySQL 主键查询几百微秒到几毫秒,单机能撑几千 QPS,但一旦有复杂 join 或写入就掉到几百),判断瓶颈在哪。至于「为什么不用 Redis」——很多时候这个问题是在探你是不是无脑上缓存,合理的回答是分情况:① 数据一致性和持久性要求高的(资金、订单状态)不适合把 Redis 当唯一存储,因为它异步持久化可能丢数据;② 访问频率低(数据量大但热点少)时缓存的收益不抵复杂度,反而引入一致性负担;③ 查询本身已经很快(走主键或覆盖索引、命中 Buffer Pool),加一层缓存的收益有限;④ 数据规模小,全量放内存不经济或没必要;⑤ 写多读少的场景缓存命中率低,还容易被写放大拖累。反面也要说清该用的场景:热点数据、计数器、分布式锁、会话、限流,这些是 Redis 的主场。能给出「用不用缓存要看命中率和一致性代价」这个判据,比单纯背缓存三大问题更有说服力。
HashMap 底层与线程安全:JDK 8 起是「数组 + 链表 + 红黑树」。put 时先算 hash(key)(内部是 h ^ (h >>> 16),让高位也参与运算,减少冲突),再 (n-1) & hash 定位桶;桶为空直接放,否则比较 hash 和 equals,相同则覆盖、不同则尾插链表(JDK 7 是头插,这也是 JDK 7 并发扩容成环的原因);链表长度 ≥ 8 且数组容量 ≥ 64 时转红黑树,退回到 6 时转回链表。扩容阈值是 容量 × 负载因子(0.75),扩容时容量翻倍,JDK 8 用「高位是否为 1」把节点拆成两条链,避免重新计算 hash。HashMap 不是线程安全的:并发 put 可能丢数据、size 不准、扩容期间读到不完整状态;多线程场景要用 ConcurrentHashMap(JDK 8 用 CAS 插入空桶 + synchronized 锁单个桶/头节点,锁粒度比 JDK 7 的分段锁更细)、Collections.synchronizedMap(全局锁,性能差)或外部加锁。常见追问:为什么负载因子是 0.75(空间与冲突概率的折中,泊松分布下 8 个节点的概率约千万分之六)、为什么容量建议是 2 的幂(使 (n-1) & hash 等价于取模且分布均匀)、为什么 key 建议用不可变对象(可变对象改字段后 hash 变化会导致查不到)。
String / StringBuilder / StringBuffer 的差别只有一条主线:可变性与线程安全。String 不可变(final 修饰的 char[]/byte[]),每次拼接都会新建对象,所以循环里用 + 拼接会产生大量临时对象(编译器只对「一行内的常量拼接」做优化成 StringBuilder,跨循环不会);StringBuilder 可变、非线程安全但最快,单线程拼接首选;StringBuffer 可变、方法带 synchronized,线程安全但多一层锁开销。实际开发里还有两个点值得提:String 不可变带来的好处是能安全地做常量池复用、作为 HashMap 的 key、天然线程安全;StringBuilder 建议预设容量(new StringBuilder(n))避免多次扩容拷贝;JDK 9 之后 String 内部改成 byte[] + coder 压缩存储,Latin-1 字符省一半内存。
SQL 优化按「先定位、再改」的顺序答,别一上来就说加索引。定位手段:用 EXPLAIN(或 EXPLAIN ANALYZE)看实现计划——重点看 type(ALL 全表扫描、index、range、ref、const 依次变好)、rows 估算行数、key 实际用的索引、Extra 里是否出现 Using filesort/Using temporary;看慢查询日志按 Query_time 和 Rows_examined 排序找 TOP 慢 SQL;有条件看 performance_schema 或 APM 的 SQL 耗时分布。优化角度:① 索引——WHERE/JOIN/ORDER BY 的列建联合索引并遵守最左前缀,区分度高的列放前面,避免在索引列上做函数/隐式类型转换导致失效;用覆盖索引消掉回表。② 减少扫描量——分页用延迟关联或游标(WHERE id > ?)替代大 OFFSET,按时间分区做归档,避免 SELECT *。③ 改写——把子查询改成 JOIN(或反之,视优化器版本)、OR 改 UNION ALL(能走索引时)、批量插入合并成一条、IN 列表别太长。④ 结构——合适的数据类型(能用 INT 别用 VARCHAR)、适当反范式和冗余字段、大字段(TEXT/BLOB)拆表。⑤ 架构——读写分离、缓存、分库分表、异步化。⑥ 数据库参数——Buffer Pool 大小、连接数、事务隔离级别(很多统计类查询降级到读已提交能减少间隙锁)。答题收尾要强调「优化的前提是可量化的基线,改完要对比 Rows_examined 和 P99 时延」。
分库分表要分清「为什么分」和「怎么分」。垂直拆分按业务或字段拆:订单库、用户库分开(减少单库压力、隔离故障域);把大字段或低频字段拆到扩展表。水平拆分把同一张表的数据按规则分散到多个库/表:range(按时间或 id 区间,便于归档,但热点集中在新数据)、hash(按用户 id 取模,分布均匀,但扩容要迁移数据)、一致性哈希/虚拟槽(扩容只迁移部分数据,Redis Cluster 和很多中间件用这个)。实现层面:用 ShardingSphere / MyCat 这类中间件,或应用层自己路由(更可控但侵入代码)。必须讲清的六个衍生问题:① 路由键的选择——决定了跨片查询的代价,没有分片键的查询要广播到所有分片再聚合;② 分布式主键——雪花算法、号段模式(Leaf)、数据库自增步长;③ 跨片 JOIN 与聚合——尽量把关联数据落到同一分片(如订单和订单明细都用 buyer_id),否则要内存归并或宽表化;④ 分布式事务——优先让单笔业务落在单库单表(本地事务),跨片用本地消息表/TCC/Seata;⑤ 扩容——双写迁移或一致性哈希,要有校验和对账工具;⑥ 全局唯一约束、分页排序、count(*) 的代价。最后一定要说「分库分表是最后手段」,前面该先做索引优化、读写分离、归档冷数据、加缓存——因为它带来的复杂度是长期的。