做了这么多年全栈开发,带过的项目从几十个人的内部系统到日活过万的业务平台都有,我越来越觉得“部署上线”这四个字才是整个开发流程里最考验基本功的一环。代码在本地跑得再欢,打不成包、上不了线、扛不住访问,前面所有工作都等于白做。这一课《项目部署上线》是整个Java全栈教程的第50课,也是把前面所有知识串成一条完整链路的最后一课:从Spring Boot后端、Vue前端,再到MySQL、Redis、Nginx,整套体系怎么在一台干净服务器上从零跑起来,我会把每一步的取舍逻辑和我在真实项目中踩过的坑都说清楚。
不管你是在准备Java面试、走Java全栈学习路线,还是手头刚做完一个想给朋友或面试官演示的项目,这篇内容都值得你从头到尾走一遍。它能帮你解决一个特别实际的问题:项目写完了,怎么让它真正“活”在一台大家都能访问的服务器上,而不是永远停留在localhost。
1. 部署这件事,其实拼的是准备功夫
很多人第一次部署项目,习惯是拿到服务器就开干:先装环境,再传代码,然后各种报错,改一步试一步,最后折腾一晚上还没跑起来。我早年也这么干过,后来带团队之后学乖了——部署前花半小时做规划和检查,比部署后花三小时排查问题要划算得多。
1.1 上线前,先把方案选型定下来
部署方案没有绝对的标准答案,但你得根据项目规模和预算选一条最合适的路。
第一类是最典型的单体全栈项目:一个Spring Boot后端、一个Vue前端、一个MySQL、一个Redis。这种项目用一台2核4G的云服务器就能跑得很稳,成本低,维护也简单。这也是我这节课主要讲的场景。
第二类是稍微复杂一点的微服务或多模块项目,比如Eureka注册中心加多个业务服务。这种最好用Docker Compose统一编排,不然每个服务手动启一遍,光是环境一致性就够你头疼。Docker能把Java环境、依赖、启动命令全部打包到一个镜像里,换服务器部署基本无痛。
第三类是你有多个模块、需要频繁发布、团队协作了,那就得上Jenkins或GitLab CI这套自动化流水线,再加一个Nexus私服存构建产物。这个方向适合工作后接触,个人项目现阶段不需要折腾那么重。
无论你选哪种方案,有几点是通用的:数据库要和应用代码分开管理,配置文件里不要写死任何环境相关的信息,服务器上只保留运行所需的最小工具集合。千万别图省事直接在服务器上装个IDEA,然后用IDE跑生产代码,这种操作我见过,出问题的时候简直是一场灾难。
1.2 服务器和环境的统一规划
假设你已经买好了一台云服务器,接下来最容易被忽略的是环境变量的统一规划。
先检查服务器发行版。我用得比较多的是CentOS 7.9和Ubuntu 20.04,两个系统在命令上略有差异,但核心逻辑一样。新的服务器到手,先做三件事:更新系统源、创建专用的部署用户、配置SSH密钥登录。
# Ubuntu/Debian sudo apt update && sudo apt upgrade -y # CentOS/RHEL sudo yum update -y创建部署用户的目的是隔离权限,不要所有的服务都拿root跑。我习惯创建一个叫deploy的用户,把项目的所有文件都放在/home/deploy/apps/下面,结构大概是这样:
/home/deploy/apps/ ├── backend/ # 后端jar包和配置文件 ├── frontend/ # 前端静态资源 ├── logs/ # 统一日志目录 └── backup/ # 数据库备份和旧版本包这个目录结构看起来简单,但能让后续的日志查看、备份、回滚都变得特别顺手。你别小看这一步,我见过很多项目,文件乱扔在/root或者/tmp下面,半年之后再想找到某个版本的包,愣是能花上一个下午。
环境规划里还要提前定好端口和防火墙策略。我的习惯是:后端应用端口用8090,Nginx用80和443,MySQL用3306,Redis用6379。云服务器控制台的安全组只对外开放80、443,其他所有端口一律禁止外部访问,后端接口全部走Nginx反向代理。这样做的理由很实际:攻击者没法直接扫描到你的应用端口,数据库和Redis更是完全藏在内网里。
2. 后端打包与Java应用落地
后端服务是整个系统的心脏,这一部分我重点讲Spring Boot项目的打包、启动和守护。很多新手最容易在这里翻车,因为本地IDE帮你把一切都处理好了,你根本不知道Java进程在真实环境里有多“野”——它不会自己重启,不会自己清日志,也不会帮你检查端口占用。
2.1 Maven多环境打包,别再手动改配置
本地开发和线上环境的数据库地址、Redis地址、日志级别肯定不一样,你要是每次发布前手动改一遍application.yml,早晚会出事。正确做法是用Maven的profile功能做多环境配置。
第一步,在pom.xml里定义profiles:
<profiles> <profile> <id>dev</id> <properties> <activatedProperties>dev</activatedProperties> </properties> </profile> <profile> <id>prod</id> <properties> <activatedProperties>prod</activatedProperties> </properties> </profile> </profiles>第二步,在application.yml里指定使用哪个profile:
spring: profiles: active: @activatedProperties@这个@activatedProperties@占位符在Maven打包时会自动替换成你指定的profile名称。然后准备两个配置文件:application-dev.yml和application-prod.yml,各自维护自己环境的配置。打包的时候只需要:
# 跳过单元测试,打生产包 mvn clean package -Pprod -DskipTests用这种方式,你会彻底告别“上线前记得把localhost改成云服务器IP”这种高危操作。我个人的额外习惯是:在prod配置里把数据库密码通过环境变量注入,而不是直接写在yml文件里。因为yml文件是有可能被上传到Git仓库的,密码一旦进了版本历史,就很难彻底清除了。
打包成功之后,target目录下会生成一个xxx.jar,这个jar就是产物。注意看下jar包大小,一般Spring Boot项目大概在50MB到100MB之间。如果特别小,很可能你用了spring-boot-maven-plugin配置错误,导致依赖没有打进去。
2.2 让Java应用跑起来,从nohup到systemd
最简单的启动方式就是Java直接跑:
java -jar backend.jar但这种方式有个致命问题:关闭终端,应用就死了。而且一旦进程因为内存溢出或其他原因退出,没人帮你把它拉起来。
我推荐用systemd来管理Java服务,它能把Spring Boot变成一个标准的系统服务,支持开机自启、崩溃自动重启、日志统一管理,比nohup甩几条街。
先创建service文件,在/etc/systemd/system/backend.service里放下面这段配置:
[Unit] Description=Java Backend Service After=network.target mysql.service redis.service [Service] Type=simple User=deploy WorkingDirectory=/home/deploy/apps/backend ExecStart=/usr/bin/java -Xms512m -Xmx1024m -jar /home/deploy/apps/backend/backend.jar Restart=always RestartSec=10 StandardOutput=append:/home/deploy/apps/logs/backend.log StandardError=append:/home/deploy/apps/logs/backend-error.log [Install] WantedBy=multi-user.target配置里有几个关键点得说清楚:
After=network.target mysql.service redis.service表示这两个服务启动之后才尝试启动backend,避免应用启动了但数据库还没就绪导致连接失败。Restart=always是核心,进程只要异常退出,systemd会等10秒自动拉起。StandardOutput和StandardError配置直接把日志输出到指定文件,比你自己写logback文件配置简单多了。
配置好之后执行:
sudo systemctl daemon-reload sudo systemctl enable backend.service sudo systemctl start backend.service sudo systemctl status backend.servicestatus显示active (running)就说明服务起来了。想看实时日志就执行journalctl -u backend -f,或者直接查看logs目录下的backend.log。
部署新版本时候的操作顺序我也说一下,这个流程我踩过太多次坑才总结出来:先把新jar传到服务器上,备份旧jar,再重启服务。直接覆盖旧jar然后重启,万一新版本启动失败,你想回滚只能干瞪眼。
# 先备份旧版本 cp backend.jar backend-$(date +%Y%m%d).jar # 覆盖新版本后重启 sudo systemctl restart backend.service2.3 启动参数与内存分配的经验值
Java应用最怕的就是内存没规划好。2G内存的服务器,你给JVM配了1.5G的堆内存,那其他进程基本就废了。
我这里给个经验值参考:
| 服务器内存 | JVM堆内存(-Xmx) | 初始堆内存(-Xms) | 适合场景 |
|---|---|---|---|
| 2G | 512M - 1G | 256M - 512M | 单体项目低并发 |
| 4G | 1G - 2G | 512M | 单体项目中等并发 |
| 8G | 2G - 4G | 1G | 较大项目或微服务 |
-Xms和-Xmx设置成一样的好处是避免JVM运行时动态扩容带来的性能波动,但这个值主要看服务器资源够不够。如果内存富裕,就直接设成相同值。
启动参数里还有一个容易被忽略的东西,就是时区。很多服务器默认时区是UTC,而你的数据库和前端用的都是北京时间,就会出现一个诡异的现象:后台看数据创建时间差了8小时。JVM层面可以这样设置:
ExecStart=/usr/bin/java -Duser.timezone=Asia/Shanghai -Xms512m -Xmx1024m -jar /home/deploy/apps/backend/backend.jar-JVM参数-Duser.timezone=Asia/Shanghai能解决一部分时区问题,但根本解法还是MySQL连接串里加上serverTimezone=Asia/Shanghai,同时操作系统时区也用timedatectl set-timezone Asia/Shanghai校正一遍。三者一致才能彻底避免时间偏移。
3. 前端构建与Nginx反向代理
前端部署看起来简单,不就是一个静态文件服务器嘛。但实际上要处理的问题不少:API请求往哪走、历史路由模式要不要特殊处理、静态资源缓存策略、HTTPS证书,随便一个都要花时间调。
3.1 Vue项目的生产构建
前端构建几乎是零成本的,前提是你本地环境是好的。在项目根目录执行:
npm install npm run build构建完成后会生成一个dist目录。如果用的是Vite,dist里是index.html和assets文件夹;如果是Vue CLI,结构类似。你会发现assets里的JS和CSS文件名带了哈希值,比如index.3a2f9b7c.js,这是Webpack或Vite内容哈希的特性。只要文件内容变了,文件名就变,这个特性配合Nginx的缓存策略,可以做到资源永久缓存、文件更新即时生效。
构建过程中最容易出问题的是接口地址。你在开发环境用axios请求的是/api,这个/api通过开发服务器的proxy代理到后端。生产环境没有vite的proxy机制了,你要么把头写死成全地址,要么用Nginx做代理。我的建议永远是:前端把请求写成相对路径/api,具体代理到哪台后端机器,交给Nginx处理。这样前端构建产物在任何环境都能复用,后端地址变了只需要改Nginx配置。
构建完成后,把dist文件夹传到服务器的前端目录,然后配置Nginx。
3.2 Nginx配置:静态资源与API转发的正确姿势
Nginx在前端部署里的角色有两个:托管静态页面和反向代理API请求。它把两者结合在一个server块里,这是最常见的部署方案。下面是我实际项目中一直在用的配置模板:
server { listen 80; server_name yourdomain.com; # 前端静态资源目录 root /home/deploy/apps/frontend/dist; index index.html; # 前端路由的history模式关键配置 location / { try_files $uri $uri/ /index.html; } # 静态资源缓存,带哈希文件缓存一年 location /assets/ { expires 1y; add_header Cache-Control "public, immutable"; } # API反向代理 location /api/ { proxy_pass http://127.0.0.1:8090; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; } }try_files $uri $uri/ /index.html这行是核心。Vue Router如果用的是history模式,访问/vue-router的history模式必须配合这个配置,否则刷新页面就会404。
/assets/的缓存策略是借助文件名哈希实现的,我前面提到过,文件内容变了哈希就变,浏览器自然请求新文件,所以可以放心设置一年缓存。
/api/的代理转发也有讲究。proxy_pass后面可以直接跟http://127.0.0.1:8090,也可以跟http://127.0.0.1:8090/,区别在于路径的处理:带结尾斜杠,转发时会去掉location匹配的/api前缀,不带结尾斜杠,则保留完整uri。这个细节我吃过亏,建议你实际项目里根据后端接口路径设计来选择。如果后端Controller的手机号写的是@RequestMapping("/api/user"),那proxy_pass就不要带结尾斜杠,或者后端统一处理前缀,两种方式别混用。
使用后端本机地址127.0.0.1的前提是Nginx和后端服务在同一台服务器。如果前后端分离部署到不同机器,这里就要改成后端服务器的内网IP,同时保证两台机器的网络安全组放通对应端口。
3.3 HTTPS证书配置,几分钟搞定
现在的项目不上HTTPS,浏览器直接预警“不安全”,很多功能还会被限制。给Nginx配HTTPS,我用的是Let's Encrypt的免费证书,配合certbot自动续期,全程完全不花钱。
如果服务器上已经有域名且解析到了这台服务器,可以执行:
sudo apt install certbot python3-certbot-nginx sudo certbot --nginx -d yourdomain.comcertbot会自动帮你修改Nginx配置,把80端口的请求重定向到443,并自动配上证书。最重要的是,它还会设置定时续期任务。Let's Encrypt证书有效期是90天,到期前需要自动续期,否则HTTPS就失效了。
证书配好之后,一个标准的HTTPS server块大概长这样:
server { listen 443 ssl http2; server_name yourdomain.com; ssl_certificate /etc/letsencrypt/live/yourdomain.com/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/yourdomain.com/privkey.pem; ssl_protocols TLSv1.2 TLSv1.3; # 其余配置和HTTP一致 }有件事必须提醒:升级HTTPS之后,前端代码里所有请求必须是https或者相对协议//api,绝不能写死http。否则浏览器会拦截混合内容,出现“页面能打开但接口全挂”的诡异问题。排查这种问题最直接的方法是打开浏览器开发者工具,看Console里的混合内容警告。
4. 数据库、缓存与数据迁移
我见过不少人部署的时候只关注应用本身,数据库随便装一个,密码设个123456,表结构手动跑一遍SQL就完事了。这种部署方式一旦出问题,后果非常严重。数据库这一层,值得多花点时间做扎实。
4.1 初始化数据库与账号体系
MySQL装好之后第一件事,就是不要直接用root连业务库。我每次初始化数据库都是按照这套顺序来的:
-- 创建业务用户,只给业务库的权限 CREATE USER 'app_user'@'localhost' IDENTIFIED BY '你的强密码'; CREATE DATABASE app_db DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; GRANT ALL PRIVILEGES ON app_db.* TO 'app_user'@'localhost'; FLUSH PRIVILEGES;utf8mb4对照上mysql8的默认字符集是utf8mb4,但你手动建库的时候还是要显式声明一下,避免某些老版本MySQL默认latin1。字符集错了,中文全变乱码,后面再改就麻烦了。
权限这里我特意只授权了localhost,也就是说只有本机应用才能连接MySQL。如果你的后端服务和数据库不在同一台机器,这里就要授权给后端服务器的内网IP,同时无论如何都不要授权成'%'所有来源。
表结构初始化,我推荐把整个数据库的表结构、初始数据全部导出成SQL脚本管理起来。可以用Navicat或mysqldump导出,然后在新环境里执行。更规范的做法是直接用Flyway这类数据库版本管理工具,它能在应用启动时自动检查数据库版本,把项目代码和数据库结构变更统一纳入版本管理。个人项目如果嫌麻烦,至少要把建表SQL脚本放到项目里留档。
4.2 Redis配置要点
Redis在Java全栈项目里一般承担缓存和会话管理的职责。部署的时候,第一件事就是设置密码。默认Redis没密码,只要端口对外开放,任何人都能连上来执行命令,这等于直接裸奔在公网上。
redis.conf里的核心配置,我就写三个通用的:
# 只监听本机 bind 127.0.0.1 # 端口 port 6379 # 密码 requirepass your-redis-passwordbind 127.0.0.1非常关键,只监听本机,那么外部IP想连也连不上,和防火墙设置形成了双重防护。如果你的后端在另一台机器上,需要远程访问Redis,那也建议同时开启防火墙限制来源IP,不要图省事把所有地址都放进来。
Spring Boot连接Redis的配置里,别忘加上密码:
spring: redis: host: 127.0.0.1 port: 6379 password: your-redis-password timeout: 3000msRedis是否配持久化,取决于你的业务。如果是纯缓存业务,比如热点数据失效就重新查库,那配置RDB快照就可以了,恢复速度快、性价比高。如果Redis里存了关键业务数据,比如某些项目的分布式锁状态或者购物车内容,那就得开AOF,虽然数据恢复慢一点但更可靠。个人项目阶段,RDB足够。
4.3 数据迁移与备份策略
数据迁移是个大坑,尤其是从本地环境把数据库搬到云服务器。最稳妥的方法是先用mysqldump导出整个库,再在新服务器的MySQL里导入。
# 本地导出 mysqldump -u root -p --default-character-set=utf8mb4 --single-transaction --set-gtid-purged=OFF app_db > app_db.sql # 服务器导入,先创建好数据库再执行 mysql -u app_user -p app_db < app_db.sql--default-character-set=utf8mb4这个参数会直接决定导出文件编码,不加它,遇到特殊字符很容易导出乱码。--single-transaction是为了在导出时不锁表,生产库也要用这个参数。
备份策略这块,我自己的节奏是:每天凌晨全量备份数据库,保留最近7天文件。服务器上写一个简单脚本,配合crontab定时任务就能跑:
#!/bin/bash BACKUP_DIR="/home/deploy/apps/backup" DB_USER="app_user" DB_PASS="你的数据库密码" DB_NAME="app_db" DATE=$(date +%Y%m%d_%H%M%S) mysqldump -u"$DB_USER" -p"$DB_PASS" --single-transaction --set-gtid-purged=OFF "$DB_NAME" | gzip > "$BACKUP_DIR/${DB_NAME}_${DATE}.sql.gz" find "$BACKUP_DIR" -name "*.sql.gz" -mtime +7 -delete这一段代码实测下来非常稳,数据量不大的情况下,整个备份过程也就几秒钟,对业务几乎没有影响。做完之后,你要做的就是把脚本加到crontab里:
crontab -e # 每天凌晨2点执行备份 0 2 * * * /home/deploy/apps/backend/backup.sh有条件的建议再加一步:异地备份。比如每天把备份文件用scp或ossutil同步到对象存储。数据这东西,多一份备份就多一份保障,服务器被误删除或者磁盘损坏的情况虽然少见,但真遇到一次就能让你崩溃。
5. 上线后才是真考验:常见问题排查实录
项目部署完,浏览器里能打开首页,接口能返回数据,你以为就结束了吗?恰恰相反,真正的考验从这一刻才开始。我把自己这几年线上问题排查的经验整理一下,很多都是花了大半夜才总结出来的教训。
5.1 502 Bad Gateway的排查思路
502应该是上线后最常见的报错页面了。它的本质是Nginx作为反向代理,没法从后端获取有效响应。排查顺序我认为应该这样来:
先看后端服务到底还活着没有:
sudo systemctl status backend.service如果状态不是active(running),那就是服务崩了,直接查看日志文件,看崩溃原因。常见原因是端口占用、数据库连不上、内存不足。
如果服务活着,接着看后端端口是否真的在监听:
netstat -tlnp | grep 8090如果端口没监听,可能是应用启动失败但systemd还没来得及更新状态,这时候journalctl -u backend -f能看实时日志。如果端口正常监听,那大概率是Nginx配置问题,重点检查proxy_pass的地址和端口是否被注释或者写错,改完别忘了nginx -t先测试配置再reload。
另外一个隐蔽的原因是Nginx和后端之间的超时。如果服务端某个接口执行超过60秒,Nginx默认的proxy_read_timeout到了就会主动断开,表现也是502。这种情况要么优化接口耗时,要么适当调整:
location /api/ { proxy_read_timeout 60s; proxy_connect_timeout 10s; }502排查那句话我说了很多遍:先看服务进程,再看端口,最后看日志。按这个顺序来,大多数问题五分钟能定位。
5.2 内存与CPU飙升问题
上线第二天早上被报警信息吵醒,打开监控面板一看,内存爆了——这种情况我经历过太多次了。
Java服务内存占用过高,第一步用jps找到进程PID,然后:
# 查看堆内存使用情况 jmap -heap <pid> # 导出堆转储文件,等待GC的现场 jmap -dump:format=b,file=heap.hprof <pid> # 查看线程状态 jstack <pid> > thread_dump.txt这些工具在JDK自带目录的bin下面,线上排查时用起来特别顺手。大家最常见的OOM原因有这么几类:一次性查了太多数据没分页、缓存没有设置过期时间、连接池配置过小导致线程全部阻塞堆积。修复起来不算复杂,但要学会用jmap和jstack定位是哪个类哪个方法导致的,这个能力在Java面试里也经常被问到。
CPU飙升的排查更直接:
# 按CPU排序看进程 top -c # 查看进程内哪个线程占用高 top -Hp <pid>拿到线程PID后,把它转成十六进制,再用jstack查看线程栈:
printf "%x\n" <线程PID> jstack <pid> | grep -A 30 "nid=0x<十六进制>"基本就能定位到具体代码。实践里最常见的场景是某个循环里频繁调用外部接口或SQL没走索引。
5.3 时区、字符集、日志几个小坑
这些小问题每个都不难解决,但凑在一起足以让人怀疑人生。
时区问题前面已经说过了,JVM、MySQL连接串、操作系统三层必须一致,有一个不匹配就会出现“数据库时间对不上”“前端显示时间少了8小时”这种脑壳疼的问题。
字符集问题,最常见的表现是接口返回的中文乱码。检查三处:Linux系统的locale(执行locale命令看LANG变量)、MySQL数据库的character_set_server、Spring Boot的server.servlet.encoding配置。一般来说,系统locale设为UTF-8,数据库字符集用utf8mb4,Spring Boot默认UTF-8,三者匹配就不会乱。
日志问题很容易被忽略。默认情况下,Spring Boot自带logback的日志文件没有大小限制和生产级别的滚动策略,跑个把月,磁盘就可能被打满。建议在生产环境里把日志配置换成下面这种滚动策略:
logging: file: name: /home/deploy/apps/logs/app.log logback: rollingpolicy: max-file-size: 50MB max-history: 7 total-size-cap: 500MB用这套配置,单个日志文件达到50MB就自动切分,最多保留7个历史文件,总大小不超过500MB,磁盘永远不会被日志写爆。
还有一个常用命令,快速看后端日志里的异常信息:
grep -n "ERROR\|Exception" /home/deploy/apps/logs/app.log | tail -n 50最后再分享一个我个人的操作习惯:每次发布新版本之后,我会把这次改动的关键信息记录到一个发布文档里,包括新增了哪些接口、改了哪些配置、动过哪些表结构。这个文档在新版本出问题的时候特别有用——你可以快速判断哪些改动可能与线上故障相关,而不是对着整个代码库大海捞针。
这一课的内容,实操占了大头。你如果把自己手头的项目从本地搬到了云服务器,体验过一次完整的部署流程,那你对Java全栈的理解会比刷十套面试题都深刻。对我来说,部署上线这件事最迷人的地方在于:它把你在开发时做的每一个技术决策,都变成了可以被测量、被验证的真实结果。本地随便跑通的代码,到了线上才会暴露真正的成色,而这个“暴露”的过程,恰恰就是全栈工程师成长最快的地方。