
说实话我见过太多自学 Java 的朋友基础语法、集合、并发都刷得滚瓜烂熟结果一到要写项目就卡壳。问起来就是我看过网上的电商项目但对于数据库表怎么设计、接口幂等怎么做、部署时配置文件怎么处理完全没有概念。这也是我为什么想聊聊《尚庭公寓》这个单体架构项目——它没有天花乱坠的微服务组件却把一套真实业务系统从开发到部署的完整链路串了起来非常适合用来补上项目实战这一课。《尚庭公寓》是一个典型的公寓管理系统业务上覆盖房源管理、租客签约、账单生成、权限控制技术上就是 Spring Boot MyBatis-Plus MySQL Redis 这一套主流单体组合。它解决的问题很实际中小型公寓运营方需要一套能管房、管人、管账的系统业务量级撑不起复杂的分布式架构单体就是最合理的选择。对想积累项目经验的人来说这套代码能让你完整体验需求分析、表设计、编码实现、容器化部署全过程而且单体架构的思路到了面试场上也更容易讲透讲深。1. 为什么要用单体架构做尚庭公寓——先想清楚这个项目在练什么现在打开招聘网站的 Java 岗位描述十有八九写着熟悉微服务架构有分布式系统经验搞得很多初学者产生了一种错觉不搞微服务就落伍了。但真实情况是大多数中小型项目的并发量、数据量、团队规模都远没有到必须拆微服务的程度。尚庭公寓这种业务场景房间几千套、租客几千人、每天的账单操作几百次单体架构完全能扛住而且开发效率、调试成本、运维复杂度都远低于微服务。这个项目的核心训练价值在于几件事第一是数据库设计能力你要把房源、租约、账单、用户这些实体之间的关系理清楚画出能落地的表结构第二是业务状态机设计比如一间房从可租到已预定到已租住再到已退租状态流转的边界条件必须严格第三是事务一致性处理创建租约时要同时改房间状态、生成账单、记录合同任何一个环节失败都要能回滚第四是权限模型落地不同角色看到的数据和能执行的操作要有清晰区分。如果一上来就硬上微服务结果通常是灾难性的。服务拆分依据是什么每个服务拆多大分布式事务用 Seata 还是本地消息表这些前置问题还没想清楚业务代码就已经被架构绑架了。我在实际带人的时候也发现能把单体写得干净利落的人理解起微服务的拆分逻辑反而更快——因为他知道一个业务域该内聚哪些东西。1.1 尚庭公寓的业务模型和核心角色公寓系统的角色并不复杂但业务闭环比较典型管理员管理房源、查看经营数据、配置基础信息全量权限管家负责具体楼栋的房间维护、带看、签约、抄表、催缴账单租客查看自己的合同、缴纳账单、提交报修申请如果做 C 端的话对应的核心业务域大致是这几个房源域楼栋、单元、房间、户型以及房间状态可租、已预定、已租住、维修中、已退租待清洁租务域租约合同从登记到签约、履约、退租、续租核心是一次完整的租赁生命周期账单域房租按月生成、水电气按抄表读数生成、物业费固定周期生成账单伴随支付状态流转用户域登录认证、角色权限、操作日志这些业务域在一个单体项目里天然适合用包结构来划分边界而不是拆成多个进程。学习的时候可以把重点放在怎么把这些域组织得高内聚、低耦合上面。1.2 单体架构下如何划分模块而不至于变成大泥球单体架构最大的黑点就是容易腐化成大泥球——所有代码堆在一起service 互相乱注入改一个地方崩一片。这个问题不是单体架构本身造成的而是项目结构从一开始就没有做好模块边界。我在尚庭公寓里推荐的方式是 Maven 多模块结构但模块划分不是按技术层controller、service、mapper 各一个模块而是按业务域划分apartment-common通用工具、统一返回体、异常处理、常量、枚举apartment-framework配置类、安全框架封装、第三方客户端配置apartment-module-room房源域的 Controller、Service、Mapper、DTO、VOapartment-module-lease租务域包含合同与租约流程apartment-module-bill账单域包含账单生成、支付回调、对账apartment-module-system用户、角色、权限、菜单模块之间的依赖规则是向下依赖 common 和 framework同层模块之间尽量不直接依赖。如果租务域需要展示房源的简要信息统一通过RoomQueryService的只读接口去查而不是直接注入RoomMapper跨模块查表。这条规则能逼着你在写代码的时候想清楚这个数据到底属于谁的领域对后续微服务拆分也是一模一样的思路。2. 技术选型不是越新越好而是够用且能说明白项目里选型是个很现实的问题。我跟不少自学的人聊过他们很喜欢在简历上写精通 Spring Cloud Alibaba但问一句你这个场景为什么需要 Nacos 做注册中心就答不上来。尚庭公寓的选型逻辑其实是在回答三个问题够不够用、容不容易上手、出了问题上哪儿查资料。下面是我在这类单体项目中常用的技术组合你可以直接参考组件选型选型理由开发框架Spring Boot 3.x快速搭建内嵌容器生态成熟ORMMyBatis-Plus 3.5.x单表 CRUD 不用写 SQL复杂查询仍可手写 XML学习成本低数据库MySQL 8.x业务数据强一致InnoDB 事务支持可靠缓存Redis 7.x验证码、首页热点数据、字典缓存、分布式锁单体内基础版权限认证Sa-Token 或 Spring Security JWT单体下 Sa-Token 更轻量上手快RBAC 模型清晰接口文档Knife4j基于 OpenAPI 3前后端联调方便Bean 映射MapStruct编译期生成映射代码比 BeanUtils 性能好且类型安全部署Docker Docker Compose环境一致性最好单机编排足够一个经常被问到的点是为什么不用 JPA 而用 MyBatis-Plus。我的看法是JPA 在复杂动态查询面前很容易写出让人看不懂的 Specification 或者 Query对初学者来说黑盒感太强MyBatis-Plus 的LambdaQueryWrapper可以让你明确看到查询条件是怎么拼的而且尚庭公寓里不少查询带动态条件比如房间筛选按状态、按户型、按租金区间这种需求用 MyBatis-Plus 表达起来最直接。另外国内公司存量项目 MyBatis 系占比依然很高从这个项目练起后续接手的概率更大。2.1 数据库设计与金额精度数据库设计是这种业务系统最见功夫的部分。尚庭公寓的核心表我挑三张最有代表性的出来房间表、租约表、账单表。CREATE TABLE room_info ( id bigint NOT NULL AUTO_INCREMENT, building_id bigint NOT NULL COMMENT 所属楼栋, unit_id bigint DEFAULT NULL COMMENT 单元, room_number varchar(32) NOT NULL COMMENT 房间号, room_type tinyint NOT NULL COMMENT 户型1-一室 2-两室 3-三室, area decimal(10,2) NOT NULL COMMENT 面积, rent_amount decimal(10,2) NOT NULL COMMENT 月租金, status tinyint NOT NULL DEFAULT 1 COMMENT 状态1-可租 2-已预定 3-已租住 4-维修中 5-已退租待清洁, version int NOT NULL DEFAULT 0 COMMENT 乐观锁版本号, create_time datetime DEFAULT CURRENT_TIMESTAMP, update_time datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY idx_building_status (building_id, status) ) ENGINEInnoDB COMMENT房间信息表;CREATE TABLE lease_contract ( id bigint NOT NULL AUTO_INCREMENT, contract_no varchar(64) NOT NULL COMMENT 合同编号, room_id bigint NOT NULL, tenant_name varchar(64) NOT NULL, tenant_phone varchar(20) NOT NULL, user_id bigint DEFAULT NULL COMMENT 关联注册租客账号, lease_start_date date NOT NULL, lease_end_date date NOT NULL, monthly_rent decimal(10,2) NOT NULL, deposit decimal(10,2) DEFAULT 0 COMMENT 押金, status tinyint NOT NULL DEFAULT 1 COMMENT 1-生效中 2-已退租 3-已续租 4-已作废, create_time datetime DEFAULT CURRENT_TIMESTAMP, update_time datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_contract_no (contract_no), KEY idx_room_status (room_id, status) ) ENGINEInnoDB COMMENT租约合同表;CREATE TABLE bill ( id bigint NOT NULL AUTO_INCREMENT, bill_no varchar(64) NOT NULL COMMENT 账单编号, contract_id bigint NOT NULL, room_id bigint NOT NULL, bill_type tinyint NOT NULL COMMENT 1-房租 2-水费 3-电费 4-燃气费 5-物业费, bill_period varchar(16) NOT NULL COMMENT 账单周期如 2025-06, amount decimal(10,2) NOT NULL COMMENT 应收金额, paid_amount decimal(10,2) DEFAULT 0 COMMENT 实收金额, status tinyint NOT NULL DEFAULT 0 COMMENT 0-待支付 1-已支付 2-已核销 3-已作废, pay_deadline datetime NOT NULL COMMENT 缴费截止时间, pay_time datetime DEFAULT NULL, create_time datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_bill_no (bill_no), UNIQUE KEY uk_contract_period_type (contract_id, bill_period, bill_type), KEY idx_room_status (room_id, status) ) ENGINEInnoDB COMMENT账单表;这里有两个细节值得单独说。第一个是金额一律用decimal(10,2)Java 里对应BigDecimal绝对不要用double或float。现实中因为浮点误差导致账单差几分钱的情况不算罕见做金融业务性质的功能要从表结构开始就掐掉这个风险。第二个是账单表的唯一索引uk_contract_period_type它保证了同一个合同、同一个账期、同一个账单类型不会生成两条账单这比在代码里先查后插可靠得多。2.2 数据一致性在单体里怎么保证有人觉得单体架构不用考虑数据一致性这是个误解。单体只是不需要分布式事务但在同一个应用里跨表写入的一致性问题依然要处理而且处理得好不好直接决定系统的可靠性。尚庭公寓里最典型的一致性场景是签约查询房间状态判断可租创建租约把房间状态改为已租住生成首月账单。如果这一串操作不加保护并发下就可能出现两单同时签同一间房的情况。我会在后文展开具体实现这里先说明常用的三层防线。第一层是数据库事务。用Transactional把多步写操作包起来任何一步异常整体回滚。第二层是业务逻辑的乐观锁。房间表里的version字段就是干这个用的更新时带上WHERE version 旧版本号如果影响行数为 0 说明被并发修改过业务侧重新拉取状态再决策。第三层是代码纪律。跨模块调用只允许走 Service 接口不允许绕到别人的 Mapper 里去写表。这条规矩在单体会让你觉得多此一举等拆微服务后会感谢自己当初的克制。3. 核心业务实现从能跑到跑得稳这几个地方最容易翻车如果说前面的工作是搭骨架那这一章就是在填血肉。我会挑几个在尚庭公寓开发中被问到最多、也是最容易翻车的模块来拆解每个都直接给可落地的实现思路。3.1 抢房场景房源状态流转的防重设计公寓管理里有一个和高并发秒杀系统同构的业务管家给客户办理签约时可以同时有多个管家带看同一个热门户型然后在系统里抢单。房间只有一间先到先得。如果你的实现是先查房间状态发现是可租然后插入租约再更新房间状态那中间任何一个环节的间隔都可能被人钻空子。正确的做法是把状态判断和更新压成一条 SQLOverride Transactional(rollbackFor Exception.class) public void signContract(ContractCreateRequest request) { // 1. 乐观锁更新房间状态条件里直接带状态限制 int updated roomInfoMapper.update(null, new LambdaUpdateWrapperRoomInfo() .eq(RoomInfo::getId, request.getRoomId()) .eq(RoomInfo::getStatus, RoomStatus.AVAILABLE.getCode()) .set(RoomInfo::getStatus, RoomStatus.RENTED.getCode()) .set(RoomInfo::getUpdateTime, LocalDateTime.now())); if (updated 0) { throw new BizException(房间已被预占请刷新后重试); } // 2. 创建租约 LeaseContract contract buildContract(request); leaseContractMapper.insert(contract); // 3. 生成首月房租账单 Bill bill buildFirstMonthBill(contract); billMapper.insert(bill); }这样写最大的好处是数据库的行锁加上条件更新天然把并发下的竞争串行化了。updated 0意味着房间已经不是可租状态可能是被预定、被租住或者维修中业务侧直接返回友好提示即可。有人说那万一第 1 步成功了第 3 步失败怎么办——这就是事务的作用整个方法被Transactional包裹任何一步抛出异常都会回滚房间状态也会跟着恢复。这个设计在面试时候是很好的谈资。你能够说出来为什么不用先查再更新而要用条件更新就证明你理解了并发控制的本质。3.2 租约生成与账单联动事务边界怎么划租约创建这个动作是尚庭公寓里牵涉最广的用例它要操作房源表、合同表、账单表可能还要生成一条跟进记录。不少人会把所有逻辑全部塞进一个方法并疯狂堆代码结果事务越来越大锁持有时间越来越长后面改的时候就特别痛苦。我建议的划分方式是把创建租约当作一个主用例内部只编排必要步骤不要把发通知、写日志、更新运营看板数据这种非关键路径都塞进主事务里。Transactional(rollbackFor Exception.class) public void createLeaseContract(CreateContractRequest request) { // 核心链路校验参数 - 锁定房间 - 插入合同 - 生成首月账单 validate(request); lockRoom(request.getRoomId()); insertContract(buildContract(request)); generateFirstMonthBill(contract); }这里的原则是所有写操作必须在同一事务内完成但如果后续要调外部接口比如发短信通知租客那就必须在事务提交之后再做否则外部接口调用阻塞会白白占用数据库连接接口失败还会导致无意义的事务回滚。单体项目里很多人容易忽略这一点等到压测或者线上出问题才后悔。正确的做法是通过ApplicationEventPublisher发一个领域事件在事务提交后异步处理通知类动作。3.3 列表查询里的性能坑N1 问题与 MyBatis-Plus 优化运营后台最常用的功能就是房间列表和账单列表。一般的做法是在列表页显示房间号、户型、租金、状态还要带出楼栋名和当前租客名。新手容易写出这样的逻辑先查 20 条房间然后循环的每条去查一次楼栋、再查一次最新合同变成 1 20 20 条 SQL。这就是典型的 N1 问题房间多的时候数据库瞬间就被拖垮。正确做法是先批量查询再在内存里组装。MyBatis-Plus 的selectList配合in条件可以一次查回全部关联数据然后用Map做本地索引ListRoomVO roomVOList roomInfoMapper.selectPage(page, wrapper).getRecords(); ListLong buildingIds roomVOList.stream() .map(RoomVO::getBuildingId).distinct().toList(); MapLong, Building buildingMap buildingService.listByIds(buildingIds).stream() .collect(Collectors.toMap(Building::getId, Function.identity())); roomVOList.forEach(room - { Building building buildingMap.get(room.getBuildingId()); room.setBuildingName(building null ? - : building.getName()); });对于获取当前租客姓名这种动态信息我倾向于在房间列表的查询里冗余一个current_contract_id或current_tenant_name字段房间签约和退租时更新它。有人会觉得冗余字段破坏范式但在实际的运营查询场景里这种冗余是合理且高效的比每次都去 join 合同表快很多。系统设计本来就是平衡的艺术不是课本里的教条。3.4 权限与登录RBAC 模型在单体里怎么落地尚庭公寓的用户群体包括管理员、管家等多个角色不同角色访问的菜单和数据范围不一样。项目里我用的是标准的 RBAC 模型用户-角色-权限三级再加一张用户-角色关联表和角色-权限关联表。登录认证流程是这样设计的用户提交账号密码校验后生成一个 Token 存入 Rediskey 是login:token:{token}value 是用户 ID后续请求在拦截器中解析 Token拿到用户 ID 后从 Redis 加载权限标识集合接口方法上用自定义注解RequirePermission(room:sign)标记所需权限用 AOP 切面统一校验当前用户是否有该权限码没有就直接抛出 403 异常之所以把 Token 状态放到 Redis 而不是用纯无状态 JWT是因为单体架构下我们希望能主动控制会话失效比如管理员把某个管家账号禁用时能立刻踢他下线。纯 JWT 想要实现这种主动失效就得维护黑名单反而多了一层复杂度。这个取舍又可以在面试里展开讲说明你不是只会调包而是根据业务场景做了权衡。4. 从本机到 Docker这个项目的部署环节我踩过很多坑代码写好只是开始真正让人长经验的是把项目从一个我本地能跑变成任何机器上都能一键起。尚庭公寓的部署我选择 Docker Compose 来编排这比直接在服务器上手动装 MySQL、Redis、Java 环境要规范得多出问题的概率也小很多。接下来我会把整个部署过程的关键节点和踩过的坑一起讲。4.1 环境准备MySQL、Redis、应用三大件服务器的初始环境不需要你在宿主机装任何东西全部依赖交给 Docker。一个最小的 docker-compose.yml 大概长这样version: 3.8 services: mysql: image: mysql:8.0 container_name: apartment-mysql restart: always environment: MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD} MYSQL_DATABASE: apartment TZ: Asia/Shanghai command: - --character-set-serverutf8mb4 - --collation-serverutf8mb4_unicode_ci ports: - 3306:3306 volumes: - ./mysql-data:/var/lib/mysql - ./init-sql:/docker-entrypoint-initdb.d redis: image: redis:7.0 container_name: apartment-redis restart: always ports: - 6379:6379 command: [redis-server, --appendonly, yes] app: image: openjdk:17-jdk-slim container_name: apartment-app restart: always depends_on: - mysql - redis ports: - 8080:8080 environment: DB_HOST: mysql DB_PORT: 3306 DB_NAME: apartment DB_USERNAME: root DB_PASSWORD: ${MYSQL_ROOT_PASSWORD} REDIS_HOST: redis REDIS_PORT: 6379 TZ: Asia/Shanghai volumes: - ./app.jar:/app/app.jar - ./logs:/app/logs working_dir: /app entrypoint: [java, -Xms512m, -Xmx512m, -jar, app.jar]init-sql目录是第一次启动时自动执行建表脚本和初始数据用的这是 Docker 官方镜像提供的能力把.sql文件放到/docker-entrypoint-initdb.d容器首次创建数据库后会自动执行。注意是首次创建如果文件夹里已经有数据不会重复执行所以后续表结构变更别指望靠这个目录应该用 Flyway 这类迁移工具。4.2 配置文件细节环境隔离与敏感信息管理Java 应用里最容易被吐槽的就是配置文件里写死密码。尚庭公寓项目的实践是本地开发用application-dev.yml线上环境用application-prod.yml公共配置放application.yml。数据库密码、Redis 密码这类敏感信息在 Compose 文件里通过${MYSQL_ROOT_PASSWORD}从宿主机环境变量读取应用容器里也用SPRING_DATASOURCE_PASSWORD这样的环境变量注入。spring: datasource: url: jdbc:mysql://${DB_HOST}:${DB_PORT}/${DB_NAME}?useUnicodetruecharacterEncodingutf8mb4serverTimezoneAsia/Shanghai username: ${DB_USERNAME} password: ${DB_PASSWORD} data: redis: host: ${REDIS_HOST} port: ${REDIS_PORT}这样配置文件本身不携带任何机密信息换一台机器部署时只需要 export 对应的环境变量即可。很多人部署失败的原因不是代码问题而是配置文件里的 IP、密码或者时区不对把配置和代码分离之后这类问题可以少掉八成。4.3 部署时最容易翻车的几个点第一个就是时区问题。MySQL 连接串里忘了serverTimezoneAsia/Shanghai日历生成时早晚的时间差可能造成账单日期错位排查起来极度隐蔽。不只是数据库连接串操作系统、容器、JVM 三个层面的时区可能不同保险的做法是三处显式都设上。第二个是JVM 堆内存没限制。在本地 16G 内存机器上跑无所谓但部署到 2G 内存的云服务器时OpenJDK 默认的堆空间会按物理内存的 1/4 计算很容易把整台机器拖死。上面 compose 文件里我写死了-Xms512m -Xmx512m这就是一个知道自己有多少预算的意识。第三个是数据库连接池耗尽。大数据量导出或某条慢查询卡住时默认的 HikariCP 最大连接数 10 很快就打满后面的请求全部排队超时。建议按应用实例数和数据库规格去设置一个 2 核 4G 的实例配maximum-pool-size: 20是比较合理的值同时把connection-timeout调成 3000ms快速失败总比无限等待好。第四个是前端静态资源路径问题。如果项目里带了管理后台前端页面打包后的dist目录要么打进 jar 包要么用 Nginx 单独托管并配置反向代理到后端接口。很多人开发环境好好的部署之后页面白屏或者接口 404多半是没配代理或者 API 基础路径不对。4.4 上线后自检部署完成不只是能打开网页应用起来之后我会强制自己做一轮基础健康检查这能省掉后面大量被用户发现的尴尬。至少包含以下五件事调一次健康检查接口/actuator/health确认应用心跳正常登录一次系统走一遍核心流程新增房源、创建租约、生成账单查看日志确认没有数据库连接失败、没有 Redis 报错设置数据库定时备份至少每天备份一次保留最近 7 天观察宿主机 CPU 和内存确认 JVM 参数和容器配置没有超卖如果你愿意多花一点时间可以在部署环节加上 Prometheus Grafana 监控指标采集这属于加分项但在尚庭公寓这个体量不是必需。我更建议先把日志规范化比如统一在方法入口记录请求URI用户ID参数摘要耗时出问题能快速回放链路。5. 单体做完了后面怎么演进以及这个项目可以怎么拿去面试很多人在做完一个单体项目之后都会问一句话那这就算完了吗从学完的角度来说可以算但从学会的角度来说你应该能回答什么情况下这个单体会撑不住以及如果撑不住第一步怎么拆这两个问题。5.1 单体架构的局限会在什么场景下被触发尚庭公寓的业务体量下单体远远谈不上撑不住。但你要知道它的极限在哪儿当多个模块对 CPU、内存等资源争抢严重时比如账单月结要跑大量计算同时房源模块在做频繁的读写系统整体响应会被拖慢当开发团队超过二三十人、代码库集中在同一个工程里时合并分支和上线发布的冲突会非常频繁当数据库连接数不够用、Redis 热点访问集中时单体的扩容方式是整个应用水平多开几台但每台多开的实例都在做同样的工作没有真正按业务分流。这些都是单体架构的边界条件也是设计上的取舍点。要不要拆微服务不是看新不新而是看痛点有没有出现。我见过用单体跑了几百台服务器规模的业务也见过拆了二十个服务却天天协调发布的团队。架构选型的本质是权衡不是站队。5.2 如果要拆从哪里开始拆假设尚庭公寓的业务真的做大了要开始演进我不会建议直接上全套 Spring Cloud 全家桶。最稳的第一步是按业务域垂直拆分接口把账单模块、租约模块、房源模块分别抽成独立进程模块之间通过 HTTP 接口或者早期的 Feign通信。数据库层面先做逻辑隔离把不同业务域的大表分到不同的 Schema 里而不是马上拆库、拆主从。这么做的原因是物理分库会直接引入分布式事务问题很多团队就是栽在这一步上。第二步再引入消息队列去解耦那些通知类和异步类的强需求。比如账单生成后要通知租客账单服务和通知服务之间通过 MQ 传递消息这样生产者不关心消费者是否在线高峰期也能削峰填谷。第三步才是注册中心、配置中心、网关、链路追踪这一整套微服务基座设施。有了前两步的铺垫引入这些组件的成本会低很多业务团队对服务边界的认知也足够清晰。5.3 面试时怎么把这套项目讲出亮点我知道很多人现在最头疼的其实是面试环节。简历上写了尚庭公寓这个项目面试官最常问的无非是几个问题你负责哪部分遇到过最难的 bug 是什么为什么这么设计关于第一个问题建议不要回答我负责写接口而要说我主要负责租约交易链路和账单生成模块包括房间状态机、防重复签约、事务边界划分。关于第二个问题可以把 3.1 节的并发抢房场景讲出来先说出问题现象再讲你尝试的方案最后给出条件更新的解法并解释为什么这样能兜住并发。关于第三个问题把 4.3 节时区、连接池这些实战里踩过的坑拿出来讲面试官会觉得你是真的部署过、真的出过问题、真的解决过问题而不是只跑了个 Demo。面试其实不是在考你用了多少高深技术而是在考你对自己做的项目有没有想清楚。一个能讲清楚为什么用单体、单体的边界在哪、怎么演进的候选人比一个背熟微服务概念却无法解释业务链路的人要有说服力得多。最后顺手分享一个小技巧项目做完之后把你梳理过的这几张核心表结构图、核心用例的状态机图、部署架构图整理成一张 A4 速览图。面试前花 20 分钟过一遍很多问题你都不用现想因为整个项目链路已经在你脑子里建立起了体系。这套自己能画出来的感觉比任何八股文都管用。