设计 ATM 系统(ATM System):面向面试的低层设计(LLD)完整实战指南
设计 ATM 系统(ATM System):面向面试的低层设计(LLD)完整实战指南
导读
本文以 problems/atm.md 为骨架,完整拆解 ATM 系统的低层设计(Low Level Design,LLD)题目:从需求分析、UML 类图到类/接口/枚举划分,并深入 awesome-low-level-design 仓库中 Java、C++、Go、C# 等多语言实现源码,讲解认证、余额查询、取款、存款、并发安全与现金分发的具体落地方式。读完本文,你将掌握 ATM 系统的对象建模方法,并能在面试中围绕"状态管理 + 并发控制 + 责任链出钞"展开有依据的设计论述。
一、需求分析:先界定系统的边界
在动手画类图之前,先明确系统的功能范围与约束。原题给出了 6 条核心需求,它们共同定义了 ATM 系统的最小可行能力:
- 基础操作:ATM 系统应支持余额查询(balance inquiry)、取款(cash withdrawal)和存款(cash deposit)。
- 身份认证:用户应能通过**银行卡(Card)+ 个人识别码(PIN)**完成身份认证。
- 银行后端交互:系统应与银行的后端系统交互,用于校验用户账户并执行交易。
- 现金出钞:ATM 应配备**现金分发器(Cash Dispenser)**向用户吐钞。
- 并发与一致性:系统应处理并发访问并保证数据一致性。
- 友好交互:ATM 应提供用户友好的界面供用户交互。
从面试角度看,这 6 条需求分别对应了设计的几个关注点:核心领域模型(账户/卡片/交易)、安全认证流程、外部系统集成边界、硬件抽象(出钞机)、并发安全(多 ATM 或多线程同时操作)、以及交互流程(状态机)。后续的所有类设计都应能追溯到这些需求,这是 LLD 面试中"需求驱动设计"的基本功。
二、整体架构与 UML 类图
原文档配套的类图位于 class-diagrams/atm-class-diagram.png,它勾勒了系统的静态结构:ATM 作为门面对外提供操作入口,内部协作 BankingService(银行业务)、CashDispenser(出钞硬件),并由 Card、Account、Transaction 及其子类构成领域模型。
三、核心类、接口与枚举:逐一定义
原文档明确了系统的类清单,下面逐类展开,并结合仓库实现说明其职责。
3.1 Card(银行卡)
Card 类代表一张 ATM 卡,包含卡号(card number)和 PIN。
在 Java 实现 entities/Card.java 中,Card 只持有 cardNumber 与 pin 两个字段;而 BankService.java 通过 createCard 创建卡片,并通过 linkCardToAccount 将卡片与账户建立映射。关键设计决策:卡片本身不校验 PIN,认证校验委托给银行服务(见 3.4),这保持了"卡是凭据、银行是权威"的职责划分。
3.2 Account(银行账户)
Account 类代表银行账户,包含账号(account number)与余额(balance),并提供**借记(debit)/ 贷记(credit)**余额的方法,即取款扣减、存款增加。
在 C++ 实现 Account.hpp 中,账户同时持有 accountNumber、pin、balance 三个私有字段,构造时默认余额为 0.0:
Account(std::string accountNumber, std::string pin, double initialBalance = 0.0);
bool deposit(double amount);
bool withdraw(double amount);
double getBalance() const;
在 Java 实现中,账户由 BankService.createAccount 创建(见 BankService.java),初始余额作为构造参数传入,例如 createAccount("1234567890", 1000.0)。
3.3 Transaction 抽象基类与子类(WithdrawalTransaction / DepositTransaction)
Transaction 是不同交易类型(取款、存款)的抽象基类,由 WithdrawalTransaction 和 DepositTransaction 两个子类扩展。这是典型的策略/模板模式的雏形:所有交易共享"交易 ID + 目标账户 + 金额"的结构,而具体的记账语义由子类实现。
在 Go 实现 transaction.go、withdrawal_transaction.go、deposit_transaction.go 中,交易被建模为携带金额与账户的领域对象,由 BankingService.ProcessTransaction 统一执行;Go 版还单独维护了 errors.go 统一错误语义(如余额不足、账户不存在)。交易 ID 由 atm.go 中的 generateTransactionID 生成:用 atomic.AddInt64 递增计数器并结合时间戳,格式为 TXN<yyyyMMddHHmmss><10位序号>,从源码结构看这保证了交易号在高并发下的唯一性与可追溯性。
3.4 BankingService(银行服务)
BankingService 管理银行账户并处理交易。原文档特别强调它使用线程安全的 ConcurrentHashMap 存储与检索账户信息——这是针对需求 5(并发访问 + 数据一致性)的直接回应。
Java 实现 entities/BankService.java 使用三个 ConcurrentHashMap 分别维护 accounts(账号 → 账户)、cards(卡号 → 卡片)、cardAccountMap(卡片 → 账户)三个映射:
private final Map<String, Account> accounts = new ConcurrentHashMap<>();
private final Map<String, Card> cards = new ConcurrentHashMap<>();
private final Map<Card, Account> cardAccountMap = new ConcurrentHashMap<>();
它对外提供 createAccount、createCard、linkCardToAccount、authenticate、getBalance、withdrawMoney、depositMoney 等方法。值得注意的设计点:
- 认证逻辑在这里:
authenticate(Card card, String pin)直接比较card.getPin().equals(pin),即银行服务才是 PIN 校验的权威方。 - 卡片与账户解耦:通过
cardAccountMap做多对一映射(一个账户可绑定多张卡),这种设计比在 Account 里硬编码单张卡更贴近真实银行体系。
Go 实现对应文件为 banking_service.go,职责与 Java 版对齐。
3.5 CashDispenser(现金分发器)
CashDispenser 代表 ATM 的出钞硬件,负责分发现金。原文档要求它使用同步机制保证出钞的线程安全(需求 5)。
Java 实现 entities/CashDispenser.java 用 synchronized 修饰 dispenseCash 与 canDispenseCash,并且**引入责任链模式(Chain of Responsibility)**来完成面额组合:
public synchronized void dispenseCash(int amount) {
chain.dispense(amount);
}
public synchronized boolean canDispenseCash(int amount) {
if (amount % 10 != 0) {
return false;
}
return chain.canDispense(amount);
}
出钞链在 ATMSystem.java 的构造器中组装:100 美元 ×10 张 → 50 美元 ×20 张 → 20 美元 ×30 张(NoteDispenser100/NoteDispenser50/NoteDispenser20,见 chainofresponsibility 目录)。canDispenseCash 首先校验金额必须是 10 的倍数(因为最小面额为 $20,链条按 $10 粒度约束),再沿链询问各面额能否凑出。
3.6 ATM 门面类
ATM 类是 ATM 操作的主入口(门面 Facade),负责协调 BankingService 与 CashDispenser,向用户暴露认证、余额查询、取款、存款四类操作。它屏蔽了底层银行服务与出钞硬件的复杂度。
- C++ 版 ATM.hpp 将 ATM 建模为持有账户列表、当前账户与会话状态的类,提供
authenticate/logout/deposit/withdraw/checkBalance/start。 - Java 版 ATMSystem.java 采用单例 + 状态模式:
ATMSystem通过getInstance()保证全局唯一实例,内部维护ATMState currentState,将插卡、输 PIN、选操作都委托给当前状态对象处理。 - Go 版 atm.go 同样聚合
BankingService与CashDispenser,且把"取款 = 记账 + 出钞"拆成两步:先bankingService.ProcessTransaction,成功后再cashDispenser.DispenseCash,保证账务与现金不会出现"扣了钱没出钞"的割裂。
3.7 ATMDriver / ATMDemo(驱动演示类)
ATMDriver 类用于演示系统用法:创建示例账户并执行 ATM 操作。它同时也是面试中的"自测入口"。
- C++ 版 ATMDemo.cpp 创建两个测试账户并启动交互式菜单:
ATM atm;
Account* account1 = new Account("1234", "5678", 1000.0);
Account* account2 = new Account("4321", "8765", 2000.0);
atm.addAccount(account1);
atm.addAccount(account2);
atm.start();
- Java 版 ATMDemo.java 走完整用例:插卡 → 输 PIN → 查余额 → 取款 → 存款 → 退卡。
四、多语言实现对照与运行方式
原文档给出了 5 个语言实现入口,全部存在于本仓库中:
| 语言 | 源码路径 | 特点 |
|---|---|---|
| Java | solutions/java/src/atm/ | 单例 + 状态模式 + 责任链出钞,结构最完整 |
| Python | solutions/python/atm/ | 轻量示例(含说明文档) |
| C++ | solutions/cpp/atm/ | 类层次简洁,交互式控制台程序 |
| C# | solutions/csharp/atm/ | .NET 风格实现 |
| Go | solutions/golang/atm/ | 交易对象 + 统一错误处理 |
以 C++ 版为例,编译运行方式(仓库为只读,仅演示查看与本地运行):
cd solutions/cpp/atm
g++ ATM.cpp Account.cpp ATMDemo.cpp -o atm
./atm
运行后按提示输入账号 1234、PIN 5678 即可进入菜单,依次体验查余额(1)、存款(2)、取款(3)、登出(4)、退出(5)流程。C++ 版 start() 的交互循环见 ATM.cpp 的 displayMenu/start:每个操作前都会检查 isAuthenticated 与 currentAccount,未认证时只允许输入账号 PIN 完成认证,体现了"先认证、后操作"的会话控制。
Java 版在 solutions/java 目录下按工程结构编译运行,入口为 atm.ATMDemo。
五、源码级深度剖析:三大设计主题
5.1 并发安全:从容器到出钞的全链路同步
需求 5 的落地体现在三个层次:
- 数据容器层:Java 用
ConcurrentHashMap(BankService.java)保证多线程读写账户映射无竞态。 - 硬件抽象层:
CashDispenser.dispenseCash/canDispenseCash加synchronized(CashDispenser.java),保证同一时刻只有一个线程在操作出钞硬件。 - 交易号生成层:Go 用
sync/atomic的AddInt64生成全局递增交易号(atm.go),避免多个并发现场产生重复交易 ID。
这组实现清晰地展示了 LLD 中"并发控制要贴着资源边界做"的原则:哪些资源是共享的(账户映射、出钞硬件),就在哪些资源上加锁。
5.2 会话与状态:用状态模式管理 ATM 流程
Java 版把"插卡 → 输 PIN → 选操作 → 退卡"建模为状态机,状态接口与实现位于 state 目录:
ATMState(接口):定义insertCard/enterPin/selectOperation/ejectCard。IdleState(空闲态):插卡后查询卡片,卡片不存在则直接退卡(IdleState.java)。HasCardState(已插卡态):等待用户输入 PIN。AuthenticatedState(已认证态):允许执行余额查询、取款、存款等敏感操作。
ATMSystem.changeState 负责状态切换(ATMSystem.java)。从源码结构看,这种设计的价值是:非法操作在状态层面就被拦截——比如未插卡时调用 enterPin,IdleState 直接打印"Error: Please insert a card first.",而不是进入业务逻辑。这比在 ATM 门面里堆一堆 if (state == ...) 更易扩展(新增"维护模式""吞卡状态"只需新增状态类)。
5.3 出钞面额组合:责任链模式
CashDispenser 内部挂载的出钞链(chainofresponsibility 目录)是责任链模式的经典应用:
DispenseChain定义链节点接口(setNextChain/dispense/canDispense)。NoteDispenser100/NoteDispenser50/NoteDispenser20各自只处理一种面额,处理不了就交给下一个节点。- 链的组装顺序即"贪心出钞"策略:优先大面额,剩余金额顺延小面额,如
ATMSystem构造器中的100×10 → 50×20 → 20×30。
同时 Java 的 withdrawCash 还做了补偿处理(ATMSystem.java):如果出钞阶段抛异常,会把已扣金额 depositMoney 退回账户,避免"扣账成功但未吐钞"的资金损失。这一细节体现了真实系统中"跨组件操作要具备回滚/补偿能力"的设计意识,是面试中的加分点。
5.4 取款流程的完整调用链(Java 视角)
综合上述分析,一次成功的取款操作在 Java 实现中的调用链为:
ATMSystem.selectOperation(OperationType.WITHDRAW_CASH, amount)委托给当前状态(AuthenticatedState)。- 状态内部调用
atmSystem.withdrawCash(amount)。 withdrawCash先调用cashDispenser.canDispenseCash(amount)预检金额(面额合法性 + 库存是否足够)。- 通过后调用
bankService.withdrawMoney(currentCard, amount)完成扣账。 - 最后
cashDispenser.dispenseCash(amount)触发责任链出钞;失败则回滚扣账。
操作类型枚举定义于 enums/OperationType.java。这条链路把本文 3.4 / 3.5 / 3.6 的职责串成闭环,可直接用于面试中的"走一遍流程"环节。
六、总结:从 ATM 题提炼的通用 LLD 方法论
ATM 系统是一道高频 LLD 题,它覆盖了面试官最常考察的几个维度,可迁移到其他系统设计:
- 领域建模:Card / Account / Transaction(含子类)的划分,回答了"谁持有数据、谁拥有行为"。
- 外部依赖抽象:BankingService(银行后端)与 CashDispenser(硬件)都被抽象成可替换的服务,便于测试与扩展。
- 并发与一致性:ConcurrentHashMap、synchronized、原子计数器,分别解决容器、硬件、ID 三类共享资源的竞态。
- 流程状态管理:状态模式把 ATM 的会话生命周期显式化。
- 算法与模式的组合:责任链解决面额组合,单例保证系统实例唯一,门面隔离复杂度。
对照 problems/atm.md 的需求清单,你可以按"需求 → 类 → 模式 → 并发 → 演示"的顺序在面试中复述完整设计,并结合本仓库的 Java 实现、C++ 实现、Go 实现 与 C# 实现 进行跨语言对比,展示你对设计的理解深度。
