传统毕业设计的常见缺陷

很多同学在做ATM机毕业设计时,往往只关注基础功能的实现,比如在控制台输入账号密码,然后进行简单的存取款操作。这种实现方式虽然能完成基本要求,但距离一个真实可用的系统还有很大差距。我总结了一下,主要有以下几个常见问题:

首先是没有并发控制。真实的ATM机是支持多用户同时操作的,如果多个线程同时操作同一个账户,比如同时取款,就可能导致余额计算错误,出现“超取”的情况。很多同学的单线程程序完全没考虑这个问题。

其次是没有事务管理。一次取款操作应该包含多个步骤:检查余额、扣减余额、生成流水记录。如果中间任何一步失败,整个操作都应该回滚,否则就会出现账户余额减少了但流水没记录,或者流水记录了但余额没扣减的混乱状态。

还有就是数据持久化方式过于简单。很多同学用文件存储或者干脆用内存变量,程序一重启数据就全丢了。而且缺乏完整的数据模型设计,账户信息、交易流水混在一起,不利于后续扩展。

ATM系统架构示意图

技术选型:从纯Java到Spring Boot生态

1. 基础技术栈对比

纯Java实现 vs Spring Boot框架

很多同学刚开始会想用纯Java写,觉得这样更“基础”。但实际开发中,Spring Boot能帮我们节省大量重复工作:

  • 依赖管理:纯Java需要手动管理各种jar包,版本冲突让人头疼。Spring Boot通过starter依赖一键配置
  • Web服务:纯Java需要自己写Servlet处理HTTP请求,Spring Boot内嵌Tomcat,几行代码就能启动Web服务
  • 数据库连接:纯Java用JDBC要写大量模板代码,Spring Boot + JPA/Hibernate让数据库操作变得简单

文件存储 vs 数据库存储

  • 文件存储:实现简单,但并发读写容易出问题,数据查询效率低,没有事务保证
  • MySQL数据库:支持ACID事务,有成熟的并发控制机制,SQL查询灵活,数据安全有保障

2. 我们的技术选型

基于以上分析,我选择了这样的技术栈:

  • 后端框架:Spring Boot 2.7.x(稳定版)
  • 数据库:MySQL 8.0 + Redis 7.0(缓存和分布式锁)
  • 构建工具:Maven
  • API文档:Swagger/OpenAPI 3.0

这个组合既保证了项目的完整性,又不会因为技术太新而增加学习成本。

核心模块设计与实现

1. 数据库设计

先来看数据库表的设计,这是整个系统的基础:

-- 账户表
CREATE TABLE account (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    account_number VARCHAR(20) UNIQUE NOT NULL COMMENT '账号',
    account_name VARCHAR(50) NOT NULL COMMENT '账户名',
    balance DECIMAL(15,2) NOT NULL DEFAULT 0.00 COMMENT '余额',
    password_hash VARCHAR(255) NOT NULL COMMENT '密码哈希',
    status TINYINT NOT NULL DEFAULT 1 COMMENT '状态:1-正常,0-冻结',
    created_time DATETIME DEFAULT CURRENT_TIMESTAMP,
    updated_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
    INDEX idx_account_number (account_number)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='账户表';

-- 交易流水表
CREATE TABLE transaction (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    transaction_id VARCHAR(32) UNIQUE NOT NULL COMMENT '交易流水号',
    account_number VARCHAR(20) NOT NULL COMMENT '账号',
    transaction_type TINYINT NOT NULL COMMENT '交易类型:1-存款,2-取款,3-转账',
    amount DECIMAL(15,2) NOT NULL COMMENT '交易金额',
    balance_before DECIMAL(15,2) NOT NULL COMMENT '交易前余额',
    balance_after DECIMAL(15,2) NOT NULL COMMENT '交易后余额',
    related_account VARCHAR(20) COMMENT '关联账户(转账时使用)',
    status TINYINT NOT NULL DEFAULT 1 COMMENT '状态:1-成功,0-失败',
    remark VARCHAR(200) COMMENT '备注',
    created_time DATETIME DEFAULT CURRENT_TIMESTAMP,
    INDEX idx_account_time (account_number, created_time),
    INDEX idx_transaction_id (transaction_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='交易流水表';

2. 账户服务实现

账户服务是整个系统的核心,我采用了分层架构:

// 账户实体类
@Entity
@Table(name = "account")
@Data
@NoArgsConstructor
@AllArgsConstructor
public class Account {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    
    @Column(name = "account_number", unique = true, nullable = false)
    private String accountNumber;
    
    @Column(name = "account_name", nullable = false)
    private String accountName;
    
    @Column(nullable = false, precision = 15, scale = 2)
    private BigDecimal balance;
    
    @Column(name = "password_hash", nullable = false)
    private String passwordHash;
    
    @Column(nullable = false)
    private Integer status;
    
    @CreationTimestamp
    @Column(name = "created_time", updatable = false)
    private LocalDateTime createdTime;
    
    @UpdateTimestamp
    @Column(name = "updated_time")
    private LocalDateTime updatedTime;
}

// 账户服务接口
public interface AccountService {
    /**
     * 用户登录验证
     */
    Account login(String accountNumber, String password);
    
    /**
     * 查询账户余额
     */
    BigDecimal queryBalance(String accountNumber);
    
    /**
     * 存款操作
     */
    TransactionResult deposit(String accountNumber, BigDecimal amount);
    
    /**
     * 取款操作(核心业务)
     */
    TransactionResult withdraw(String accountNumber, BigDecimal amount);
}

// 账户服务实现
@Service
@Slf4j
public class AccountServiceImpl implements AccountService {
    
    @Autowired
    private AccountRepository accountRepository;
    
    @Autowired
    private TransactionService transactionService;
    
    @Autowired
    private RedisTemplate<String, String> redisTemplate;
    
    @Override
    @Transactional(rollbackFor = Exception.class)
    public TransactionResult withdraw(String accountNumber, BigDecimal amount) {
        // 参数校验
        if (amount.compareTo(BigDecimal.ZERO) <= 0) {
            throw new BusinessException("取款金额必须大于0");
        }
        
        // 获取账户并加锁(防止并发操作)
        Account account = accountRepository.findByAccountNumberForUpdate(accountNumber);
        if (account == null) {
            throw new BusinessException("账户不存在");
        }
        
        // 检查账户状态
        if (account.getStatus() != 1) {
            throw new BusinessException("账户状态异常,无法取款");
        }
        
        // 检查余额是否充足
        if (account.getBalance().compareTo(amount) < 0) {
            throw new BusinessException("余额不足");
        }
        
        // 执行扣款
        BigDecimal oldBalance = account.getBalance();
        BigDecimal newBalance = oldBalance.subtract(amount);
        account.setBalance(newBalance);
        accountRepository.save(account);
        
        // 记录交易流水
        String transactionId = generateTransactionId();
        transactionService.recordTransaction(transactionId, accountNumber, 
            TransactionType.WITHDRAWAL, amount, oldBalance, newBalance);
        
        log.info("取款成功:账户={}, 金额={}, 余额从{}变为{}", 
            accountNumber, amount, oldBalance, newBalance);
        
        return new TransactionResult(true, transactionId, newBalance, "取款成功");
    }
    
    private String generateTransactionId() {
        // 生成唯一交易流水号:时间戳 + 随机数
        return "TXN" + System.currentTimeMillis() + 
               String.format("%06d", new Random().nextInt(1000000));
    }
}

3. 交易流水与幂等性控制

交易流水记录是金融系统的关键,必须保证幂等性(同一笔交易只能成功一次):

@Service
public class TransactionServiceImpl implements TransactionService {
    
    @Autowired
    private TransactionRepository transactionRepository;
    
    @Autowired
    private RedisTemplate<String, String> redisTemplate;
    
    // 交易幂等性控制:使用Redis记录已处理的请求ID
    private static final String TRANSACTION_IDEMPOTENT_KEY = "atm:txn:idempotent:";
    
    @Override
    @Transactional(rollbackFor = Exception.class)
    public void recordTransaction(String transactionId, String accountNumber, 
                                  TransactionType type, BigDecimal amount,
                                  BigDecimal balanceBefore, BigDecimal balanceAfter) {
        
        // 幂等性检查:如果这个交易ID已经处理过,直接返回
        String key = TRANSACTION_IDEMPOTENT_KEY + transactionId;
        Boolean isNew = redisTemplate.opsForValue().setIfAbsent(key, "1", 24, TimeUnit.HOURS);
        
        if (Boolean.FALSE.equals(isNew)) {
            log.warn("重复的交易请求,transactionId={}", transactionId);
            throw new BusinessException("重复的交易请求");
        }
        
        // 保存交易记录
        Transaction transaction = new Transaction();
        transaction.setTransactionId(transactionId);
        transaction.setAccountNumber(accountNumber);
        transaction.setTransactionType(type.getValue());
        transaction.setAmount(amount);
        transaction.setBalanceBefore(balanceBefore);
        transaction.setBalanceAfter(balanceAfter);
        transaction.setStatus(1); // 成功
        
        transactionRepository.save(transaction);
        
        log.info("交易流水记录成功:{}", transactionId);
    }
}

并发控制:从synchronized到分布式锁

1. synchronized关键字(单机场景)

在单机部署的情况下,可以使用synchronized保证线程安全:

@Service
public class SynchronizedATMService {
    
    // 使用对象锁保护关键资源
    private final Object lock = new Object();
    
    public TransactionResult withdrawWithSync(String accountNumber, BigDecimal amount) {
        synchronized (lock) {
            // 这里的代码同一时间只能有一个线程执行
            return doWithdraw(accountNumber, amount);
        }
    }
    
    // 或者使用方法级别的同步
    public synchronized TransactionResult withdrawWithMethodSync(String accountNumber, BigDecimal amount) {
        return doWithdraw(accountNumber, amount);
    }
}

适用场景

  • 单机部署的ATM系统
  • 用户量不大,并发请求少
  • 开发测试环境

局限性

  • 只能保证单个JVM内的线程安全
  • 性能较差,锁粒度粗
  • 不适合集群部署

2. Redis分布式锁(生产环境)

在生产环境多实例部署时,需要使用分布式锁:

@Component
@Slf4j
public class DistributedLockService {
    
    @Autowired
    private RedisTemplate<String, String> redisTemplate;
    
    private static final String LOCK_PREFIX = "atm:lock:account:";
    private static final long LOCK_EXPIRE_TIME = 30; // 锁过期时间30秒
    private static final long WAIT_TIME = 10; // 等待锁的最长时间10秒
    
    /**
     * 尝试获取分布式锁
     */
    public boolean tryLock(String accountNumber) {
        String lockKey = LOCK_PREFIX + accountNumber;
        String lockValue = UUID.randomUUID().toString();
        
        long startTime = System.currentTimeMillis();
        
        while (System.currentTimeMillis() - startTime < WAIT_TIME * 1000) {
            // 使用SETNX命令尝试获取锁
            Boolean success = redisTemplate.opsForValue()
                .setIfAbsent(lockKey, lockValue, LOCK_EXPIRE_TIME, TimeUnit.SECONDS);
            
            if (Boolean.TRUE.equals(success)) {
                log.debug("获取锁成功:{}", lockKey);
                return true;
            }
            
            // 短暂等待后重试
            try {
                Thread.sleep(100); // 100毫秒后重试
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
                break;
            }
        }
        
        log.warn("获取锁超时:{}", lockKey);
        return false;
    }
    
    /**
     * 释放分布式锁
     */
    public void unlock(String accountNumber) {
        String lockKey = LOCK_PREFIX + accountNumber;
        redisTemplate.delete(lockKey);
        log.debug("释放锁:{}", lockKey);
    }
}

// 在取款服务中使用分布式锁
@Service
public class DistributedATMService {
    
    @Autowired
    private DistributedLockService lockService;
    
    @Autowired
    private AccountService accountService;
    
    public TransactionResult withdrawWithDistributedLock(String accountNumber, BigDecimal amount) {
        // 尝试获取锁
        if (!lockService.tryLock(accountNumber)) {
            throw new BusinessException("系统繁忙,请稍后重试");
        }
        
        try {
            // 执行取款操作
            return accountService.withdraw(accountNumber, amount);
        } finally {
            // 无论如何都要释放锁
            lockService.unlock(accountNumber);
        }
    }
}

分布式锁示意图

安全性设计与实现

1. 防止余额透支

余额检查必须在事务内完成,并且要加锁:

@Service
public class SafeWithdrawService {
    
    @Autowired
    private AccountRepository accountRepository;
    
    @Transactional(rollbackFor = Exception.class)
    public void safeWithdraw(String accountNumber, BigDecimal amount) {
        // 使用SELECT ... FOR UPDATE获取行锁
        Account account = accountRepository.findByAccountNumberForUpdate(accountNumber);
        
        // 双重检查:在事务内再次验证余额
        if (account.getBalance().compareTo(amount) < 0) {
            throw new BusinessException("余额不足,取款失败");
        }
        
        // 执行扣款
        account.setBalance(account.getBalance().subtract(amount));
        accountRepository.save(account);
    }
}

2. 操作日志追踪

完整的操作日志对于问题排查和审计至关重要:

@Aspect
@Component
@Slf4j
public class OperationLogAspect {
    
    @Around("@annotation(operationLog)")
    public Object logOperation(ProceedingJoinPoint joinPoint, OperationLog operationLog) throws Throwable {
        String methodName = joinPoint.getSignature().getName();
        Object[] args = joinPoint.getArgs();
        
        // 记录操作开始
        String requestId = UUID.randomUUID().toString();
        log.info("操作开始[{}]:方法={}, 参数={}", requestId, methodName, Arrays.toString(args));
        
        long startTime = System.currentTimeMillis();
        
        try {
            // 执行原方法
            Object result = joinPoint.proceed();
            
            long costTime = System.currentTimeMillis() - startTime;
            log.info("操作成功[{}]:耗时={}ms, 结果={}", requestId, costTime, result);
            
            return result;
        } catch (Exception e) {
            long costTime = System.currentTimeMillis() - startTime;
            log.error("操作失败[{}]:耗时={}ms, 异常={}", requestId, costTime, e.getMessage(), e);
            throw e;
        }
    }
}

// 使用注解标记需要记录日志的方法
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface OperationLog {
    String value() default "";
}

// 在服务方法上使用
@Service
public class LoggedATMService {
    
    @OperationLog("用户取款操作")
    public TransactionResult withdrawWithLog(String accountNumber, BigDecimal amount) {
        // 业务逻辑
        return withdraw(accountNumber, amount);
    }
}

生产环境避坑指南

1. 数据库连接泄漏问题

数据库连接是宝贵资源,必须正确管理:

// 错误示例:手动管理连接容易忘记关闭
public void wrongQuery() {
    Connection conn = null;
    try {
        conn = dataSource.getConnection();
        // 执行查询
        // ... 
        // 如果这里抛异常,conn就不会被关闭!
    } catch (SQLException e) {
        e.printStackTrace();
    }
    // 忘记关闭连接
}

// 正确做法:使用try-with-resources或Spring的事务管理
@Repository
public class SafeAccountRepository {
    
    @Autowired
    private JdbcTemplate jdbcTemplate; // Spring会自动管理连接
    
    public Account findByAccountNumber(String accountNumber) {
        String sql = "SELECT * FROM account WHERE account_number = ?";
        return jdbcTemplate.queryForObject(sql, new BeanPropertyRowMapper<>(Account.class), accountNumber);
    }
}

// 或者在配置中设置连接池参数
@Configuration
public class DataSourceConfig {
    
    @Bean
    public DataSource dataSource() {
        HikariConfig config = new HikariConfig();
        config.setJdbcUrl("jdbc:mysql://localhost:3306/atm");
        config.setUsername("root");
        config.setPassword("password");
        config.setMaximumPoolSize(20); // 最大连接数
        config.setMinimumIdle(5); // 最小空闲连接
        config.setConnectionTimeout(30000); // 连接超时时间
        config.setIdleTimeout(600000); // 空闲连接超时
        config.setMaxLifetime(1800000); // 连接最大生命周期
        
        return new HikariDataSource(config);
    }
}

2. 冷启动延迟优化

应用启动时,可以预热缓存和连接池:

@Component
public class SystemWarmUp implements ApplicationRunner {
    
    @Autowired
    private AccountRepository accountRepository;
    
    @Autowired
    private RedisTemplate<String, String> redisTemplate;
    
    @Override
    public void run(ApplicationArguments args) throws Exception {
        log.info("开始系统预热...");
        
        // 预热数据库连接
        warmUpDatabase();
        
        // 预热Redis连接
        warmUpRedis();
        
        // 加载常用数据到缓存
        loadHotDataToCache();
        
        log.info("系统预热完成");
    }
    
    private void warmUpDatabase() {
        // 执行一些简单的查询,建立连接池
        accountRepository.count();
    }
    
    private void warmUpRedis() {
        redisTemplate.opsForValue().set("warmup", "1", 10, TimeUnit.SECONDS);
    }
    
    private void loadHotDataToCache() {
        // 加载常用账户信息到缓存
        List<Account> hotAccounts = accountRepository.findTop100ByOrderByUpdatedTimeDesc();
        // ... 缓存逻辑
    }
}

3. 测试数据隔离

测试环境必须使用独立的数据,避免影响生产:

// 使用Testcontainers进行集成测试
@SpringBootTest
@Testcontainers
class ATMServiceIntegrationTest {
    
    @Container
    static MySQLContainer<?> mysql = new MySQLContainer<>("mysql:8.0")
        .withDatabaseName("test_atm")
        .withUsername("test")
        .withPassword("test");
    
    @Container
    static GenericContainer<?> redis = new GenericContainer<>("redis:7.0")
        .withExposedPorts(6379);
    
    @DynamicPropertySource
    static void properties(DynamicPropertyRegistry registry) {
        registry.add("spring.datasource.url", mysql::getJdbcUrl);
        registry.add("spring.datasource.username", mysql::getUsername);
        registry.add("spring.datasource.password", mysql::getPassword);
        
        registry.add("spring.redis.host", redis::getHost);
        registry.add("spring.redis.port", redis::getFirstMappedPort);
    }
    
    @Test
    void testWithdraw() {
        // 测试代码使用独立的测试数据库
        // 不会影响生产数据
    }
}

// 或者在测试类上使用内存数据库
@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.ANY)
class AccountRepositoryTest {
    
    @Autowired
    private AccountRepository accountRepository;
    
    @Test
    void testSaveAccount() {
        // 使用H2等内存数据库进行测试
    }
}

4. 监控与告警配置

生产环境必须有完善的监控:

# application.yml 监控配置
management:
  endpoints:
    web:
      exposure:
        include: health,info,metrics,prometheus
  metrics:
    export:
      prometheus:
        enabled: true
  health:
    db:
      enabled: true
    redis:
      enabled: true

# 自定义健康检查
@Component
public class ATMHealthIndicator implements HealthIndicator {
    
    @Autowired
    private AccountRepository accountRepository;
    
    @Autowired
    private RedisTemplate<String, String> redisTemplate;
    
    @Override
    public Health health() {
        // 检查数据库连接
        try {
            accountRepository.count();
        } catch (Exception e) {
            return Health.down()
                .withDetail("database", "连接失败: " + e.getMessage())
                .build();
        }
        
        // 检查Redis连接
        try {
            redisTemplate.opsForValue().get("health_check");
        } catch (Exception e) {
            return Health.down()
                .withDetail("redis", "连接失败: " + e.getMessage())
                .build();
        }
        
        return Health.up()
            .withDetail("database", "连接正常")
            .withDetail("redis", "连接正常")
            .build();
    }
}

总结与思考

通过这个项目,我们实现了一个从简单控制台程序到生产级ATM系统的完整演进。关键点总结如下:

  1. 架构设计:采用分层架构,分离关注点,提高代码可维护性
  2. 并发控制:根据部署场景选择合适的锁机制(synchronized或分布式锁)
  3. 数据一致性:通过数据库事务和行锁保证余额计算的准确性
  4. 安全性:实现幂等性控制、操作审计、防重放攻击
  5. 可观测性:完善的日志记录和监控告警

这个项目不仅满足了毕业设计的基本要求,更重要的是展示了如何将一个学术项目升级为生产可用的系统。在答辩时,你可以详细讲解这些设计决策和技术选型,这会让你的项目脱颖而出。

思考题:如何扩展这个系统支持跨行转账?

跨行转账涉及更多复杂问题:

  • 如何保证分布式事务的一致性?
  • 如何处理不同银行之间的通信协议差异?
  • 如何设计对账和差错处理机制?
  • 如何保证跨行转账的安全性?

你可以尝试基于现有的ATM系统,设计一个跨行转账模块,考虑使用消息队列保证最终一致性,或者研究一下分布式事务解决方案如Seata。这会是项目一个很好的延伸方向。

希望这篇笔记能对你的毕业设计有所帮助。在实际开发中,记得多写测试、多考虑异常情况,这样才能构建出健壮的系统。祝你毕业设计顺利!

Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐