ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

SpringBoot+SSM股票交易系统实战:从搭建到部署踩坑全记录

2026/9/26 7:42:36 拓冰建站 浏览量
SpringBoot+SSM股票交易系统实战:从搭建到部署踩坑全记录 SpringBootSSM的股票交易管理系统这个组合看起来老生常谈但真正落地的时候坑比想象中多得多。项目代号848是我给这个系统起的内部名字从数据库设计到最终跑通交易流程前后折腾了将近三周。今天把整个搭建过程、核心代码、踩过的坑一次性写清楚给正在做springboot、ssm项目学习或者毕设参考的朋友一个完整的实操样本。这个系统解决的核心问题很明确让用户能注册登录、查看股票行情、买入卖出股票、管理自己的持仓和资金账户同时管理员能维护股票数据、处理用户交易记录。本质上是一个典型的业务管理系统但因为涉及资金计算和交易状态流转比普通的增删改查多了一层业务复杂度。适合谁看如果你正在学SSM整合或者准备用SpringBoot做毕设、想搞明白一个带状态机性质的业务系统怎么设计这篇能帮你少走很多弯路。整个项目我采用的是SpringBoot 2.x Spring MVC MyBatis的经典架构前端用的是Thymeleaf模板引擎加一点原生JS数据库选了MySQL连接池用Druid权限控制直接杀用拦截器实现。没有引入Redis和MQ这些重型中间件就是为了让系统保持“可易读性”——你能一眼看懂每个请求从Controller到Service到Mapper的完整链路而不是被各种中间件分散注意力。1. 项目整体设计与思路拆解1.1 技术选型背后的考量先说为什么还用SSM而不是SpringBoot全家桶。SpringBoot本身并没有取代SSM它只是一个自动配置的壳子核心依然是Spring容器、SpringMVC和MyBatis。所以标题里写“springbootssm”其实非常准确——这就是在SpringBoot里面整合SpringMVC和持久层框架。选择这种组合的好处是第一MyBatis的手写SQL可控性强。股票交易系统里有很多复杂的统计查询比如计算持仓均价、当前盈亏、按时间分组查交易明细这些场景用MyBatis的XML文件写SQL比JPA和MyBatis-Plus的自动生成方法要直观得多。第二SSM的体系成熟稳定学习资料多遇到问题能快速搜索到解决方案。第三SpringBoot帮我们处理了配置文件、内嵌Tomcat、自动装配这些繁琐的东西可以把精力集中在业务逻辑上。举个例子如果纯粹用SpringMVCMyBatis的老式配置要写一堆XML配置文件定义组件扫描、视图解析器、数据源、事务管理器一套下来没半天搞不定。SpringBoot把这些全部简化了一个SpringBootApplication就能启动项目需要什么配置就往application.yml里面填这是效率上的本质区别。1.2 功能模块划分与核心流程股票交易管理系统我把功能拆成了六个模块用户模块、股票信息模块、自选股模块、股票交易模块、持仓与资金模块、后台管理模块。没有做K线图和模拟分时图因为对于SSM练习项目来说那更多是前端的活而且需要引入ECharts和大量数据对接我会放在扩展部分说思路。交易模块是整个系统的核心也是最容易出bug的地方。买入股票时系统要做以下几件事检查账户余额是否足够、计算买入数量和手续费、扣减资金、修改持仓数据、写入交易流水。卖出时反过来检查持仓数量、计算收益、加回资金、更新或删除持仓记录。这整个过程必须在同一个数据库事务里完成否则可能出现资金扣了但持仓没变的情况。我用了Spring的声明式事务Transactional手动标记这个在后面实操章节详细说。1.3 数据库设计的几个关键决策数据库设计我总共建了7张表这里只挑核心的讲。用户表id、用户名、密码MD5加密存储、资金余额、创建时间。股票信息表股票代码、名称、当前价、涨跌幅、成交总量等。持仓表用户id、股票代码、持仓数量、成本价、当前市值。交易流水表用户id、股票代码、交易类型买入/卖出、成交数量、成交价格、成交金额、手续费、交易时间。自选股表用户id、股票代码。关键设计点有两个。一是持仓表不应该存储实时的股票价格和市值而是只在查询时通过left join联合股票表计算当前市值。二是交易流水表必须有成交时间字段并且建议建索引因为用户查看历史记录的时候一般是按时间倒序没有索引的话数据量上来会非常慢。我在实际测试的时候给trade_time建了普通索引查询速度从300ms左右降到了20ms。2. 核心细节解析与实操要点2.1 SpringBoot整合SSM的关键配置项目我使用Maven构建SpringBoot版本选了2.7.18这个版本在JDK8和JDK11下都稳定网上资料也多不建议一上来就用3.x因为3.x改成了Jakarta命名空间很多东西不兼容。引入的核心依赖就这几个spring-boot-starter-web、spring-boot-starter-thymeleaf、mybatis-spring-boot-starter、mysql-connector-java、druid-spring-boot-starter。版本号直接用SpringBoot parent管理的默认值就行不用手动指定避免版本冲突。网上很多人问MyBatis分页插件的用法现在用的是PageHelper只要引入pagehelper-spring-boot-starter在MyBatis配置文件里加一个拦截器插件然后在Service层调用PageHelper.startPage(pageNum, pageSize)后面的第一条查询就会自动带上limit。这里有一个我强烈推荐的细节application.yml里面的配置项要写全特别是MyBatis的mapper-locations和type-aliases-package很多人忘了配导致启动报错或者映射文件找不到。我这里的配置长这样spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/stock_trade?useUnicodetruecharacterEncodingutf-8serverTimezoneAsia/ShanghaiuseSSLfalse username: root password: 123456 type: com.alibaba.druid.pool.DruidDataSource mybatis: mapper-locations: classpath:mapper/*.xml type-aliases-package: com.stock.model configuration: map-underscore-to-camel-case: true log-impl: org.apache.ibatis.logging.stdout.StdOutImpl可以留意一下map-underscore-to-camel-case这个配置数据库字段是user_idJava属性是userId开启这个配置之后MyBatis会自动帮我们映射不用在resultMap里手动写一堆字段映射。还有StdOutImpl开发时候强烈建议开着能看到MyBatis执行的每条SQL排查问题特别管用。线上环境关掉就行。2.2 权限控制怎么设计才靠谱我没有引入Spring Security或者Shiro原因是对于这个系统的规模来说拦截器加Session已经足够了而且代码更直白。系统里分用户和管理员两种角色用户在登录成功后放入Session针对需要权限的URL写一个AuthInterceptor拦截器来判断Session是否存在对管理员请求加一个角色判断。Component public class AuthInterceptor implements HandlerInterceptor { Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { HttpSession session request.getSession(); User user (User) session.getAttribute(loginUser); String uri request.getRequestURI(); if (uri.startsWith(/admin) !admin.equals(user.getRole())) { response.sendRedirect(/login); return false; } if (user null) { response.sendRedirect(/login); return false; } return true; } }但这套方案有一个致命的缺陷我后来才发现。系统重启之后Session直接丢失用户好不容易登录的状态没了。更合理的做法是把登录凭证存入RedisRedis没挂之前Session都能共享。不过因为我们没用Redis就接受这个限制毕竟这是一个学习项目重点是业务逻辑而不是高可用。如果你的项目想显得更完整建议在这一步引入spring-session-data-redis配置起来也很快。2.3 股票交易流程的状态设计交易流程是系统的灵魂。我最开始按直觉来买入就是更新数据库的几个字段。后来发现这个思路太粗糙比如用户连续买入同一只股票今天买了100股明天又买200股持仓成本怎么算这时候需要引入加权平均成本的计算逻辑。买入时新成本价的计算公式(原有持仓市值 本次买入金额 本次手续费) / (原有持仓数量 本次买入数量)。卖出的时候我采用移动加权平均成本作为卖出成本这样卖出盈亏(卖出价格-持仓成本)×卖出数量-手续费。这种算法虽然不是最精确的FIFO或移动加权但在个人股票账户模拟场景中足够实用。为了不让交易方法里的代码变成一坨大杂烩我把交易拆分成了几个小方法checkBalance、updateBalance、updatePosition、insertTradeRecord。每个方法只干一件事这样出了问题也容易定位。在后面实操章节我会放出完整代码。3. 实操过程与核心环节实现3.1 从零搭建项目的完整步骤第一步用IDEA的Spring Initializr创建SpringBoot项目选好Web、Thymeleaf、MyBatis依赖。如果网络好直接用start.spring.io生成两步搞定。第二步连接MySQL数据库创建库和表我建议先用CREATE DATABASE stock_trade DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci注意是utf8mb4不是utf8否则存emoji或者生僻字的时候会编码错误。第三步在pom里补齐依赖然后写application.yml。第四步创建对应的包结构controller、service、mapper、entity、interceptor、common等。可能很多人会问为什么entity和mapper要分开还要写XML来扫描用注解写SQL不行吗行但只适合简单的增删改查。股票行情按时间统计这种多表联查的SQL用注解写会满屏Select字符串拼接里到处是空格和逗号很难维护。我自己把所有的SQL都写在StockMapper.xml里统一管理看起来整整齐齐。3.2 核心交易代码实现详解这里放出买入股票的核心Service代码这个类包含了主要的业务逻辑Service public class TradeServiceImpl implements TradeService { Resource private AccountMapper accountMapper; Resource private PositionMapper positionMapper; Resource private TradeRecordMapper tradeRecordMapper; Resource private StockInfoMapper stockInfoMapper; Override Transactional(rollbackFor Exception.class) public boolean buyStock(Long userId, String stockCode, int buyAmount, double price) { // 1. 查询用户资金 Account account accountMapper.findByUserId(userId); double balance account.getBalance(); // 2. 计算总金额和手续费这里手续费按万三计算最低5元 double totalCost price * buyAmount; double commission Math.max(totalCost * 0.0003, 5.0); double totalDeduct totalCost commission; if (balance totalDeduct) { throw new BusinessException(账户余额不足); } // 3. 更新账户余额 accountMapper.deductBalance(userId, totalDeduct); // 4. 更新持仓——有则加仓计算新的加权成本无则插入 Position position positionMapper.findByUserIdAndCode(userId, stockCode); if (position null) { Position newPos new Position(); newPos.setUserId(userId); newPos.setStockCode(stockCode); newPos.setStockName(stockInfoMapper.findNameByCode(stockCode)); newPos.setAmount(buyAmount); newPos.setCostPrice(price commission / buyAmount); positionMapper.insert(newPos); } else { int oldAmount position.getAmount(); double oldCostPrice position.getCostPrice(); double newAmount oldAmount buyAmount; // 加权平均成本加上本次交易手续费 double newCostPrice (oldAmount * oldCostPrice totalCost commission) / newAmount; positionMapper.updatePosition(userId, stockCode, newAmount, newCostPrice); } // 5. 插入交易流水 TradeRecord record new TradeRecord(); record.setUserId(userId); record.setStockCode(stockCode); record.setTradeType(BUY); record.setTradeAmount(buyAmount); record.setTradePrice(price); record.setTradeFee(commission); record.setTradeTime(new Date()); tradeRecordMapper.insert(record); return true; } }买入逻辑我在实际测试的时候最常出问题的地方就是手续费是否计入成本以及单位是否统一。很多人把amount数量和price每股价格的单位混了导致计算出来的总价差了100倍。所以动手写之前先把公式列出来用单元测试去验证。我给自己写了一个简单的测试类每次改完代码跑一遍确保买入、卖出、查询三者之间的数据是一致的。卖出逻辑类似不同的地方是卖出要检查持仓数量是否够卖出后如果剩余数量为0就从持仓表删除同时把卖出的资金加回账户盈亏金额需要记录下来。3.3 分页查询与股票列表展示股票列表和管理交易记录必须分页否则数据一多前端卡死。这里用MyBatis分页插件PageHelper最省事。在Service层调用之前必须确保PageHelper.startPage()和后面的查询在同一个线程中执行中间不能有任何别的查询语句否则PageHelper会自动把最近的查询当成分页对象。这个坑我踩过一次在startPage之后先查了一次数据库来获取别的信息结果PageHelper把它当成主查询分页直接失效。Controller层的处理也很重要需要把PageHelper返回的PageInfo对象转成统一结果集返回给前端。我封装了一个Result类里面放code、message、data三个字段前端拿到之后统一处理。这样前后端交互规范不会出现一会在data里面一会在list里面的混乱局面。3.4 Thymeleaf模板与表单提交细节前端我没有做前后端分离直接用了Thymeleaf服务端渲染。好处就是避免CORS跨域问题数据直接通过ModelAndView塞进模板。但Thymeleaf有个坑如果页面里面有JavaScript需要接收后端传来的List数据得格外小心。我这里用了一个很土但有效的方法把JSON字符串放进页面隐藏域JavaScript读取后再JSON.parse()。这比硬塞到script标签里安全得多因为直接塞到script标签会引入XSS风险而我把数据放到input typehidden里在解析之前先正则过滤掉敏感字符。表单提交也用了一点小技巧。比如买卖股票我直接用了两个表单一个action指向/trade/buy另一个指向/trade/sell。提交时把股票代码、价格、数量通过隐藏字段传递。这样每个表单的功能单一后端处理逻辑也更清晰。不用前端去拼一堆复杂的Ajax JSON对SSM学习项目来说简单可靠压倒一切。3.5 Docker部署的实战配置项目开发完成后部署发现直接java -jar跑没问题但每次都要手动敲命令太蠢了。后来我用Docker容器化部署写了一个最简单的DockerfileFROM openjdk:8-jre-alpine COPY target/stock-trade-0.0.1-SNAPSHOT.jar /app.jar EXPOSE 8080 ENTRYPOINT [java,-jar,/app.jar]然后在服务器上用docker build -t stock-trade .构建镜像docker run -d -p 8080:8080 --name stock-trade stock-trade启动。这里提醒大家一个细节如果数据库也在Docker容器里网络要选--networkhost模式或者配置--link否则应用容器连不上数据库容器。更推荐的做法是直接使用docker-compose同时编排MySQL和应用容器不用手动去处理网络问题。4. 常见问题与排查技巧实录4.1 MyBatis分页插件失效问题这是被问得最多的一个问题。特征很典型加入了PageHelper依赖调用PageHelper.startPage(1, 10)查询返回的结果没有分页直接返回了所有数据。排查思路是这样第一确认引入的是pagehelper-spring-boot-starter而不是老版的pagehelper两者的自动配置机制不同。第二确认startPage之后的第一条SQL语句是不是你真正要分页的那个查询如果之间有别的方法调用了MapperPageHelper会作用到那个方法上。第三确认你的Mapper接口方法是List返回类型而不是封装后的ResultMap。我自己检查后发现是因为我startPage之后在同一个方法里先查了一条用户信息用于记录日志导致分页错乱。解决方式很简单把日志记录放到异步线程或者放到分页查询之后执行。另一个隐藏很深的原因就是MyBatis版本和SpringBoot版本不兼容。SpringBoot 3.x和PageHelper早期版本有冲突如果你用的是SpringBoot 3.x尽量升级到最新的PageHelper版本。这个坑在搜索热词里有springboot 3.5.x说明很多人已经在用高版本了记得配好版本的兼容组合。4.2 事务回滚为什么没生效交易系统最怕的是钱扣了持仓没加这种情况几乎都是事务配置错了。我的经验是要严格在类或者方法上加上Transactional(rollbackFor Exception.class)注意这个rollbackFor参数非常重要。Spring默认只对RuntimeException进行回滚如果你在Service方法里抛出了一个自定义的Exception但是不继承RuntimeException那么事务是不会自动回滚的。有些同学在业务方法里用throw new Exception结果数据库里数据变了代码却没有回滚原因就是这个。我在买入方法里如果余额不足会抛出自己写的BusinessException extends RuntimeException这样才能触发回滚。还有一个坑是Transactional只对通过Spring代理调用的方法生效。如果你在同一个类里一个方法调另一个方法默认情况下this.buyStock()这种直接调用不会走代理事务不生效。解决办法是把调用拆到不同的Service类里或者用AopContext.currentProxy()强制代理调用我选择了第一种方式代码结构也清晰一些。4.3 并发请求导致超卖和超额扣款这里是我这次项目里最值得说的经验。当两个请求同时发生的时候假如账户有10万块钱同时要买两笔各8万的股票在没有并发控制的情况下两笔请求都能通过余额检查最后余额变成-6万这明显是严重的资金缺陷。我一开始没意识到这个问题只做了普通的逻辑校验后来用Jmeter模拟并发请求测了一下马上暴露了。怎么解决我用了两条最稳妥的路径。一是数据库层面的行锁在查询用户账户余额的时候加上SELECT ... FOR UPDATE这样两个并发请求会排队执行。二是使用乐观锁更新在account表加一个version字段更新的时候检查version是否和当前读取的一致不一致则重试或者抛异常。对于股票交易系统行锁的方式更简单直接代价是并发性能会弱一点但学习项目够用了。select idfindByUserIdForUpdate resultTypeAccount SELECT * FROM account WHERE user_id #{userId} FOR UPDATE /select在事务里调用这个方法MySQL会在这一行记录上加X锁直到事务提交才释放。这样第二个事务的余额检查就会阻塞到第一个事务结束从而避免了资金同时被扣的问题。我强烈建议做任何资金交易类的系统都必须在数据库层面加上这种锁机制单纯在应用层加锁解决不了多实例部署的场景。4.4 前端页面刷新出缓存旧数据开发的时候改了Thymeleaf模板刷新浏览器却显示的是旧页面这个问题折腾我很长时间。原因是SpringBoot默认开启了Thymeleaf缓存解决方法是开发环境中把缓存禁用。spring: thymeleaf: cache: false这个配置太重要了不关掉的话每次改完HTML都要重启应用体验极差。同时建议使用spring-boot-devtools依赖这样可以实现热更新改完模板甚至不用重启刷新页面就能看到效果。对应的搜索热词里也有springboot thymeleaf热更新说明踩的人都聚到一块了。4.5 排查与调试的常用工具清单聊到排查我把自己顺手且靠谱的工具拉了个单子。数据库工具推荐Navicat或者DataGrip但真正要确认锁的等待情况时Navicat就不如直接敲SQL方便。查询锁等待情况可以用SELECT * FROM information_schema.INNODB_TRX; SELECT * FROM information_schema.INNODB_LOCK_WAITS;这会直接告诉你哪个事务在等哪个锁比凭感觉猜靠谱多了。对于HTTP接口的排查写代码的时候把接口文档放到位用Postman测一遍再配合IDEA的Debug模式打断点定位。但最关键的一招还是日志。我在application.yml里开了SQL日志输出同时也加了logback配置把com.stock包的日志级别调成Debug这样MyBatis执行的SQL、传入的参数、返回的结果都能看到。很多时候你以为代码算错了看日志发现其实是SQL的where条件多了个空格。别笑这种低级错误在联调的时候真能遇到。5. 进阶扩展从SSM管理型系统到生产可用的迭代路径5.1 引入Redis缓存股票行情数据现在整个系统里每次股票列表页面的刷新都会直接查询数据库的股票信息表。这个表虽然在现在的数据量下没什么压力但要想做成一个更像样的业务系统应该把热点数据缓存起来。我建议的做法是用Redis做缓存Key设计为stock:info:{code}Value存入股票信息的JSON字符串。查询的时候先查Redis不存在再查数据库然后写入Redis并设置缓存过期时间。行情数据一般要求不高缓存个5秒钟完全够用。SpringBoot整合Redis现在非常方便引入spring-boot-starter-data-redis之后配置好连接信息直接注入StringRedisTemplate就能用了。不过要注意序列化方式如果使用默认的JdkSerializationRedisSerializer在Redis可视化工具里看到的是乱码字节码。建议在Config里手动指定使用GenericJackson2JsonRedisSerializer这样存的是JSON调试起来一目了然。5.2 用消息队列处理交易异步通知交易买入卖出属于写操作在用户量上来之后同步处理会让用户等待很久。可以考虑把一些非核心流程比如交易后的通知、邮件发送、持仓快照生成丢给DelayQueue或者RabbitMQ处理。搜索热词里有activemq和emqx这些都是消息中间件但思路是类似的。具体做法是交易主流程完成之后把交易结果打包成一个消息发到MQ专门的服务去消费消息做后续处理。这样用户的请求响应时间会大幅缩短也能更好地保护核心交易链路。这个扩展对于毕设加分挺明显的。5.3 可视化K线图与股票走势我在这个版本里有意识地没有做K线图不是做不出来而是想先把后端搞透。等到想扩展前端的时候引入ECharts后端提供一个查询历史价格的接口返回数组数据前端用K线图组件渲染。要注意的是历史价格数据通常需要定时任务去拉取或者模拟生成这里和第三方接口对接要处理好频率和权限问题。如果只是学习用途直接在数据库里生成随机历史数据画图就够了重点在于理解ECharts的data格式怎么和后端对接。5.4 系统性能优化的几个方向如果你想让项目在答辩中更有说服力可以从性能角度讲三个优化点。第一是SQL层面的索引优化在trade_record表的user_idcreate_time建联合索引可以明显加速历史交易查询。第二是数据库连接池的参数调整Druid默认配置偏保守可以设置initialSize5、maxActive20具体数值根据机器内存调整。第三是代码层面的减负比如Session里的用户对象别放太重的字段我的user对象只放id、用户名、角色这三个字段能省不少内存。6. 踩坑记录与经验心得汇总6.1 那些平时文档里不写但特别实用的知识点Header的内容被拦截器忽略。我在写拦截器的时候一开始没有排除登录请求的静态资源路径导致登录页面的CSS、JS全被拦截下来页面显示得一塌糊涂。解决方式是在WebMvcConfigurer里给拦截器添加excludePathPatterns把/login、/register、/css/**、/js/**都排除掉。这个细节看起来简单但估计有半数人栽过。字段命名规范直接影响merge效率。我当初建表的时候把一些字段用了下划线命名java代码用驼峰然后开启了map-underscore-to-camel-case。这个挺好。但问题出现在写XML的resultMap的时候如果你标明propertystockCode columnstock_code那么就算不开驼峰转换也能正常工作。一旦你依赖开启驼峰转换那么务必保持resultMap的结果类型是JavaBean而不是Map否则MyBatis无法清楚映射。这也是一个很隐蔽的坑。金额的类型选择要慎重。千万不要用浮点型来存钱MySQL里的double类型在加减乘除时会有精度误差Java里也推荐用BigDecimal。我在这次项目里所有资金相关的字段都用了DECIMAL(20, 2)Java实体类对应BigDecimal计算的时候统一用BigDecimal避免0.10.2!0.3的问题。这是个典型的金融系统常识但在学习项目中常常被忽略。6.2 从848项目中收获的开发经验这个项目做到后期我越来越感觉到做业务系统最重要的不是炫技而是把正确的流程用最简单可靠的方式串起来。SSM框架的每个组件都在扮演自己独特的角色Spring管对象SpringMVC管请求分发MyBatis管数据持久化。SpringBoot则把这些整合成本拉低了很多所以我们更快地把精力放在业务流程的设计上。我个人在实际操作中的体会是如果你也想做一个类似的系统务必先画出业务状态流转图把买卖股票、资金变动、持仓变动的所有路径都列出来再动手写代码。画图的过程会逼着你把异常分支想清楚比如用户同时买了两笔不同的股票资金是否够用如果第二笔因为资金不足而回滚第一笔应该保持原样这些都是分布式事务要考虑的问题但在我们的单体项目里通过一个事务内的顺序执行就能解决。这个思考过程就是项目经验的价值所在。最后再分享一个小技巧做任何SpringBootSSM项目的时候在启动类旁边加一个MapperScan注解扫描所有Mapper接口然后XML文件里没有对应的接口也不用担心启动报错。如果你漏了MapperScanSpring容器里就没有Mapper的代理Bean依赖注入直接失败整个项目启动都启动不了。第一次遇到这个错误时我以为自己依赖配错了搞了好几个小时才恍然大悟。愿你们少踩点坑一次跑通。