
1. 课件里的那张图才是整个项目的灵魂刚看完CMU 15445的第二节内容时我心里就一个感觉数据库系统的性能瓶颈根本不是CPU算不过来而是数据搬家搬得太慢了。Bustub这门课的第一个大项目就是Buffer Pool Manager说白了就是让你亲手写一个数据库自己的内存管家。如果你还没看过这门课的课件我强烈建议先放下手头所有事把那几张关于磁盘、内存、缓冲池的架构图盯上十分钟因为它们才是整个Project 1的灵魂。提示这一篇是学习心得系列的第二篇。如果你还在纠结怎么搭环境、怎么跑通测试框架建议先把第一篇看完。这篇假定你已经具备了基本的课程环境并且对Bustub的代码结构有一定了解。这个主题拆开来看就两部分内存管理和数据移动。前者解决的是数据库怎么把磁盘上的页缓存到内存里、又如何在内存不足时腾位置后者解决的是数据从磁盘到内存、从内存到CPU这一路搬家的成本到底有多高、又怎么把成本压下来。这两个问题连在一起就是数据库系统玩转内存的全部秘密。我们一步步说。2. 为什么数据库不肯老老实实相信操作系统2.1 直接把文件读进内存不就行了还真不行很多人学到这里会有个疑问操作系统不是自带Page Cache吗数据库直接把文件映射进内存mmap或者用read()读文件剩下的事情交给OS不就好了为什么非要自己搞一套Buffer Pool这个疑问我当初也有。答案其实藏在两个词里可控性和预判性。操作系统通用的Page Cache是按文件块来管理的它根本不知道什么是表、什么是索引、什么是聚簇顺序。它只会你读什么我就缓存什么而且淘汰策略是通用的LRU变种根本不会考虑数据库的访问模式。数据库却清楚地知道这个页是B树的内节点接下来一分钟会被高频访问那个页是顺序扫描读出来的用完就扔扔早了无所谓。这种业务级的预判能力操作系统完全没有。另一个更关键的原因是事务语义。数据库需要精确地控制一个脏页什么时候落盘——它必须在WAL预写日志已经把对应的日志写进日志文件之后才允许把数据页刷出去否则断电就会丢数据。你自己写Buffer Pool才能把刷脏页和日志先行这两件事精确地编排起来。如果把这些都交给OS的Page Cache那一断电日志和数据页的落盘顺序就是不可控的事务的持久性就无从谈起。所以结论很简单数据库自己管内存不是为了显摆技术而是为了把命脉攥在自己手里。2.2 把缓冲池想象成一个酒店的前台为了让小白也能有直观感受我把Buffer Pool Manager比作一个有几百间客房的酒店前台这个类比后面会一直用到。磁盘上的每一页数据相当于你要接待的客人身份信息登记在客人的身份证号上也就是Page ID。内存里的缓冲池是一排排客房Frame。每个Frame大小固定通常是4KB或8KB对应一门课里Page类的存储区。前台手里有两本台账一本是房态表Page Table记录每个Page ID被分配到了哪间Frame以及那位客人是不是已入住、另一本是客房状态表Frame数组记录每个Frame目前住的是哪位客人、房间是否被占用、里面的东西有没有被弄脏。当数据库要读取一个页面时流程是这样的前台先在台账里查这个Page ID有没有已经住在某个Frame里。如果已经在了直接把房号告诉数据库FetchPage命中返回指针。如果不在就去磁盘上把这位客人接过来安排一间空房给他ReadPage进内存同时更新两本台账。如果所有客房都满了就得赶走一位客人——优先赶走最久没被访问过的那位这就是后面要说的LRU置换策略。被赶走的客人如果弄脏了房间Dirty Page走之前还得把房间打扫干净——把脏页写回磁盘。就这样一个朴素的模型Project 1要求你自己用C把它实现出来并且扛住并发访问。2.3 解码Bustub的Buffer Pool代码骨架打开src/include/buffer/buffer_pool_manager.h几个核心成员要提前看明白PoolSize pool_size_客房总数也就是缓冲池里Frame的数量。Page *pages_连续分配的Page数组。注意这里的Page是物理房间不管它当前住的是哪个逻辑页。PageTable page_table_从Page ID映射到Frame ID的哈希表。ListFrameId free_list_空房列表。Replacer *replacer_负责客房置换策略的对象Project 1要求你实现LRUReplacer。std::mutex latch_整个缓冲池的大锁。最初的代码框架里NewPage、FetchPage、UnpinPage、DeletePage、FlushPage都是空壳子留给你填。这里我先大概说每个函数该干什么具体实现细节放后面。3. Buffer Pool Manager的实现动手前必须想清楚的几件事3.1 FetchPage的完整路径一步都不能少FetchPage(page_id)是Buffer Pool最核心的入口所有读路径都会经过它。完整逻辑如下加锁查page_table_。命中把对应的Frame从Replacer里揪出来表示它正在被使用不能被淘汰对应的Page的pin_count返回指针。未命中先从free_list_里找空Frame没有就调用replacer_-Victim()抢一个Frame下来。被抢的Frame如果脏了要先FlushPage把它写回磁盘。把这个Frame从旧Page ID的映射中删除从磁盘读入新的Page ID更新映射pin_count 1。这个活儿看着不难但坑全在细节里坑一Victim回来的Frame可能和你要读的Page ID重了——比如并发场景下另一个线程刚把同一个页面读进来结果你又把它当Victim抢了。这个属于并发导致的错误需要在整个Fetch过程中用锁保护好查找和分配这两个步骤的原子性。坑二从磁盘读页面失败怎么办——代码里disk_manager_-ReadPage(page_id, pages_[frame_id].GetData())这一步如果抛异常别让脏页状态和映射表处于半改不改的状态要先回滚现场再报错。坑三永远不要在你的实现里出现一个页面同时有两个Frame引用它的场景。这会导致一个页面被复制两份到内存里所有后面的索引扫描、更新逻辑都会数据错乱。这种bug超级难查建议在实现的一开始就保持映射关系唯一。3.2 NewPage和DeletePage的正确打开方式NewPage是分配一个全新的页面本质上是在磁盘上腾出一个新的Page ID然后在内存里给它安排一个Frame。关键点是先通过AllocatePage()取得一个新Page ID。然后走一遍找Frame→塞数据→登记的流程。pin_count照样要置为1因为调用的地方马上就要往这个页面里写数据。DeletePage则需要注意几个边界页面正在被别人使用pin_count 0时不能删要么抛异常要么返回false。删除前先看看是不是脏页如果是刷盘之后再删让磁盘上的数据别丢。删除后要把Frame回收进free_list_同时清理page_table_里对应的映射关系并且释放Page IDDeallocatePage。3.3 Pin/Unpin和脏标记是一对要配合的孪生兄弟UnpinPage(page_id, is_dirty)这个函数日常使用频率很高它做的事情是在page_table_里找到Page ID对应的Frame。如果is_dirty为true把Page的脏标记置上。把pin_count减1。如果pin_count减到0了就把这个Frame交还给Replacer管理可以被置换。这里有一个非常容易犯的错UnpinPage不负责写回磁盘它只是标记这块内存我不用了可以腾地方了。真正的写回发生在两个时机一是这个Frame被选为Victim时二是手动调用FlushPage时。很多初学者写完之后发现数据一直对不上就是因为默认Unpin会刷盘结果时序全乱了。脏标记也是一样脏标记只是告诉置换器走之前要打扫不是现在就走。理解了这个你对整个Buffer Pool的生命周期就有了掌控感。3.4 并发到底怎么加锁这门课和工业界有什么差距Project 1最容易被测试打崩的地方就是并发。Bustub的buffer_pool_manager.h里已经有了一把std::mutex latch_最简单正确的做法是每一个公开API进来就锁上所有内部操作都是串行的。这样实现最简单、绝对安全、性能也确实不咋地但对于过测试来说完全足够。工业界的Buffer Pool是分片加锁的——比如按Frame ID做hash分段或者用std::shared_mutex区分读写锁。PostgreSQL的Buffer Manager用的是分区锁partitioned lock每个buffer partition一把锁就是为了降低全局锁的竞争。但那是伺候几万个并发连接用的你先跑通一把大锁再说优化。我当初犯的错误是把锁只加在page_table_查找上结果Victim和Fetch之间出了竞态测试里偶尔就挂一次那种随机性的bug比稳定复现的bug难查十倍。老老实实锁整个函数是新手在Project 1里最优的策略。4. 页面置换策略LRU、LRU-K与Clock算法到底该怎么选4.1 朴素LRU在数据库场景下为什么不够用LRULeast Recently Used的核心思想是最近被访问过的页面短时间内可能还会被访问优先淘汰最久没被访问的页面。实现上就是哈希表双向链表这个经典组合查哈希得frame、查链表得访问顺序O(1)完成置换。但数据库的工作负载里有一种典型场景能把LRU按在地上摩擦顺序扫描。假设你的缓冲池有100个Frame现在要全表扫描一张有1000个页的表。LRU本应该保留常用页但顺序扫描会把每个页都变成刚被访问过导致真正高频使用的B树根节点和内节点被无情地挤出去。这种缓存污染问题在数据库里非常致命。教科书里把这个现象叫LRU的扫描问题。Bustub的测试用例里没有故意刁难这一块但你要知道真实场景下没人敢直接用朴素LRU管缓冲池。4.2 LRU-K给每个页面装一个访问频率记录仪LRU-K的思路是不看你最后一次访问是什么时候而是看你第K次之前的访问历史。说白了它判断的不是你最近来过没而是你到底是不是一个常客。具体做法是为每个页面维护它最近K次访问的时间戳。页面被选中淘汰时比较的是每个页面的倒数第K次访问时间也就是第K新的那次访问的间隔时间。如果一个页面访问次数还没到K次那就用最后一次访问时间来比较。这样做的效果是临时被扫描到的页面哪怕刚用过一次由于它的访问次数很少、且倒数第K次时间很老它依然会被优先淘汰。而真正高频使用的页面即使中间隔了一段时间没访问过只要历史访问频率高就不容易被踢出去。Bustub的Project 1要用LRUReplacer但它要求的是朴素的LRU不是LRU-K。不过如果你有时间把LRU-K实现一遍会有完全不同的感悟。顺带一提PostgreSQL的Buffer置换策略也是时钟扫描Clock算法和LRU-K类似它的设计目标是在性能足够接近LRU的前提下把维护成本降到最低。它的做法是给每个页面一个引用位reference bit用一个环形指针不停地转访问过的页面把引用位置1被扫到时引用位为1就置0继续走为0就抓走。这个环形指针引用位的组合就是Clock算法开销极低而且不需要维护访问顺序的链表。4.3 置换器的实现技巧一个双向链表就够了Bustub里的LRUReplacer是独立于Buffer Pool的一个类维护的是一个unpinned pages的集合——那些pin_count0的页面才会被你登记进去被访问的页面会通过Pin()从里面移除被释放的页面通过Unpin()加进去。实现时建议直接用std::list同时维护一个unordered_mapFrameId, list迭代器用于O(1)查找和删除。Victim()就是从链表头部最久没用的那个拿一个出来别忘了先把迭代器从map里清掉。这个map和链表双维护的过程稍微一粗心就会在删除时留下脏迭代器然后运行时直接UB崩溃。我当初用了个调试用的assert辅助检查才把这个隐患逮住。注意LRUReplacer里维护的元素是Frame ID不是Page ID。因为置换器根本不需要关心某个Frame现在住的是哪一页它只负责挑一个Frame出来当受害者。4.4 什么时候用哪种算法心里要有本账场景推荐策略原因课程Project 1朴素LRU简单测试能过重点在理解框架数据库扫描密集LRU-K或Clock防顺序扫描污染高并发OLTPClock分区锁开销小锁竞争可控数据仓库/列存直接顺序预读大页缓存扫描为主缓存意义不大5. 数据移动的代价从磁盘到CPU到底有多远5.1 你以为的读文件实际上是什么流程很多人写操作系统课设时早就知道了read()系统调用但很少真正关注一次read()帮你做了什么。《数据库系统实现》这类书里都会告诉你一次随机磁盘读的延迟大约是毫秒级而一次内存访问是纳秒级两者相差了几个数量级。具体拆解一次磁盘读的代价应用程序发起read()系统调用陷入内核上下文切换几微秒。内核检查页是否在Page Cache里。不在就发起磁盘I/O。磁盘寻道寻到正确的磁道几毫秒 旋转延迟等着数据转到磁头下几十毫秒 传输时间真正传数据一个小页几十微秒。DMA把数据从磁盘控制器搬到内核缓冲区。内核再把数据从内核缓冲区拷贝到用户缓冲区。系统调用返回切回用户态。注意第5步一次read()至少发生一次内核态到用户态的数据拷贝。如果数据要跨过进程边界比如客户端请求还有更多次拷贝在等着。这也是为什么数据库系统会执着地研究直接I/ODirect I/O、异步I/OAIO、**零拷贝Zero-Copy**等黑科技。解决的核心问题只有一个让数据搬家少走几步路。5.2 数据移动在数据库里的几大场景磁盘到Buffer Pool就是你实现的那个ReadPage过程重点在减少随机I/O、增加顺序预读。Buffer Pool到存储引擎存储引擎拿到Page指针后直接把Page::GetData()这个字符数组当作记录区来解析这里已经避开了不必要的拷贝——只要你实现接口时别多复制一次数据到临时vector里就行。存储引擎到执行器执行器从叶子节点取出一条记录再往上层层传递。如果一层一个拷贝一个查询就多了几百次的memory copy。执行器到客户端结果集要写进网络缓冲区经网络协议栈发出去。这里涉及用户态和内核态的多次数据拷贝大数据量查询尤其痛。5.3 减少数据移动的实战手法手法一避免系统调用次数。用pwrite/pread代替write/read避免文件偏移量的维护用批量读写而不是一次一条记录地刷。高层面的意思是能少下几次内核态就少下几次。手法二Direct I/O 绕过Page Cache。数据库自己管理缓冲池后OS的Page Cache就变成了双重缓存——自家缓存一份OS又帮你缓存一份纯属浪费内存还引入一致性问题。于是有了O_DIRECT标志跳过OS Page Cache让磁盘数据直接进用户态缓冲池。但Direct I/O要求缓冲区对齐一般是512或4096字节而且失去了OS的预读能力需要自己实现预读。手法三向量化执行减少数据触达。现代分析型数据库ClickHouse、DuckDB都走向量化执行路线一次取一大批数据放进一个紧凑的内存数组里让CPU的SIMD指令批量处理减少逐行循环的开销。这本质上是把一次移动一条记录变成一次移动一整批记录用内存带宽换执行效率。手法四压缩减少磁盘和网络传输。磁盘I/O是瓶颈CPU压缩却是相对廉价的。数据页在写入磁盘前先压缩在读回后解压能把I/O量降低几倍。虽然多了压缩/解压的CPU开销但整体上还是划算。列存数据库尤其爱玩这一手。顺便说一句Bustub的Project 1还没有到这些花哨技巧的层面但如果你时间富余可以在实现完Buffer Pool之后给它加个顺序预读逻辑看看全表扫描的场景下性能提升有多明显——这个实验会让你对预读的价值产生身体记忆。6. 内存管理的并发细节Latch和Page状态管理6.1 为什么Page里面要放一把独立的锁你去看Bustub中Page类的定义会发现里面有个ReaderWriterLatch rwlatch_这个就是Page级别的锁。Buffer Pool的全局锁管的是哪个Page ID映射到哪个Frame而Page自己的latch管的是这个页面当前的内容正在被谁读/写。这两个锁不是同一层级的千万别搞混。实际执行中一个线程FetchPage拿到某个页面的指针后需要读页面里的记录。此时它应该持有这个Page的读锁RLatch防止另外的线程正在修改这个页面。如果所有线程都只依赖Buffer Pool的全局锁那就会变成任何一次页面访问都会把整个缓冲池锁住并发性直接完蛋。工业界则更进一步把Page latch设计成std::shared_mutex支持多读单写允许一个页面同时被多个线程读。6.2 Project中常见的一类死锁问题提到并发就绕不开死锁。Bustub的测试里最容易遇到的一种死锁是这样的事务A持有Page 1的写锁需要访问Page 2事务B持有Page 2的写锁需要访问Page 1互相等待死锁。Project 1里面你不需要去检测和解决死锁但也别把锁顺序搞乱。一个实用的经验是锁的顺序要全局一致比如始终先锁Page ID小的页面再锁Page ID大的页面这样就能破坏循环等待条件从根上避免死锁。Bustub测试虽然不一定能测出你实现里的死锁问题但写的时候保持好习惯能给后面的Project省下无数排查时间。6.3 深挖一下什么时候必须强制刷脏页刷脏页的时机不只是Victim被选中这一个。还有几种场景你必须主动刷正常关闭数据库时所有脏页都要落盘保证下次启动时数据都在。对应的函数就是FlushAllPages。检查点Checkpoint为了保证崩溃恢复时不需要回放太多WAL日志数据库会周期性地把所有脏页刷盘同时记录一个LSN日志序列号作为恢复点。Project没有要求你做检查点但理解这个机制对理解存储引擎至关重要。某个页面要被复用为其他页面时NewPage如果拿到一个可复用Frame而这个Frame里住着脏页必须先刷盘再复用否则这个页面的旧数据就丢了。我当初做Project 1的时候就是因为NewPage里忘了检查旧页面的脏状态导致数据随机丢了一部分。后来在测试日志里看到Lost page的错误才回过味来。7. 实操过程实录从零实现Buffer Pool Manager7.1 实现前先画好依赖图动笔之前先在你的脑子里建立这张依赖图DiskManager磁盘读写 ↓ BufferPoolManager分配Frame、管理映射、调用Replacer ↓ LRUReplacer决定牺牲谁DiskManager在Bustub里已经实现了你只需要调它提供的ReadPage和WritePage。BufferPoolManager协调一切而LRUReplacer被BufferPoolManager组合调用。7.2 分步实现编码思路我这里给一份我实现时的代码骨架帮大家理清思路。注意这不是完整代码只是关键逻辑你自己动手补全。// 构造新页面 Page *BufferPoolManager::NewPage(page_id_t *page_id) { std::lock_guardstd::mutex lock(latch_); frame_id_t frame_id; if (!free_list_.empty()) { frame_id free_list_.front(); free_list_.pop_front(); } else { if (!replacer_-Victim(frame_id)) { return nullptr; // 没有可用的Frame了 } } // 如果牺牲的Frame里有脏页先刷盘 if (pages_[frame_id].IsDirty()) { disk_manager_-WritePage(pages_[frame_id].GetPageId(), pages_[frame_id].GetData()); } // 从映射表中删掉旧映射 page_table_.erase(pages_[frame_id].GetPageId()); // 分配新的Page ID并登记 page_id_t new_page_id AllocatePage(); *page_id new_page_id; page_table_[new_page_id] frame_id; pages_[frame_id].Reset(); pages_[frame_id].SetPageId(new_page_id); pages_[frame_id].pin_count_ 1; replacer_-Pin(frame_id); // 这个Frame正在被用不能换出去 return pages_[frame_id]; }这段代码里最容易被忽略的是replacer_-Pin(frame_id)——如果你新分配的Frame没从Replacer里Pin掉它会一边被当前线程用着、一边被另一个线程当成Victim抢走页面会被同时写坏两个地方而且这种错误几乎是随机出现的非常让人抓狂。再来看UnpinPagebool BufferPoolManager::UnpinPage(page_id_t page_id, bool is_dirty) { std::lock_guardstd::mutex lock(latch_); auto it page_table_.find(page_id); if (it page_table_.end()) { return false; // 页面根本没在缓冲池里 } frame_id_t frame_id it-second; if (is_dirty) { pages_[frame_id].SetDirty(true); } if (pages_[frame_id].GetPinCount() 0) { return false; // 已经unpin过了再unpin是非法操作 } pages_[frame_id].pin_count_--; if (pages_[frame_id].GetPinCount() 0) { replacer_-Unpin(frame_id); } return true; }这里有个细节is_dirty这个参数是或逻辑不是覆盖逻辑。如果页面之前已经脏了你这次不脏地Unpin不能把脏标记清掉否则会导致前面的修改没写回磁盘。所以代码里用的是if (is_dirty) { SetDirty(true); }这种单向写法。7.3 跑通测试的那一刻你会发现一切值得Bustub的测试框架会把几十个Buffer Pool的测试用例跑起来里面有一堆并发测试。第一次跑全绿的时候我特别想截图发朋友圈——那种从概念模糊到代码跑通突破感几乎每个认真做Project 1的人都会经历。如果你过程中卡住了先把并发那部分注释掉单线程跑通基础功能再加锁搞并发不然你会发现bug交叉出现排查难度几何级上升。8. 常见问题与排查经验实录8.1 编译报错类问题1死锁检测报错MakeNewPage failed这个错误一般不是死锁而是你的NewPage没找到可用Frame。检查一下你的free_list_是不是已经空了而replacer_-Victim()也返回false。最可能的原因是页面pin_count一直没降到0导致所有Frame都被Pin住Replacer里一个可牺牲的对象都没有。问题2内存泄漏导致测试超时Bustub的测试会检测所有页面在测试结束后是否都释放掉了。如果你的DeletePage没有正确把Frame归还给free_list_或者page_table_里还有残留映射就会内存泄漏。这种问题一般在测试输出里直接报leaked page。8.2 逻辑错误类问题3脏标记覆盖导致数据丢失之前说了SetDirty(true)只能往脏的方向单方向变不能反向清。我见过有人在Unpin里写成SetDirty(is_dirty)结果一个页面本来脏的因为一次不脏的Unpin把脏标记抹掉后面当干净页面直接复用磁盘上旧数据没刷写数据就丢了。问题4LRUReplacer的Pin与Unpin理解相反很多人的Replacer里Pin和Unpin写反了Pin应该是从置换候选里移除因为正在用Unpin是加入置换候选因为用完了。Bustub的测试会疯狂地随机Pin/Unpin如果你语义搞反很快就出现页面被置换出去但还在被访问的恐怖错误。8.3 并发类问题问题5不加锁/部分加锁导致随机失败的竞态这是最恶心的一类bug测试跑100次99次过1次挂你根本不知道挂在哪。原因通常是你只在某些路径上加锁、某些路径上不加锁导致竞态窗口出现。排查思路把所有公开API都统一加锁跑100次如果稳定通过了再一步步去掉不必要的锁来优化性能。Bustub课程测试一般只要求功能正确不关心你的锁粒度有多细所以大锁是最稳的解。问题6低级调试工具不会用如果你在Linux上做建议用gdb配合catchsegv。不过对于这类逻辑bug有个更有效率的做法——在关键操作前后打日志打印每次Victim的frame_id、每次FetchPage的page_id、每次Unpin之后的pin_count。因为逻辑bug往往不是崩溃而是状态不对日志比断点好用得多。9. 数据移动在查询执行里的真正扮演者其实做Project 1时你只碰了内存管理但等到了Project 3Query Execution你会回来发现数据移动的代价远远不止磁盘到内存这一段。从执行节点的输出到下一个节点的输入从Store到Projection、从Join到Aggregation每一级算子之间都在搬数据。Bustub的火山模型Volcano Model里每个算子通过Next()吐出一个Tuple这个Tuple要在内存里被反复拷贝、传递、再拷贝。如果Project 1教会了你怎么把页搬进内存那么Project 3会教会你怎么让内存里的数据少搬几趟。到那时再回看这篇心得你对数据库系统如何玩转内存的理解就立体了。10. 我在实际做这个项目时的一些体会踩过几轮坑之后我最深的感受就是一切性能问题都是延迟问题一切延迟问题都是数据移动问题。磁盘到内存是毫秒级内存到CPU缓存是纳秒级CPU缓存到寄存器是皮秒级。CPU算得快不稀奇稀奇的是怎么让数据排队等它的时间最短。CMU 15445的Project 1把这个道理用一个Buffer Pool Manager压缩到了一节课的作业里属实是数据系统启蒙的神作。如果你也是正在啃这门课的战友最后送你几个实操建议。第一别抄代码哪怕卡到凌晨三点。这个项目的测试用例覆盖很全但你抄完的代码自己根本不会debug。自己写出来的代码哪怕丑后面Project 3遇到问题回来查也有感情基础。第二做完之后加一个预读Prefetch逻辑试试。在顺序扫描场景下给FetchPage加一个下一个页面也读了的操作性能会肉眼可见地提升。这个课外小实验能让你对顺序I/O为什么比随机I/O快那么多有切肤体验。第三把这篇学习心得和Bustub的测试代码对着一块看。测试里其实藏了很多边界条件比如Unpin一个不存在的页面、Fetch一个已经删除的页面、Delete一个正在被pin的页面——这些场景光看课件根本遇不到测试跑挂了才会记得牢。我当时就是靠一遍遍读测试用例反过来搞明白了Buffer Pool应该有的各种行为。数据库系统玩转内存的本质一句话说清楚就是少让数据搬家搬的时候知道优先级搬走之前记住回来。希望这篇心得能帮你在CMU 15445的长征路上省下几个本不该熬的夜。