1. 项目整体定位与方案选型思路拆解
1.1 股票门户网站到底在解决什么问题
很多人看到“股票门户网站系统”这几个字,第一反应是“不就是把行情数据展示出来吗”。真上手做过的人都知道,这类项目的难点从来不是“把数字显示在页面上”,而是在数据高频变化、用户请求集中、页面信息密度极高的三重压力下,把信息组织得让人一眼能看懂。它本质上是一个信息聚合与分发系统,核心链路只有四段:采集数据、清洗入库、按需查询、可视化呈现,但每一段都有大量细节会把新手卡住。
我把这类系统的用户角色拆成三层,后面的所有设计都围绕这三层展开。第一层是游客,进来就是看大盘指数、涨跌幅榜、个股详情,不需要登录,要求首屏快、不白屏;第二层是注册用户,核心诉求是“自选股”和“资讯订阅”,他每天打开就只想看自己关注的那十几只,其他一律不关心;第三层是管理员,负责资讯发布、股票基础信息维护、用户管理。三层角色的数据权限、缓存策略、接口粒度完全不同,如果一开始不分开设计,后面改起来就是灾难。
还有一个容易被忽略的定位问题:门户网站和交易系统是两回事。门户只做信息展示与检索,不涉及任何委托、撮合、资金流转。这个边界必须在开题阶段就写清楚,一方面它决定了你的工作量边界,另一方面它也决定了你的系统在答辩或评审时不会因为“功能不完整”被追问。我见过不少项目做到一半想加“模拟交易”,结果把自己拖进了一个无底洞,最后连基本的行情列表都没打磨好。
适合参考这套内容的人有三类:正在做类似课程设计或毕业设计的同学,想找一个结构完整、能跑通、讲得清的项目;刚入行的后端开发者,想了解一个真实的信息聚合类系统怎么分层、怎么缓存、怎么防并发;还有想自己搭个小工具盯盘的技术爱好者,直接拿骨架改成自己顺手的形态。代码层面的东西我会给到能直接抄的程度,但更重要的是每一步“为什么这么做”。
1.2 技术栈选型:为什么是这套组合而不是别的
标题里“基于”后面的技术名词被截断了,这在同类项目里非常常见,通常是“基于 Spring Boot 的”“基于 SSM 的”“基于 Vue + Spring Boot 的”。我按最常见的、也是我认为最合理的组合来讲:后端 Spring Boot + MyBatis-Plus + MySQL + Redis,前端 Vue 3 + Element Plus + ECharts,采集侧用定时任务 + HTTP 客户端。如果你要换成别的,思路完全通用。
先解释后端为什么选 Spring Boot 而不是原生 Servlet 或 SSM 手写 XML。行情类系统的接口特点是“数量多但逻辑薄”,一个股票详情页可能要并发调用基础信息、最新快照、分时数据、相关资讯四五个接口,每个接口都是查表、组装、返回。Spring Boot 的自动配置和注解式开发能把单个接口的开发成本压到很低,而 SSM 那套 XML 配置在一个有几十个接口的项目里,光是维护 mapper 映射就够喝一壶。这不是技术优劣问题,是投入产出比问题。
前端为什么坚持前后端分离而不是 JSP 或 Thymeleaf 服务端渲染?关键在图表。分时图、K 线图这类可视化组件,主流方案都是基于 Canvas 的 JavaScript 库,需要在前端拿到结构化数组后自行渲染。服务端渲染塞一堆数据进模板,再靠内联脚本初始化图表,页面会变得极其难维护,而且首屏之后的所有交互(切换周期、缩放、悬浮查看)都得写额外的 AJAX,等于两套逻辑并存。前后端分离之后,后端只负责吐 JSON,前端专心处理渲染和交互,职责非常干净。
数据库选 MySQL 而不是时序数据库,理由也很实在:这个量级根本用不上。一只股票一天的分钟级数据是 240 条,全市场按几千只算,一天几十万条,MySQL 加上合适的索引和分区策略完全扛得住。Redis 则承担两个职责:热点行情的短周期缓存和分布式锁。前者是为了扛住开盘时段的集中刷新,后者是为了防止定时采集任务在多实例部署时重复执行。这两个用途后面都会展开讲。
| 层次 | 选型 | 核心理由 | 替代方案的坑 |
|---|---|---|---|
| 后端框架 | Spring Boot | 注解式开发,接口多时维护成本低 | 原生 Servlet 配置繁琐,SSM 的 XML 易失控 |
| 持久层 | MyBatis-Plus | 单表 CRUD 零 SQL,复杂查询仍可手写 | 纯 JPA 在复杂关联查询上不灵活 |
| 数据库 | MySQL 8 | 数据量适中,生态成熟,运维简单 | 时序库学习成本高,对本项目属于过度设计 |
| 缓存 | Redis | 热点数据缓存 + 分布式锁一把梭 | 仅用本地缓存多实例下会不一致 |
| 前端 | Vue 3 + Element Plus | 组件生态全,后台管理页面开发快 | 纯手写 DOM 在表格和表单上效率极低 |
| 图表 | ECharts | 分时图、K 线图开箱即用 | 自研 Canvas 渲染周期长、坑多 |
1.3 分层架构与包结构设计
一个能长期维护的项目,包结构必须在第一天就定好,后面只往里填,不来回挪。我通常按这样的方式组织后端:
com.example.stockportal ├── common 全局返回体、异常、常量、工具类 │ ├── Result.java │ ├── GlobalExceptionHandler.java │ └── constant ├── config 跨域、拦截器、Redis、定时任务配置 ├── controller 对外接口层,只做参数校验和调用 ├── service 业务逻辑,接口 + impl ├── mapper MyBatis-Plus 的 Mapper ├── entity 与表一一对应的实体 ├── dto 入参对象 ├── vo 出参对象(脱敏、聚合后的视图) └── task 定时采集任务这里有几个我踩过坑才坚持的约定。entity 绝不直接返回给前端,因为表结构里往往有内部字段(比如数据来源标记、更新时间戳、软删除位),直接透出去既冗余又有隐患,统一用 vo 转换。controller 里不写业务判断,只做参数合法性和登录态校验,剩下的全丢给 service,否则同一段逻辑在多个接口里复制粘贴,改一处漏三处。全局返回体统一,形如{code, msg, data},前端写一个统一的请求拦截器就能处理所有错误提示,不用每个接口单独判断。
登录态这块我倾向于用 JWT 放在请求头里,而不是 Session。原因不是 JWT 更“先进”,而是这个系统通常会部署成前后端分离甚至跨端口,Session 的 Cookie 同源策略会带来一堆跨域配置麻烦。JWT 的代价是无法主动失效,解决办法是在 Redis 里存一份 token 白名单或黑名单,登出时删掉对应 key,校验时多查一次 Redis,这点开销可以忽略。
2. 核心模块拆解与数据结构设计
2.1 行情数据模型:先把表设计对,后面少改十次
这类系统最容易返工的地方就是表结构。我的经验是把“基础信息”和“时序数据”彻底分开,前者变化极少,后者天天新增,混在一张表里会导致基础信息被反复冗余写入,索引也会越来越臃肿。
股票基础信息表只存不变的属性:
CREATE TABLE stock_info ( id BIGINT PRIMARY KEY AUTO_INCREMENT, stock_code VARCHAR(16) NOT NULL COMMENT '股票代码', stock_name VARCHAR(64) NOT NULL COMMENT '股票名称', market VARCHAR(16) NOT NULL COMMENT '所属市场标识', industry VARCHAR(64) DEFAULT NULL COMMENT '所属行业', list_date DATE DEFAULT NULL COMMENT '上市日期', status TINYINT NOT NULL DEFAULT 1 COMMENT '1正常 0停牌', create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE KEY uk_code (stock_code), KEY idx_industry (industry) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='股票基础信息';行情快照表存最新一笔,只保留每个代码一行,用唯一索引做覆盖更新:
CREATE TABLE stock_quote ( id BIGINT PRIMARY KEY AUTO_INCREMENT, stock_code VARCHAR(16) NOT NULL, current_price DECIMAL(12,3) DEFAULT NULL COMMENT '最新价', change_amount DECIMAL(12,3) DEFAULT NULL COMMENT '涨跌额', change_rate DECIMAL(8,3) DEFAULT NULL COMMENT '涨跌幅百分比', open_price DECIMAL(12,3) DEFAULT NULL, pre_close DECIMAL(12,3) DEFAULT NULL, high_price DECIMAL(12,3) DEFAULT NULL, low_price DECIMAL(12,3) DEFAULT NULL, volume BIGINT DEFAULT NULL COMMENT '成交量', turnover DECIMAL(20,2) DEFAULT NULL COMMENT '成交额', quote_time DATETIME DEFAULT NULL COMMENT '行情时间', UNIQUE KEY uk_code (stock_code), KEY idx_change_rate (change_rate) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='最新行情快照';K 线表按周期存,用period字段区分日线、周线、月线,避免为每个周期建一张表:
CREATE TABLE stock_kline ( id BIGINT PRIMARY KEY AUTO_INCREMENT, stock_code VARCHAR(16) NOT NULL, period VARCHAR(8) NOT NULL COMMENT 'D/W/M', trade_date DATE NOT NULL, open_price DECIMAL(12,3) NOT NULL, close_price DECIMAL(12,3) NOT NULL, high_price DECIMAL(12,3) NOT NULL, low_price DECIMAL(12,3) NOT NULL, volume BIGINT DEFAULT NULL, UNIQUE KEY uk_code_period_date (stock_code, period, trade_date) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='K线数据';金额字段统一用DECIMAL而不是DOUBLE,这一点必须强调。浮点数在反复累加和格式化时会出现0.1 + 0.2 = 0.30000000000000004这种问题,价格字段一旦出现这种尾巴,前端展示就会变成一长串小数,非常难看。DECIMAL(12,3)能覆盖绝大多数价格精度需求,成交额用DECIMAL(20,2)是因为它的量级大得多。
2.2 数据采集侧:定时任务怎么写得不容易崩
采集是整个系统的心脏,它挂了这个门户就是个空壳。我见过太多项目在这里图省事,直接在 Controller 里挂一个手动触发的接口,每次访问就顺手拉一次数据,结果页面上线第一天接口就被刷爆了。
正确的做法是定时任务独立跑,与请求链路完全解耦。用 Spring 的@Scheduled配合 cron 表达式,按交易时段和非交易时段设置不同的频率:
@Component public class QuoteCollectTask { @Resource private StringRedisTemplate redisTemplate; // 交易时段:每 60 秒采集一次快照 @Scheduled(cron = "0 */1 9-11,13-14 * * MON-FRI") public void collectDuringTrading() { doCollect("trading"); } // 收盘后:补一次收盘数据,并落 K 线 @Scheduled(cron = "0 5 15 * * MON-FRI") public void collectAfterClose() { doCollect("close"); } private void doCollect(String scene) { String lockKey = "lock:quote:collect:" + scene; // 30 秒过期,防止实例崩溃后锁无法释放 Boolean ok = redisTemplate.opsForValue() .setIfAbsent(lockKey, "1", Duration.ofSeconds(30)); if (Boolean.FALSE.equals(ok)) { return; // 已有实例在执行,直接跳过 } try { // 实际采集逻辑:拉取、校验、批量 upsert } finally { redisTemplate.delete(lockKey); } } }这段代码有三个点值得说。第一,cron 里限定星期一到星期五,周末不跑,减少无谓请求,虽然节假日还会空跑,但影响可控,真要精确可以再维护一张交易日历表做判断。第二,用 Redis 的setIfAbsent做分布式锁,而不是 synchronized,因为线上很可能是多实例部署,JVM 级别的锁在跨进程时形同虚设,会导致同一份数据被重复写入、任务被重复触发。第三,锁必须设置过期时间并且放在 finally 里释放,只做释放不设过期,一旦实例在持锁期间被强杀,这把锁就永远死在那了。
采集到的数据还要做一层校验才能入库,常见的脏数据有三类:价格为空或为 0、涨跌幅算出来明显离谱、时间戳比库里已有数据还旧。这三种情况都要丢弃并记录日志,宁可少一条,不要把脏数据写进去污染整张表。批量写入用INSERT ... ON DUPLICATE KEY UPDATE,依靠唯一索引实现覆盖更新,比“先查再决定插还是改”少一次数据库往返,在批量场景下差距很明显。
2.3 自选股与用户模块:看似简单,坑在并发和一致性
自选股功能表面上就是一张关联表,增删查三条 SQL。但实际操作里有两个问题必须提前想清楚。
第一个是重复添加。用户手抖点两下,或者前端请求超时自动重试,很容易往表里塞两条一样的记录。解决办法是建表时直接加唯一索引,让数据库来兜底,不要指望前端按钮置灰能拦住所有情况:
CREATE TABLE user_watchlist ( id BIGINT PRIMARY KEY AUTO_INCREMENT, user_id BIGINT NOT NULL, stock_code VARCHAR(16) NOT NULL, sort_no INT NOT NULL DEFAULT 0 COMMENT '排序号', create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_user_code (user_id, stock_code), KEY idx_user (user_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户自选股';第二个是自选股列表要展示实时价格,这就把“用户模块”和“行情模块”耦合起来了。如果每次打开自选股页面都去关联查询行情表,用户越多压力越大,而且行情表每 60 秒就整体刷新一次,缓存命中率会很难看。我的处理方式是:自选股接口只返回代码列表(带顺序),行情由前端再调一次批量行情接口,后端用一个IN查询加 Redis 缓存兜底。这样职责清晰,缓存也能复用——同一个行情缓存可以被首页榜单、自选股、个股详情三处共用。
密码存储必须用 BCrypt 这类带盐的慢哈希,绝对不能明文或者简单 MD5。这一点在很多学生项目里被忽略,但它属于那种“一旦被指出来就很尴尬”的问题。BCrypt 每次加密结果都不同,校验时用matches方法比对,实现起来只有一行代码,没有理由不用。
2.4 资讯与后台管理:内容运营的骨架
资讯模块的设计重点是状态流转,而不是简单的增删改查。一条资讯从录入到对外可见,中间至少要经过“草稿、待审、已发布、已下线”几个状态,直接插入就展示的做法,一旦录入有误就会直接暴露给所有访客。表里加一个status字段,前台查询永远带上status = 1的条件,后台则按状态筛选,逻辑就清晰了。
后台管理的另一个重点是分页与检索。股票基础信息可能有几千条,管理员要按代码或名称模糊搜索,用LIKE '%xxx%'在数据量上来之后会全表扫描。在几千条的规模下还能忍,但我建议至少给stock_code加上前缀索引意识,并且把列表接口的每页条数限制在 100 以内,防止有人手动改 URL 参数拉一万条出来把数据库拖死。
提示:后台接口一定要做角色校验,不能只判断“有没有登录”。普通注册用户和管理员走的是同一套登录体系时,如果只在拦截器里校验 token 有效性,普通用户拿到后台接口地址照样能调通,这是同类项目里出现频率最高的越权问题。
3. 实操过程与核心环节实现
3.1 环境准备与项目启动顺序
跑通这套系统的顺序很关键,顺序错了会出现“前端起来了但满屏报错”的情况,让人误以为是代码问题。我的标准流程是:数据库 → Redis → 后端 → 前端,一步一验证。
环境版本上,JDK 建议 8 或 17(看你用的 Spring Boot 版本,2.7 配 8,3.x 必须 17),MySQL 8.0,Redis 6 以上,Node 16 以上。版本不匹配是新手最常见的翻车点,尤其是 Spring Boot 3.x 强依赖 JDK 17,用 JDK 8 编译会直接报一堆看不懂的错。
# 1. 建库并导入初始脚本 mysql -u root -p -e "CREATE DATABASE stock_portal DEFAULT CHARSET utf8mb4;" mysql -u root -p stock_portal < sql/init.sql # 2. 启动 Redis(默认端口即可) redis-server # 3. 后端编译运行 mvn clean package -DskipTests java -jar target/stock-portal-1.0.0.jar # 4. 前端安装依赖并启动 npm install npm run dev配置文件里的数据库连接、Redis 地址、采集接口地址这几项一定要抽到application-{env}.yml里,不要把密码硬编码在代码中。这不是洁癖,是因为你早晚要在本机、测试机、演示机之间来回切换,改代码里的常量迟早会漏改一处。
3.2 批量行情接口的实现与缓存设计
行情列表是这个系统里访问量最大的接口,必须重点打磨。它的逻辑其实很简单:按涨跌幅排序,取前 N 条,关联基础信息拿到名称。问题在于调用频率。
先算一笔账。假设日活 2000 人,其中三成集中在开盘后半小时访问,行情页平均停留 5 分钟、每 3 秒刷新一次,那么这半小时内的请求数大约是2000 × 0.3 × (1800 / 3) ≈ 36 万次,折算下来峰值接近200 QPS。如果每个请求都直接查 MySQL,一台普通配置的机器大概率会开始出现慢查询堆积。
解决办法是给行情列表加一层短 TTL 的 Redis 缓存。因为数据本身 60 秒才更新一次,缓存 3 到 5 秒完全不会影响准确性,却能把这 200 QPS 中的绝大部分挡在数据库之外:
public List<QuoteVO> listTopChange(int limit) { String cacheKey = "quote:top:change:" + limit; String cached = redisTemplate.opsForValue().get(cacheKey); if (StringUtils.hasText(cached)) { return JSON.parseArray(cached, QuoteVO.class); } List<QuoteVO> list = quoteMapper.selectTopByChangeRate(limit); // TTL 设为 3 秒,比采集周期短,保证数据不会太陈旧 redisTemplate.opsForValue() .set(cacheKey, JSON.toJSONString(list), Duration.ofSeconds(3)); return list; }TTL 的选择是有讲究的。设得太长(比如 60 秒),用户会明显感觉到“榜单个股价格不动”;设得太短(比如 1 秒),缓存基本失效,等于白加。3 秒是个经验值:比采集周期短一个数量级,同时又足以把同一秒内的并发请求合并掉。这里还有个隐藏细节——缓存击穿。如果某个 key 恰好在高并发瞬间过期,所有请求会同时打到数据库。可以在缓存为空时加一个短锁,只放一个请求去查库,其余短暂等待后重试,实现成本很低,收益明显。
3.3 分时图与 K 线图的数据组织
图表是这个系统的门面,数据格式对不上,前端画出来就是一条直线或者一片空白。ECharts 的 K 线图接收的数据是一个二维数组,每项形如[开盘, 收盘, 最低, 最高],顺序不能错,错了图形会完全变形。
后端返回结构我一般设计成这样:
{ "code": 200, "data": { "dates": ["2024-01-02", "2024-01-03", "2024-01-04"], "values": [[10.20, 10.55, 10.10, 10.60], [10.55, 10.30, 10.25, 10.62], [10.30, 10.78, 10.28, 10.80]], "volumes": [120000, 98000, 156000] } }前端拿到之后直接喂给配置项,不需要再做转换:
const option = { xAxis: { type: 'category', data: res.data.dates }, yAxis: { scale: true }, series: [ { type: 'candlestick', data: res.data.values, itemStyle: { color: '#e34b4b', // 上涨颜色 color0: '#2ba36a', // 下跌颜色 borderColor: '#e34b4b', borderColor0: '#2ba36a' } } ] }这里有个必须提前处理的问题:数据量。日线一年 240 多条还好,但如果用户切到分钟级,一天就是 240 条,一年几万条,全量返回会让接口响应变慢、前端渲染卡顿。做法是后端按周期和区间做限制,比如分钟级最多返回最近 5 个交易日,日线最多返回 500 根,超出部分通过缩放交互触发增量加载。别指望前端一次吃下几万个点还能流畅缩放,实测下来超过两千个点,拖拽就开始掉帧。
分时图则是另一套数据:横轴是交易时间点,纵轴是价格,通常还要叠一条均价线。均价线的计算方式是用累计成交额除以累计成交量,这个计算放在后端做,前端只负责画,能省掉不少浏览器里的浮点运算开销。
3.4 数据库查询优化与索引验证
写完功能之后一定要做一件事:打开慢查询日志,把每个接口实际跑的 SQL 都看一遍。我见过太多项目的列表页在本地几十条数据下飞快,一导入全量数据就卡死,原因无一例外是全表扫描。
验证方法很直接,用EXPLAIN看执行计划:
EXPLAIN SELECT s.stock_code, s.stock_name, q.current_price, q.change_rate FROM stock_quote q JOIN stock_info s ON s.stock_code = q.stock_code WHERE s.status = 1 ORDER BY q.change_rate DESC LIMIT 20;如果type列出现ALL,说明在做全表扫描,需要补索引。涨跌幅榜单这种按change_rate排序取前 N 的场景,给该字段建索引之后,配合LIMIT可以走索引顺序扫描,避免额外排序。关联字段stock_code在两张表上都必须有索引,尤其是被驱动表,否则每匹配一行都要扫一遍。
还有一个隐性的性能陷阱:N+1 查询。比如资讯列表要展示每条资讯关联的股票名称,如果写成“先查 20 条资讯,再循环查 20 次股票表”,就是典型的 N+1。正确做法是一次性把涉及的所有代码收集起来,用一条IN查询捞回来在内存里做映射。这个改动通常能把一个接口的耗时从几百毫秒压到几十毫秒,且代码量并不增加。
3.5 打包部署与反向代理配置
本地跑通只是第一步,能稳定部署才算完成。前端npm run build之后产物是一堆静态文件,后端是一个 jar 包,两者放在同一台机器上,用反向代理统一入口,可以避免跨域配置带来的各种麻烦。
server { listen 80; server_name localhost; # 前端静态资源 location / { root /opt/app/dist; index index.html; # 单页应用刷新 404 的经典解法 try_files $uri $uri/ /index.html; } # 后端接口转发 location /api/ { proxy_pass http://127.0.0.1:8080/; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_read_timeout 30s; } # 静态资源缓存,减少重复下载 location ~* \.(js|css|png|jpg|woff2)$ { root /opt/app/dist; expires 7d; } }配置里try_files那一行是必须的,否则用户在前端某个子路由页面按 F5,服务器会去找一个不存在的文件,直接返回 404。这个问题在开发阶段用 dev server 时不会暴露,一上线就立刻出现,属于“必踩”的坑。
后端启动脚本建议加上内存参数和日志输出,别用默认配置裸跑:
nohup java -Xms512m -Xmx1024m \ -Dspring.profiles.active=prod \ -jar stock-portal-1.0.0.jar \ > logs/app.log 2>&1 &4. 常见问题与排查技巧实录
4.1 行情数据不准或不刷新
这是反馈最多的问题,现象是首页数字长时间不动,或者个股详情和列表页的价格对不上。排查顺序我固定为三步。
第一步看采集任务有没有真的在跑。在任务方法首尾打日志,如果只有开始日志没有结束日志,说明中间抛异常了,多半是外部接口超时或返回格式变了。加个超时时间和异常捕获,别让一次失败把整个任务链断掉。
第二步看缓存有没有及时失效。如果缓存 TTL 设得比采集周期还长,就会出现“库里已经更新了,页面上还是老数据”。我一般把缓存 TTL 设成采集周期的三分之一左右,并且把采集完成的时间戳单独存一个 key,前端展示时可以带上“数据更新时间”,让用户心里有数。
第三步看数据源本身的一致性。列表页和详情页如果走了不同的缓存 key 或者不同的查询路径,就有可能出现短暂不一致。解决办法是让它们共享同一个数据源和同一套缓存逻辑,本质上是“单一数据源”原则。
4.2 图表渲染卡顿与内存泄漏
分时图和 K 线图切来切去之后页面越来越卡,最后浏览器直接卡死,这个问题在图表类项目里极其常见。根因通常是每次切换都新建一个图表实例,却没有销毁旧的。ECharts 的实例会挂载大量事件监听和 Canvas 上下文,不销毁就会一直堆积。
正确写法是在组件卸载或重新初始化前,先判断实例是否存在并调用dispose:
import * as echarts from 'echarts' let chartInstance = null function renderChart(dom, option) { if (!chartInstance) { chartInstance = echarts.init(dom) } else { // 复用实例,只更新数据,比重新 init 快得多 chartInstance.clear() } chartInstance.setOption(option) } // 组件销毁时务必调用 function destroyChart() { if (chartInstance) { chartInstance.dispose() chartInstance = null } }另一个性能点是定时刷新时的整图重绘。如果每 3 秒就重新setOption一次完整配置,图表会频繁闪烁。用setOption(option, { notMerge: false })做增量更新,只传变化的数据部分,视觉上会平滑很多。
4.3 定时任务重复执行与数据重复写入
现象是数据库里出现了两条完全一样的行情记录,或者同一分钟内采集日志打了两次。前面提过分布式锁是主要解法,但还有一个容易忽略的来源:开发和部署时的多实例。本地调试时开着 IDE 跑一份,同时又跑着打包后的 jar,两个进程都注册了定时任务,自然就重复了。
排查时先看进程数,ps -ef | grep java看看是不是起了多个。确认只有一个进程还重复,就去看锁的 key 是否一致、锁的过期时间是否短于任务执行时间。如果任务本身执行超过 30 秒而锁只有 30 秒过期,第二个实例就会在第一个还没跑完时拿到锁,照样重复。锁的过期时间必须显著大于任务最大执行耗时,这一点比锁本身还重要。
4.4 接口跨域与登录态失效
本地开发时前端跑在 5173 端口,后端在 8080,跨域问题必现。配置里加允许来源、允许方法、允许请求头,同时要注意如果用了自定义的认证头,需要显式声明allowedHeaders,否则浏览器预检请求会失败,报错信息还挺迷惑人。
登录态失效的典型表现是“登录成功了,但一刷新就变回未登录”。如果用的是内存变量存 token,页面刷新就丢了,必须落到localStorage或sessionStorage。如果用了 Redis 存 token 白名单,则要检查 Redis 的连接配置和 key 的过期时间,很多时候是 token 的 Redis TTL 设得比 JWT 自身的过期时间短,导致 JWT 还没过期,白名单记录先没了。
4.5 常见问题速查表
| 现象 | 大概率原因 | 快速验证方式 | 处理办法 |
|---|---|---|---|
| 首页行情不刷新 | 缓存 TTL 过长 / 采集任务异常 | 直接查库看数据是否更新 | 缩短 TTL,给采集加异常捕获与日志 |
| 列表与详情价格不一致 | 两处走了不同缓存 key | 对比两接口返回的 quote_time | 统一数据源与缓存 key |
| 图表切换后越来越卡 | 图表实例未销毁 | 打开性能面板看内存曲线 | 卸载时调用 dispose |
| 行情记录重复 | 多实例部署 / 锁过期过早 | 查进程数,看采集日志条数 | 加分布式锁并延长过期时间 |
| 刷新页面 404 | 单页应用路由未回退 | 直接访问子路由地址 | 反向代理加 try_files |
| 登录后刷新丢失状态 | token 存内存 / Redis TTL 过短 | 看浏览器存储与 Redis key | 落地存储,统一过期时间 |
| 列表页加载慢 | 全表扫描 / N+1 查询 | EXPLAIN 看执行计划 | 补索引,改批量 IN 查询 |
| 价格显示一长串小数 | 用了浮点类型 | 查表字段类型 | 改 DECIMAL 并按位格式化 |
4.6 几条用血换来的实操心得
别在采集任务里做业务计算。我一开始图省事,在采集时顺便把涨跌幅、均价线都算好存库,看起来很高效,结果后来业务要改计算口径,历史数据全得重刷。正确做法是采集只负责“拿到原始数据并入库”,计算放到查询侧或者单独的聚合任务里,随时可以重跑。
给数据加时间戳,永远比你想的重要。每个快照、每条 K 线、每次采集都带上时间,出问题时第一眼看时间就能判断是数据源的问题还是你系统的问题,能省掉大量猜谜时间。
前端先做骨架,后端再填肉。我习惯先把页面布局、路由、接口 mock 全部搭完,看着像个完整系统了,再回头一个个实现真实接口。这样能避免“后端写了一堆没人用的接口”,也能在早期就发现数据结构设计不合理的地方。
导入全量数据再谈优化。本地拿十条数据测出来的性能毫无意义。上线前一定造一批接近真实的量(比如几千只股票 × 几百个交易日的 K 线),在这个基础上压测和看慢查询,才知道哪里真的需要加缓存、加索引。
写开题和文档时,把“边界”写清楚。明确说明系统只做信息展示、不涉及任何交易功能,明确说明数据来源是外部公开接口而非自建行情源。这既是技术诚实,也能避免后续被追问一堆超出范围的需求。文档里的架构图、时序图用文字和表格描述清楚数据流向,比堆一堆花哨的图形更有说服力。
最后分享一个我在实际项目里养成的小习惯:给每个核心接口都加一个极简的耗时日志,用拦截器统一打印接口名、耗时、是否命中缓存。上线初期这份日志能让你在用户反馈之前就发现问题接口,尤其是在行情刷新这种高频路径上,效果非常直接。