同一个开发机上跑 Go 1.22 写服务、Java 17 跑 Spring Boot、Python 3.12 做脚本,是再常见不过的组合。问题不在装不上,而在“三套工具都靠 PATH 抢位置”:PATH 里谁在前面谁赢,但决定顺序的往往是你三天前随手追加的一行 export,而且只在交互式 shell 里生效。
这篇给出一套按“版本管理”和“依赖隔离”分层、用目录布局兜底的方案,把三套语言的环境变量冲突压到可控范围。
1. 只靠 PATH 顺序为什么不够
| 场景 | PATH 顺序的表现 | 后果 |
|---|---|---|
| 非交互 shell(cron、CI、systemd) | 不读 ~/.bashrc |
版本悄悄退回系统自带 |
sudo 执行 |
默认重置 PATH(secure_path) |
命令找不到或用了系统版 |
| IDE / 编辑器插件 | 不继承你的 shell 初始化 | 编译用的是另一套 JDK |
| 多项目并行 | 全局只能有一个“当前版本” | 切项目要手动改环境 |
| 同一终端开两个项目 | 后 source 的覆盖前面 |
看起来随机失败 |
结论:PATH 只能解决“命令能找到”,解决不了“哪个版本被找到、在哪个目录被找到”。需要的是显式变量 + 目录布局 + 项目级钩子三层。
2. 目录布局:先把位置定下来
/opt/jdk/17.0.11 # 手动装的 JDK
/opt/go/1.22.5 # 手动装的 Go
~/.pyenv/versions/3.12.4 # pyenv 管的 Python
/usr/lib/jvm/ # apt 装的 JDK
~/go/bin # GOBIN
~/.local/bin # 用户级可执行文件
| 层 | 位置 | 谁负责升级 | 是否进 PATH |
|---|---|---|---|
| 系统包 | /usr/bin、/usr/lib/jvm |
apt |
是(靠后) |
| 手动多版本 | /opt/<tool>/<version> |
自己,符号链接切换 | 否,靠变量 |
| 用户级工具 | ~/.local/bin、~/go/bin |
pipx、go install |
是(靠前) |
| 语言版本管理器 | ~/.pyenv、~/.sdkman |
管理器 | shim 目录靠前 |
约定:系统目录只放“系统要用的”,业务要的版本一律进 /opt 或用户目录。 这样 apt upgrade 不会把你的运行时换掉。
3. Java:JAVA_HOME 与 update-alternatives
$ sudo update-alternatives --install /usr/bin/java java /usr/lib/jvm/java-17-openjdk-amd64/bin/java 1710
$ sudo update-alternatives --install /usr/bin/javac javac /usr/lib/jvm/java-17-openjdk-amd64/bin/javac 1710
$ sudo update-alternatives --config java
$ readlink -f /usr/bin/java
| 手段 | 改什么 | 不改什么 |
|---|---|---|
update-alternatives |
/usr/bin/java 等命令的符号链接 |
JAVA_HOME 完全不动 |
export JAVA_HOME=... |
当前 shell 的变量 | 不改 /usr/bin/java |
JAVA_HOME + PATH=$JAVA_HOME/bin:$PATH |
命令解析优先走变量指向 | 系统脚本里硬编码路径的仍用旧版 |
| SDKMAN | 同时管变量与链接 | 需要 source 初始化脚本 |
最典型的错配:java -version 显示 17,但 Maven 编译报“需要 21”——因为 JAVA_HOME 还指着 11,Maven 优先读 JAVA_HOME。核对方法:
$ echo $JAVA_HOME
$ $JAVA_HOME/bin/java -version
$ mvn -v | head -3
mvn -v 会明确打印它用的 Java 路径,比猜快。
4. Python:pyenv 管版本,venv 管依赖
这两件事必须分开,混在一起就会出现“为了装个包被迫升级整个解释器”。
| 层 | 工具 | 解决 | 不解决 |
|---|---|---|---|
| 解释器版本 | pyenv |
3.9/3.11/3.12 并存与切换 | 包依赖冲突 |
| 项目依赖 | venv / uv / poetry |
每个项目独立 site-packages |
解释器版本 |
| 命令行工具 | pipx |
全局可用的 CLI,各自独立环境 | 库依赖 |
$ pyenv install 3.12.4
$ pyenv global 3.12.4
$ pyenv local 3.11.9 # 只在本目录生效,写 .python-version
$ python -m venv .venv
$ source .venv/bin/activate
$ python -V
pyenv 靠 ~/.pyenv/shims 里的转发脚本工作,所以 eval "$(pyenv init -)" 必须进 shell 初始化,且要在 PATH 设置之后。没生效时 which python 会指向 /usr/bin/python3,而 pyenv version 显示的还是你设的版本——两者不一致就是 shim 没进 PATH。
Ubuntu 24.04 还有一层:PEP 668 之后,pip install 到系统解释器会直接报 externally-managed-environment。这是设计,不是故障;正确做法是 venv、pipx 或 --break-system-packages(只在明确知道后果时用)。
5. Go:三个变量与 go env -w
$ go env GOROOT GOPATH GOBIN GOMODCACHE
$ go env -w GOPATH=$HOME/go
$ go env -w GOBIN=$HOME/go/bin
$ go install golang.org/x/tools/cmd/goimports@latest
| 变量 | 含义 | 默认 | 什么时候要设 |
|---|---|---|---|
GOROOT |
Go 安装目录 | 由 go 二进制位置推断 |
多版本并存、被别的工具读到时 |
GOPATH |
工作区(模块缓存、bin) |
$HOME/go |
想放独立磁盘或避免备份膨胀 |
GOBIN |
go install 的落点 |
$GOPATH/bin |
想统一到 ~/.local/bin |
GOMODCACHE |
依赖缓存 | $GOPATH/pkg/mod |
缓存太大要挪走 |
GOTOOLCHAIN |
是否允许自动下载工具链 | auto |
离线环境要设 local |
go env -w 写的是 $HOME/.config/go/env,比 export 更可靠——它不依赖 shell 初始化,IDE 和 CI 都能读到。多版本并存时,把每个版本装到 /opt/go/<ver>,用 /usr/local/go 符号链接指向当前默认,GOROOT 跟着链接走。
6. direnv:把环境绑到目录
$ sudo apt install direnv
$ echo 'eval "$(direnv hook bash)"' >> ~/.bashrc
$ cd ~/work/api && direnv allow
# ~/work/api/.envrc
export JAVA_HOME=/opt/jdk/17.0.11
export PATH="$JAVA_HOME/bin:$PATH"
layout python python3.12
export GOFLAGS=-mod=mod
| 能力 | 说明 |
|---|---|
| 进目录自动加载 | cd 时注入变量 |
| 出目录自动卸载 | 变量回到进入前的状态 |
| 需显式授权 | 新 .envrc 要 direnv allow,避免误执行 |
| 与 pyenv 共存 | .envrc 里调 pyenv local 或直接 layout python |
| 不在 CI 生效 | CI 要显式 direnv exec . <cmd> 或自己 export |
它解决的是“项目 A 要 JDK 17、项目 B 要 JDK 21”这种并行需求。代价是多一个 shell hook,以及团队成员都得装——所以 .envrc 进仓库时,README 里要写清楚。
7. 三者的定位差:which、type -a、command -v
$ type -a python
python is /home/yg/.pyenv/shims/python
python is /usr/bin/python
$ command -v python
/home/yg/.pyenv/shims/python
$ which python
/home/yg/.pyenv/shims/python
| 命令 | 是否看别名/函数 | 输出 | 适用 |
|---|---|---|---|
type -a |
是,列出全部匹配 | 多行,含类型说明 | 排查“到底有几个” |
command -v |
是(POSIX 语义) | 一行路径或名字 | 脚本里判断存在性 |
which |
通常否 | 一行路径 | 交互式快速看 |
hash -r |
— | 清空命令缓存 | 换版本后 shell 还认旧路径时 |
换完版本发现“命令还是旧的”,八成是 bash 的 hash 表缓存。hash -r 清一下即可,不用重开终端。
8. 容器:最终隔离手段
| 场景 | 裸机多版本 | 容器 |
|---|---|---|
| 单人本机开发 | 够用,配 direnv 更顺 | 略重 |
| 团队统一构建环境 | 易漂移 | 强一致 |
| 需要旧版 glibc / 特殊依赖 | 极难 | 镜像里随便装 |
| CI 与本地一致 | 靠文档约束 | 同一镜像即一致 |
| GPU / 硬件直通 | 直接 | 需要额外配置 |
判断标准很简单:“别人在我机器上能不能复现”如果是否,就上容器。 三套语言并存本身不是问题,问题是要跑在同一个 glibc、同一个 pkg-config 路径上;容器把这层依赖封进镜像,宿主机只留编辑器。
9. 常见坑:六种“环境明明配了却不生效”
| 现象 | 原因 | 处理 |
|---|---|---|
| 变量只在新终端生效 | 写进了 ~/.bashrc 但当前 shell 没重载 |
source ~/.bashrc;确认是交互式 shell |
| 配置被执行两次 | .profile 与 .bashrc 互相 source |
用 [ -z "$X" ] && export X=... 做幂等 |
sudo java -version 是旧版 |
secure_path 重置 PATH |
sudo -E 或写 /etc/sudoers.d/ 的 env_keep |
pip install 报 externally-managed-environment |
PEP 668 保护系统解释器 | 用 venv / pipx |
pyenv 切了但 python 没变 |
shim 没进 PATH 或 hash 缓存 | pyenv rehash、hash -r、检查 pyenv init |
| IDE 编译用错 JDK | IDE 不读 shell 初始化 | 在 IDE 里显式设置 JDK / 项目 SDK |
sudo 那条影响最大:env_keep += "JAVA_HOME PATH" 写进 /etc/sudoers.d/dev(visudo -f 校验)比每次 -E 稳。
10. 边界:什么时候别折腾多版本
| 需求 | 更简单的做法 | 理由 |
|---|---|---|
| 只跑一个项目 | 直接用系统包 + venv | 版本管理器是净负担 |
| 版本差异只在补丁号 | 用同一个大版本 | 跨小版本兼容性通常够 |
| 需要精确复现生产 | 容器镜像对齐 | 本机模拟不可靠 |
| 团队协作 | 提交 .tool-versions / .python-version |
配置进仓库比口头约定有效 |
| 临时试新版本 | docker run 一次 |
不污染宿主 |
一句话收口:版本管理交给 pyenv/SDKMAN//opt 目录,依赖隔离交给 venv/容器,目录级差异交给 direnv,壳层只负责把 shim 和变量顺序摆对。 四层各管一件事,PATH 就不再是唯一的裁判。
11. 自测
update-alternatives --config java之后,JAVA_HOME会跟着变吗?- pyenv 与 venv 各自解决什么问题?为什么不能互相替代?
go env -w写的配置存在哪个文件?为什么比export可靠?sudo java -version与当前用户看到的版本不一致,原因是什么?type -a比which多看到什么?- 什么信号说明“该上容器而不是继续配多版本”了?