尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

金山办公2020校招服务端笔试全解析:从TCP到系统设计

金山办公2020校招服务端笔试全解析:从TCP到系统设计 金山办公的校招笔试在业内一直有“范围广、基础深、贴近实战”的口碑。作为服务端开发岗它不像某些大厂那样纯刷 LeetCode 式筛人而是很看重你对计算机基础知识的掌握程度以及用工程化思维解决实际问题的能力。我手头正好整理过这套 2020 年的服务端开发笔试题一当时做完整个人最大的感受是不偏不怪但想拿高分光靠背八股文还真不够。今天把整套题的拆解思路和答案复盘分享出来给准备投递金山办公或者其他办公软件类公司的同学做个参考。这篇内容适合三类人正在准备秋招/春招的应届生、想查漏补缺巩固基础的初级开发以及纯粹想看看大厂笔试题长什么样的技术爱好者。我会按照试卷的真实结构逐题拆解每道题都会给出我的解题思路、完整答案和踩坑复盘力求让你看完之后不仅能应付这道题还能明白它背后想考察的真正能力点。1. 拿到这套题的第一感受覆盖全面但重心清晰先说整体观感。金山办公的这套笔试题和我之前做过的其他互联网公司试卷相比最大的差异在于它没有把大量篇幅花在“偏题怪题”上而是非常务实地覆盖了服务端开发日常工作中最常打交道的几个领域。卷面结构大概是这样的客观题部分涵盖计算机网络、操作系统、数据库原理、Linux 基础大概占 40% 左右主观题部分有两道编程题占 30% 左右剩下的是一道数据库设计题和一道系统设计题。这个比例分布本身就传递了一个信号——金山办公希望招到的是“基础扎实、能直接上手写代码、对系统架构有基本认知”的人而不是只会刷题的做题家。从考察重心来看网络和数据库的分量最重。这个也好理解服务端开发日常打交道最多的就是网络请求处理和数据存储尤其是金山办公这种做 WPS Office、云文档等产品的公司对数据的可靠性、一致性要求非常高数据库这块必然会是重点。另外我还注意到一个细节这套试卷的编程题部分并没有给出特别复杂的算法背景题干都是非常贴近业务的场景化描述。比如有一道题直接把“协同编辑中的冲突检测”包装成了算法问题这个思路其实比单纯的算法题更高明——它考的是你能不能把业务场景抽象成算法模型这个能力在实际工作中比会背十种排序算法重要得多。2. 基础客观题复盘概念题背后的真实考点客观题部分虽然每道题看起来都是基础概念但仔细做下来会发现它考察的深度比表面看起来要深一层。很多题都设置了“看起来对但实际上有前提条件”的干扰项这其实是在考察你对概念的精确理解而不是模棱两可的印象。2.1 计算机网络题三次握手和四次挥手的“坑”有一道典型的 TCP 三次握手题目问的是“在 TCP 连接建立过程中第二次握手后服务端处于什么状态”。很多同学看到这道题会毫不犹豫地选“SYN_RCVD”但这个答案其实不够精确。关键在于题目问的是“第二次握手后”这个时间点。第二次握手是服务端发出 SYNACK 报文之后此时服务端的状态确实从 LISTEN 变成了 SYN_RCVD。但是这里有个容易被忽略的细节如果题目问的是“客户端收到第二次握手后”那客户端的状态是 SYN_SENT 变成 ESTABLISHED而服务端此时仍然是 SYN_RCVD。不同的时间粒度答案完全不同。这种题目表面上考状态机实际上考的是你有没有真正理解 TCP 连接建立的交互过程而不是死记硬背状态名称。我的建议是复习 TCP 状态迁移时不要只看状态表要跟着报文交互顺序走一遍客户端发 SYN、服务端收 SYN 发 SYNACK、客户端收 SYNACK 发 ACK、服务端收 ACK 建立连接。每一步都把双方的状态写下来来回走两遍就记牢了。2.2 操作系统题进程与线程的经典辨析进程和线程的区别是操作系统部分的必考题但金山办公的这道题加了一个比较刁钻的切入点——“多线程环境下哪个资源是线程间共享的”。选项给了栈、寄存器、堆、程序计数器。正确答案是堆。但有意思的是下面这道扩展题——如果改成“多线程环境下哪些资源是线程私有的”或者“多进程环境下哪些资源是共享的”答案就完全不一样了。线程共享的是进程的堆空间、全局变量、文件描述符表私有的是栈、寄存器、程序计数器。而进程之间内存空间互相隔离不共享堆空间。这个点看起来很基础但实际面试时我见过不少工作一两年的人都会搞混。你只需要记住一个核心逻辑线程是调度的基本单位进程是资源分配的基本单位。既然线程共享进程的资源那所有“分配”给进程的资源堆、全局变量、文件在进程内的线程间就是可见的而每个线程要独立执行就必须有自己的栈和程序计数器来维护执行现场。2.3 数据库题事务隔离级别与锁机制数据库部分的题目集中在了事务隔离级别上这其实是服务端开发面试中的老演员了。题目大概是给出几种并发场景让你判断应该使用哪种隔离级别。当时试卷给的场景是在一个在线文档系统中用户 A 查询一篇文档的修改时间用户 B 同时修改了这篇文档并提交问在可重复读REPEATABLE READ隔离级别下用户 A 再次查询会看到什么结果。答案是看到第一次查询时的快照也就是修改前的时间。这里的关键在于理解 MVCC多版本并发控制机制。InnoDB 的可重复读通过一致性视图consistent snapshot保证在同一个事务中多次执行相同查询得到的结果是一致的。用户 A 第一次执行 SELECT 时生成了一个 ReadView之后在这个事务中所有普通 SELECT 都基于这个 ReadView看不到其他事务在之后提交的修改。我当时在这道题上多留了个心眼如果用户 A 的查询是SELECT ... FOR UPDATE加锁读而不是普通快照读结果就完全不同了。加锁读走的是当前读current read看到的是最新已提交的数据会读到用户 B 的修改。这个延伸在后面的系统设计题里会用上当时我就在笔记里标注了这个问题结果后面果然有考题和它呼应。2.4 Linux 基础题awk 和进程管理命令基础题里还夹杂了几道 Linux 命令题难度不大但很实用。有一道是让从日志文件中统计请求量最大的前 10 个 IP看到了要条件反射般地写出awk {print $1} access.log | sort | uniq -c | sort -rn | head -n 10。为什么刻意强调条件反射因为这种命令在服务端定位问题时太常用了金山办公考这个点说明他们要的不是“见过”Linux 的人而是真正用过、熟练掌握了的人。建议你在准备时一定亲手在虚拟机或云服务器上敲几遍这些命令把 awk、sed、sort、uniq、grep 这五个命令的常用参数练熟这是服务端开发的基本功。3. 编程题第一道数据流式处理与动态规划的变体客观题做完重头戏来了。第一道编程题我记得很清楚因为在众多数值题里它的形式非常少见——题目提供了好几天真实风格的访问日志数据要求处理这些数据并得出统计结果。3.1 题目原题重述题目先给了一个本地文件每一行是一条访问日志格式包含四列访问时间、用户 ID、页面 ID、停留时长秒。要求实现两个功能第一统计出访问次数最多的前 5 个页面第二实现一个函数在给定用户 ID 时计算该用户两次连续访问之间“间断时间”的最大值。3.2 解题思路与代码实现第一问是送分题用哈希表统计页面 ID 出现次数然后按 value 排序取前 5 个即可时间复杂度 O(n log n)。但要注意日志文件可能非常大不能一次性读入内存在 Python 里手动排序否则会内存溢出。实际笔试环境内存通常只有 256MB 或 512MB几 GB 的文件不能这么处理。更稳妥的方案是边读文件边统计使用collections.Counter或者自己维护字典最后再统一排序。虽然计数器本身也会占内存但对于“统计页面访问次数”这种场景页面 ID 的种类数通常远小于总行数所以字典是可以放下的。如果极端到页面 ID 种类也上千万那就必须用外部排序的思路或者直接上 SQL 让数据库来做这件事。第二问我实现的思路也不复杂只需要对入参的用户 ID 找到它在日志中出现的所有行按时间排序然后相邻两条记录之间计算时间差保留最大差值即可。核心就是排序后做差。真正需要小心的地方是日志中的时间格式原题给的是2020-08-01 12:30:45这样的字符串要先用datetime.strptime把它转成时间对象再做减法。from datetime import datetime from collections import defaultdict def parse_line(line): parts line.strip().split(,) if len(parts) 4: return None time_str, uid, pid, duration parts ts datetime.strptime(time_str, %Y-%m-%d %H:%M:%S) return ts, uid, pid, int(duration) def count_top_pages(log_path, top_n5): page_count defaultdict(int) with open(log_path, r, encodingutf-8) as f: for line in f: parsed parse_line(line) if parsed: _, _, pid, _ parsed page_count[pid] 1 top sorted(page_count.items(), keylambda x: x[1], reverseTrue)[:top_n] return top def max_gap_for_user(log_path, target_uid): visits [] with open(log_path, r, encodingutf-8) as f: for line in f: parsed parse_line(line) if parsed: ts, uid, _, _ parsed if uid target_uid: visits.append(ts) visits.sort() max_gap 0 for i in range(1, len(visits)): gap (visits[i] - visits[i - 1]).total_seconds() max_gap max(max_gap, gap) return max_gap这道题整体难度不高的原因在于它没有考复杂的算法但很考工程思维你的代码要能在大数据量下稳定运行要考虑到日期解析、数据清洗、内存控制。这些能力是工作后每天都在用的。3.3 做题时的经验复盘我做完这道题最大的感受是一定要先看输入规模。当时我在脑海里估算了一下如果日志文件有 2GB那我用 Counter 统计页面 ID 的字典最多存几十万个 key完全没问题但如果在 Python 里用f.readlines()一次加载整个文件2GB 直接就把内存打爆了。所以代码里一定用的是for line in f这样的逐行迭代方式。4. 编程题第二道协同编辑中的“版本向量”问题第二道编程题需要一定算法功底也是很多同学觉得最棘手的一道。题干用很短的话描述了一个在线文档协同编辑场景多个用户同时编辑同一篇文档系统为每个用户维护一个版本号要求设计一种数据结构来判断两个用户的编辑结果是否存在冲突。4.1 题目背后的Lamport时间戳概念这道题表面看是在问数据结构设计实际考的是分布式系统中的“版本向量”Version Vector或“向量时钟”概念。简单解释就是每个用户的编辑操作都对应一个全局递增的版本号但每个用户维护一个向量向量中每个分量表示这个用户看到的各个用户的版本号。当两个用户的版本向量可以比较大小一个向量中的每个分量都大于等于另一个向量中的所有分量时说明其中一个用户包含了另一个用户的全部修改合并时可以自动解决冲突如果两个向量互不可比你改你的我改我的各包含对方不知道的修改那么合并时就会产生冲突需要人工介入。整理一下就是这样的判断逻辑向量 A 和向量 B 相等说明两者看到的历史完全一致无需合并A 完全包含 B向量 A 的每一维数值都大于等于向量 B说明 A 包含了 B 的全部修改不会冲突A 与 B 互有对方没有的修改存在一维 A B 且存在另一维 B A此时两者并发修改了文档合并时必然发生冲突4.2 我的实现与测试用例我当时的实现是用一个字典来表示每个用户的版本向量key 是用户 IDvalue 是用户见过的最大版本号。每次用户编辑成功后对应 key 的值加一。class VersionVector: def __init__(self, user_id): self.user_id user_id self.vector {} def record_edit(self, user_id): self.vector[user_id] self.vector.get(user_id, 0) 1 def merge(self, other): merged VersionVector(self.user_id) all_users set(self.vector.keys()) | set(other.vector.keys()) for u in all_users: merged.vector[u] max(self.vector.get(u, 0), other.vector.get(u, 0)) return merged def is_ancestor(self, other): for u, v in other.vector.items(): if self.vector.get(u, 0) v: return False return True def needs_merge_manual(self, other): return not self.is_ancestor(other) and not other.is_ancestor(self)其实还有更简单的解法不定义 VersionVector 类只用两个字典来表示两个用户的版本向量然后逐维比较代码反而更短。但工程上把它封装成类肯定更清晰也方便后续扩展“自动合并”的逻辑。笔试时建议以 AC 为主要目标但良好的封装在后续面试环节会很加分因为面试官问“你当时怎么设计的”时你能很结构化地讲出来。4.3 同类型题目举一反三这类题在分布式系统的教材里很常见但很多同学只看概念不写代码导致笔试时想不到用版本向量。其实还有一个特别经典的变体银行转账场景中判断两个账户的余额快照是否存在冲突。本质都是在问在无法进行全局串行化控制的系统中怎么判断两个操作是串行发生的还是并发的。理解了这个本质以后你碰到类似题目就不慌了先判断这个系统的操作是否依赖于全局有序性如果依赖就用版本号或时间戳如果不确定先后关系就用向量时钟。这是分布式系统设计里的“老三样”Kubernetes 的 resourceVersion 本质上也是这套思想。5. 数据库设计题在线文档分享系统的表结构设计接下来是一道完整的数据库设计题分值占比最高。题干给了一个在线文档的分享场景用户可以在系统中创建文档、设置文档权限只读/可编辑、把文档分享给其他用户或者群组、查看文档的分享历史记录。要求设计数据库表结构并写出核心查询的 SQL。5.1 需求分析先搞清楚有多少个实体这种题的大忌是一上来就噼里啪啦建表。先花两分钟梳理需求里的实体和关系比直接动手高效得多。根据题干可以整理出以下实体用户user系统用户包含用户 ID、昵称、头像等基础信息文档document包含文档 ID、标题、内容、创建者 ID、创建时间、更新时间分享记录share_record记录一次分享操作的信息包括分享者 ID、被分享者 ID、文档 ID、权限级别、分享时间权限permission其实可以直接在分享记录中用字段表示不需要单独建表实体之间的关系是一个用户可以拥有多个文档一个文档可以被分享给多个用户一个用户可以收到多个文档的分享。所以用户和文档之间是多对多的分享关系而分享记录就是中间表。5.2 建表 SQL 与索引优化按上述分析表结构可以分为以下三张核心表CREATE TABLE user ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, nickname VARCHAR(50) NOT NULL DEFAULT , avatar_url VARCHAR(255) NOT NULL DEFAULT , created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE document ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, owner_id BIGINT UNSIGNED NOT NULL, title VARCHAR(200) NOT NULL, content LONGTEXT, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY idx_owner_id (owner_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE share_record ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, doc_id BIGINT UNSIGNED NOT NULL, sharer_id BIGINT UNSIGNED NOT NULL, grantee_id BIGINT UNSIGNED NOT NULL, permission TINYINT NOT NULL COMMENT 1-read, 2-edit, 3-owner, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY idx_doc_grantee (doc_id, grantee_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;索引设计方面share_record上我建了一个联合索引(doc_id, grantee_id)因为最常见的查询模式是“用户打开一个文档时校验他有没有访问权限”也就是查WHERE doc_id ? AND grantee_id ?。如果没有这个联合索引MySQL 只能先按 doc_id 把该文档的所有分享记录捞出来再在内存里匹配 grantee_id当某篇热门文档的分享记录特别多时性能会很差。5.3 高概率出现的SQL题查询“某个用户能访问的所有文档”这道题我把年份记不太清但查询模式很典型几乎每个办公类产品都会遇到。问题是“查询用户 1001 能访问的所有文档 ID 和权限级别。”需要拆成两种情况用户自己创建的文档以及别人分享给他的文档。前者是WHERE owner_id 1001后者是WHERE grantee_id 1001。最后用 UNION 把结果合并或者用一个带OR的查询也可行。SELECT doc_id, 3 AS permission FROM document WHERE owner_id 1001 UNION SELECT doc_id, permission FROM share_record WHERE grantee_id 1001;这里要注意一个细节自己创建的文档权限定为 3owner其他人为 1 或 2这两类结果需要合并。用 UNION 而不是 UNION ALL 是因为理论上不会出现同一文档既属于你又分享给你的情况但写 UNION 更保险它可以顺手去重。5.4 设计题里的隐藏加分项做完基本表结构还不够如果你想在笔试中拿高分建议再考虑几个扩展点第一分享记录的表是否需要记录“取消分享”业务上用户可能取消分享如果直接删除记录那历史审计数据就丢了。更稳妥的做法是加一个status字段0-正常1-取消查询时过滤掉status 1的记录。第二分享表是否需要存储权限的来源比如用户 A 把文档分享给群组 G群组成员 B 就通过群组获得了文档权限。如果之后 B 想知道“我为什么能看到这个文档”直接从 share_record 表里查 grantee_id B 是查不到的因为记录存的是 grantee_id G。这个需求在真实业务里几乎必然存在。解决方案是增加grantee_type字段区分“个人”还是“群组”再通过群组成员关系表去解析。我当年在做这个设计时就加了grantee_type面试官针对这个追问了两轮最后评价还不错。6. 系统设计题实现一个有历史版本的文档存储服务最后一道系统设计题也非常有意思设计一个类似“文档历史版本”功能的存储服务要求支持保存文档每次编辑后的快照、支持查看任意历史版本、支持版本间差异对比并且要考虑存储成本问题。6.1 核心难点快照存储的成本控制如果每次编辑都保存一份完整快照那么一篇被频繁编辑的文档光历史版本就能占据巨大的磁盘空间。比如一篇 100KB 的文档修改 1000 次完整快照就要占 100MB这显然不可接受。业界常见的做法是做增量存储或者叫“前向增量/后向增量”。简单来说只存第一个版本的完整内容之后每次修改只记录“变更的 diff”查看历史版本时从第一个版本开始依次应用 diff就能重建出任意版本。如果用户常常查看旧版本那就用后向增量也就是存最新版本完整内容旧版本用 diff 记录从最新版本往前应用反向 diff 来获得历史版本。以 Git 为例Git 目前采用的是“快照增量压缩”的混合存储每次提交都保存了所有文件的快照索引但对象库里通过 zlib 压缩和 delta 压缩来减小存储占用。我们在设计文档历史版本时完全可以借鉴同样的思路。6.2 我给出的设计方案我当时的方案分三层底层存储用对象存储比如 OSS 或 S3每个文档的每个版本有一个独立的 object keyvalue 是版本内容或者 diff 内容。第一版存完整快照后续版本存基于上一版本生成的 diff。中间层用一个版本元数据表来记录每个版本的标识、父版本、时间戳、diff 文件地址等信息。这个表可以存在 MySQL 里因为它的写频率不高读频率比较高完全撑得住。最上层是一个历史版本服务对外提供几个核心接口save_version保存新版本、get_version获取指定版本内容、diff_versions对比两个版本的差异。这个服务负责调用底层存储和读写元数据表同时把“重建某个历史版本”的逻辑封装起来。版本元数据表字段 - id版本ID - doc_id文档ID - parent_id父版本ID - version_seq版本序号 - snapshot_typeFULL 或 DIFF - content_key对象存储中的 key - created_at创建时间6.3 为什么选“后向增量”针对查看历史版本的频率远高于查看最新版本的业务场景我最终选了存“最新版本完整快照 逆序 diff 链”的方案。也就是说文档总是保存最新版本的完整内容而历史版本只保存相对下一个版本的 diff。这样读最新版本是 O(1) 的操作如果要读很老的版本虽然需要沿着 diff 链回溯计算但毕竟老版本访问频率低是划算的。这个权衡的点我专门写在答案的备注里“因为 WPS 这类文档工具用户 90% 的场景是打开最新版本只有少数场景会查看历史记录。如果用前向增量每次打开最新版本都要从头应用所有 diff热点读取就爆炸了。”我当时还补充了一个优化如果用户频繁查看某一历史版本可以把该版本提升为 FULL 快照打断 diff 链这样后续对这个版本的访问就是 O(1) 了。类似 LRU 的缓存淘汰策略但这里缓存的是“快照内容”。这个点不少面试官会感兴趣建议准备时多说一句。6.4 系统设计题的高分要点这道题我复盘下来拿高分的核心在于展示了几个思维层次第一层是数据结构设计能力会想到增量存储第二层是业务感知能力知道热点读取是“最新文档版本”第三层是容灾意识在方案里我主动提了“快照需要定期全量备份防止 diff 链断裂导致无法恢复”。能够想到第三层通常就能和普通候选人拉开差距了。7. 复盘这套试卷背后的出题逻辑与备考启示整套试卷做完最大的感触不是哪道题不会而是每道题都“恰好在你会与不会的边界上”。这才是筛选效率最高的出题方式。如果一整套题做下来全是送分题那区分度太低全是偏题那招进来的人可能只会考试不会干活。金山办公的出题逻辑很清晰可以总结为“三看”一看基础是否扎实。网络、操作系统、数据库这些客观题不设置特别复杂的场景但要求你理解概念的精确前提。这说明他们默认一个有竞争力的候选人基础一定要过硬。基础不稳的人可能背了状态名但做不对状态题这就是差距。二看算法能否落地。两道编程题都包装了业务场景没有直接说“输出最长上升子序列长度”。如果你平时练题只追求 AC 而对业务抽象不敏感可能会在理解题意上花太多时间。建议备考时把 LeetCode 的题目按照“业务场景题”和“纯算法题”分类有针对性地练习把业务问题转化为算法问题的能力。三看设计是否考虑成本。系统设计题没有要求你写出完整代码但要求考虑存储成本、读取性能、扩展性等多个维度。这说明他们关注的是候选人的架构意识——你写的每一行代码、设计的每一张表将来都是要真金白银烧服务器资源的。这种意识不靠刷题靠的是平时多做项目、多复盘。从备考策略来看我建议打算投金山办公的同学按这个优先级安排时间第一优先级搞定计算机网络和操作系统的基础题尤其是 TCP 状态迁移、进程线程、锁和并发第二优先级把数据库事务、索引、SQL 优化做到“闭卷能写”因为这一块是服务端开发的核心第三优先级编程题保持手感重点练字符串处理、哈希统计、动态规划、二叉树第四优先级系统设计题至少亲手设计过三个不同类型的系统比如短链系统、文档协作系统、feed 流系统8. 最后再分享两个应试心态上的技巧有很多同学笔试前焦虑得不行疯狂刷高难度题结果忽略了这套题真正想考的基础能力。我个人做了几十场笔试后的体会是大部分校招笔试题的难度中位数不超过 LeetCode Medium真正拉开分数差距的是那些“明明会做但因为不仔细而做错”的基础题。第一个建议是审题时把“前提条件”圈出来。比如 TCP 那题问的是“第二次握手后”那就把重点放在此刻服务端和客户端各自的状态上不要凭惯性写出“SYN_SENT 之后是 ESTABLISHED”这种答案。第二个建议是写完 SQL 之后要检查“权限来源”。很多数据库设计题你以为查完 share_record 就完了但其实漏掉了“文档创建者天然有权限”这个业务规则。这种隐性需求既是笔试的考点也是将来工作的需求分析能力。把这个习惯养成你不仅能应付笔试还能在真实业务评审中少踩很多坑。这套题整体难度对我个人而言中等偏上但题目质量很高普适性也强。不管你是不是投金山办公把这里面涉及的考点逐项过一遍对服务端开发能力的查漏补缺也很有价值。后续如果大家需要我也可以接着把二的解析整理出来包括里面那道比较有深度的“分布式锁设计”的题那块我印象还挺深的。
返回列表