最近技术社区里“caveman”这个词又火了起来。它未必是在骂人,更多是一种自嘲和调侃:当你知道现代工程有一整套 Best Practice,却仍然选择用最简单的变量名、最少的抽象、最直接的方式把东西跑起来,同事会笑着叫你 caveman。这个标签在职场里既像“贬义”,又像“夸你方案够皮实”,仔细琢磨还挺有意思。
这个标签其实有两副常见面孔。第一副是“Caveman Debugging”,也就是大家常说的“print 大法”——不管调试 Python 服务还是排障 Java 接口,第一反应都是在不理解的地方加打印,跑一遍看输出,而不是立刻打开调试器断点观察。第二副是“Caveman Deployment”——一台服务器、一段脚本、一条 rsync 命令搞定发布,不引入 CI/CD 流水线,不用容器编排,程序能跑、能回滚就行。
听着是不是有点“土”?但这个“土”恰恰是把问题从抽象层拽回真实世界的武器。今天很多技术项目不是饿死的,而是被撑死的。我写这篇文章,不是想劝大家彻底弃用现代工具,而是想聊清楚一个决策顺序:先能用最原始的方式理解和解决一个问题,再决定要不要为其叠现代化工具。文章适合小团队开发者、刚入行被“微服务架构”吓住的新人,以及想给团队建立“先可运行、再软件工程”共识的负责人。
1. 先把话说清楚:Caveman 到底在说什么
1.1 程序员圈里的“原始人”标签
在程序员圈子里,Caveman 这个标签有几层微妙的意思。最表面的一层,是嘲笑某人“代码风格太原始”:不用框架、不用类型系统、不写测试,所有逻辑都靠 if else 硬堆,变量名随手写 a、b、c,部署靠手动复制文件。这种风格确实容易出问题,谁看了都想摇头。
但深一层看,大家嘴上在笑,心里其实暗暗佩服。因为 Caveman 风格背后往往站着一种稀缺能力:不依赖工具就能理解系统。你让他把某个服务的请求链路画出来,他能从数据库连接池一路讲到反向代理的 keepalive;你让他处理一个线上故障,他没有花哨的平台工具,但能靠几个命令和一份日志快速定位问题。这种人写代码虽然原始,可他真的“知道自己在干什么”。
我自己见过不少相反的案例:工具链用得飞起,K8s、Prometheus、SkyWalking 全部拉满,但问到底层某个进程为什么挂了、某个请求为什么超时,他一脸茫然,只能把希望寄托在下一个“更强大的工具”上。工具是辅助,不是大脑。Caveman 标签真正讽刺的,其实不是不使用现代工具的人,而是被现代工具养废了的人。
1.2 同名框架造成的误会
要澄清一个容易混淆的点:Common Lisp 社区里真有一个叫 Caveman 的 Web 框架,作者是日本程序员 fukamachi,定位类似 Python 的 Flask、Ruby 的 Sinatra,用来快速搭建 Web 服务。这款框架取名为 Caveman,背后是同一个理念:让 Common Lisp 程序员从“庞大复杂”的既有印象中回到简单、直接的世界。
如果你去搜索 caveman,很可能会先看到这个框架的文档,然后才看到程序员圈子的“原始人”梗。本文聊的是后者这种开发价值观,但两者名字相同并不是巧合,核心气质是一路的:先求简单可运行,再谈结构和扩展。所以就算你本来想搜的是那个 Web 框架,也不妨碍你带着同样的思路读完这篇文章——因为你手头那些“为你好”的工具,可能比原始人的石斧还要沉重。
1.3 我想借这个话题解决什么问题
我写这篇东西的动机,来自这几年做项目和带团队时反复出现的一个矛盾:公司买了一大堆平台工具,微服务、消息队列、可观测性全家桶都上了,但真正解决业务问题的代码,往往不到一千行。堆砌的工具不仅没带来效率,反而让每个想改点东西的人望而却步。于是我开始有意识地“Caveman 化”:先问自己,不用 Kubernetes 能发布吗?不引入消息队列能解耦吗?不用 ORM 能存取数据吗?能的话,先把能跑的版本做出来。
这篇文章的价值,就是把 Caveman 式判断拆成几个能落地的实操场景。不空谈“极简主义”,直接给你四套可以抄的代码和命令,再带你完整做一个每日巡检小工具。你会看到 print 调试、rsync 发布、标准库脚本、JSON 文件存储这些“原始手段”,在真实场景里不但不可笑,反而是性价比最高的选择。当然,我也会在最后说清楚:什么时候必须忍住,老老实实上现代工具。
2. 为什么越是复杂的年代,越需要“原始人”视角
2.1 现代工程的三座大山
先说我观察到的问题。很多项目失败不是因为代码写得差,而是被三座大山压垮了。
第一座是依赖膨胀。一个 hello world 级别的小服务,package.json 里装了两百个依赖,锁文件比源码还大。每次升级依赖都提心吊胆,因为不知道哪个间接依赖偷偷改了行为。第二座是平台复杂化。为了让一个日活三位数的小产品“具备扩展能力”,一上来就划分多个服务、多个环境、多个团队,结果上线最慢、排查最难,扩展能力还没用上,先被自己的复杂度拖垮了。第三座是抽象过剩。为了用上“最新设计模式”,把简单直白的业务逻辑包裹了四五层,读代码的人要一层层拆套娃,拆到最后发现里面什么都没有。
这三座大山有个共同点:它们都不是为了“今天能跑”而存在的,而是为了“未来也许”而存在的。Caveman 视角恰恰反着来:未来也许不需要,那就先不要。不是拍脑袋否定工具,而是把所有工具当成可以按需取用的道具,而不是默认必须戴上的盔甲。
2.2 Caveman 式判断:先可运行,再软件工程
我常用的判断顺序是:可运行的版本、可理解的版本、可维护的版本、可扩展的版本。很多人都想做第四步,但连第一步都没有完成。所谓 Caveman 式开发,就是把每一步的验收标准定得特别低:脚本能跑、接口能返回、页面能打开,先让业务闭环。
举一个很生活化的类比。你周末在家招待朋友,不会为了做一桌菜先把厨房改造成专业中央厨房——你会先确认手头有锅、有火、有食材,然后开火下锅,先让大家吃上饭。等家里确实经常做饭,再考虑添置烤箱、厨师机。代码也一样。最小可用版本就是那个“先吃上饭”的版本,工具是后面根据实际使用频率慢慢添的,而不是开饭前就买齐一整套。
我在团队里经常念叨一句话:没有哪个现代工具是“免费”的。你多请一位管家,就得给他发工资。Kubernetes 确实好用,但维护它需要人力;消息队列确实能削峰,但它引入的运维成本、网络问题、重复消费问题,都需要人盯。Caveman 式判断要你回答的是:这个管家今天帮你省了多少事?如果省的事不够多,宁可自己动手。
2.3 什么时候算“过度工程”
判断“过度工程”有一个很朴素的标准:如果把这个组件删掉,系统仍然可以工作,那它大概率是过度工程。请注意,是“仍然可以工作”,不是“仍然高性能”。很多组件是被性能恐惧、扩展幻觉、大厂病驱动加上去的,加完之后,系统的正确性没有提升,团队的理解成本却暴涨。
另一个信号是回滚困难。一个 Caveman 式系统通常特别容易回滚:改坏了,切回上一个文件或上一个目录,三分钟搞定。而一个高度复杂的系统,回滚往往牵扯到数据库迁移、消息补偿、多服务版本兼容,一次回滚比上线还惊险。所以,如果让我用一个词定义 Caveman,我会说是“可回滚的简单”:不是原始,而是随时可以回到之前能用的状态。这也正是下面几个实操场景最核心的追求。
3. 四个可以直接抄的 Caveman 式实操场景
3.1 场景一:print 大法解决线上问题
先把原理说透。调试器固然强大,但它在多线程、异步任务、依赖外部状态的生产场景里有个天然的不足:断点会改变执行时序,可能导致海森伯格效应——你停下来观察的时候,问题不出现;你移除断点,问题又冒出来了。print 输出并不会阻断执行流,它只是记录真实路径上的观测点,信息虽然粗糙,但更贴近线上真实状态。
这个场景我不展开讲高深技巧,直接给一套可复制的步骤:
- 定位入口。想清楚一条请求或一个任务从哪进、从哪出,把打印放在入口和出口各一行。
- 在关键分支加标记。条件分支、异常分支、循环边界各一行,带上下文变量。
- 加时间戳。如果并发度高,打上毫秒级时间戳和线程号,便于排序。
- 二分注释。如果问题在某个大流程内部,先注释掉后半段,确认前半段是否正常,再反过来测试后半段,每次缩小一半范围。
- 找到后立即清理。临时调试信息要删掉或降到 debug 级别,别留在主干日志里污染后续排查。
示例写法(Python):
import logging logger = logging.getLogger("order") logger.debug("order entered: order_id=%s user_id=%s", order_id, user_id) logger.debug("threshold check: amount=%s limit=%s result=%s", amount, limit, result)这里有一点要特别注意:print 和 logger 在线上环境不一样。print 默认走标准输出缓冲,在进程崩溃、容器 OOM 时最后几行可能直接丢失;logger 的级别和格式化则是配置化的,更适合生产。我的习惯是:本地临时排查用 print,必须加 flush=True;线上部署前统一换成结构化日志,同时避免把敏感参数打到日志里。踩过坑的人都懂,日志里出现用户手机号那一刻,麻烦就来了。
3.2 场景二:一条 rsync 命令完成发布
如果一个小网站、小服务只有一两台服务器,说实话没有必要上 CI/CD。持续集成的核心诉求是“自动化、可重复、快速回滚”,这些用 rsync 加一个脚本完全能实现。基本流程是:本地先跑构建,再用 rsync 推送到服务器,最后执行重启或 reload。
#!/usr/bin/env bash set -euo pipefail APP_DIR=/var/www/myapp RELEASE_DIR=/var/www/myapp/releases/$(date +%Y%m%d_%H%M%S) REMOTE_USER=deploy REMOTE_HOST=myserver # 1. 本地构建 npm run build # 2. 推送新目录(不直接覆盖当前版本) rsync -avz --delete ./dist/ "$REMOTE_USER@$REMOTE_HOST:$RELEASE_DIR/" # 3. 切换软链接 ssh "$REMOTE_USER@$REMOTE_HOST" "ln -sfn $RELEASE_DIR $APP_DIR/current && systemctl reload myapp"我解释一下关键参数:-a 表示归档模式,保留权限、属主、时间戳;-v 是详细输出;-z 是压缩传输,网络差的时候很有效;--delete 表示让远端和本地保持一致,会删除远端多余的旧文件。最后那个软链接切换是回滚的关键——想回滚,只需要把 current 指回上一次的目录,不用重新传代码。
必须提醒的是,--delete 是一把双刃剑。如果你远端目录里有一些本地没有的文件,比如 runtime 缓存、上传目录,它也会被删。所以要么把这些路径写进 --exclude,要么先跑一遍 --dry-run 预览会删哪些东西,确认无误再去掉 --dry-run。我见过不止一个同事加完 --delete 之后把服务端的上传图片全删光的,那场面,真的希望你不要经历。
3.3 场景三:标准库写日志分析
很多时候我们被“大数据”“实时计算”这些词吓到,但现实中常遇到的需求只是:给我看一下 nginx 访问日志里到底哪些 IP 访问最多。Caveman 方案是用 Python 标准库逐行读文件,完全不引入计算框架。下面直接给一段能用的脚本。
#!/usr/bin/env python3 import re import sys from collections import Counter from pathlib import Path pattern = re.compile(r"^(\S+).*") counter = Counter() log_file = Path(sys.argv[1]) if len(sys.argv) > 1 else Path("access.log") with log_file.open() as f: for line in f: match = pattern.match(line) if match: counter[match.group(1)] += 1 for ip, count in counter.most_common(10): print(f"{ip:20s} {count}")这段脚本做了几件很简单但容易忽略的事。逐行读取而不是一次性读入内存,所以日志即使有十几个 GB 也能平稳处理;正则只提取每行开头的 IP 字段,匹配失败的行直接跳过;统计交给 collections.Counter,这是标准库实现,写出来几乎不可能出错。
为什么我不推荐一上来就上 Spark 或 ClickHouse?一是日志文件通常就几百 MB,单机遍历耗时才几十秒,完全在可接受范围内;二是这种分析通常是一次性需求,建一套数据仓库的成本远超收益;三是这类脚本写入仓库后,后续改动成本极低。Caveman 场景最常见的毛病就是“只想着优雅,忘了问题本身有多小”。
3.4 场景四:用 JSON 文件做极简存储
内部工具、个人项目或者超低并发的脚本,经常需要一个“能存点数据”的地方。这种场景直接用数据库反而多了一层依赖:要安装、要配置、要维护。Caveman 方案是一个 JSON 文件,加上原子写入,把读改写做对。先看代码:
import json import os DATA_FILE = "/var/data/counter.json" def load(): if not os.path.exists(DATA_FILE): return {"count": 0, "updated_at": ""} with open(DATA_FILE, encoding="utf-8") as f: return json.load(f) def save(state): tmp_file = DATA_FILE + ".tmp" with open(tmp_file, "w", encoding="utf-8") as f: json.dump(state, f, ensure_ascii=False, indent=2) os.replace(tmp_file, DATA_FILE)关键在 save 函数。如果直接打开原文件写入,写入过程中程序崩溃或者磁盘满,原文件可能只剩半个 JSON,数据就毁了。我的写法是先写临时文件,再用 os.replace 把临时文件原子性地替换成目标文件。在 Linux 上,os.replace 底层是 rename 系统调用,要么旧文件还在,要么新文件已经完整就位,不存在中间状态。
这个方案适合什么?适合请求频率极低的内部页面计数、配置缓存、简单的状态持久化。不适合高并发写入或强一致场景。判断标准很直接:如果两个进程同时写会导致结果错乱,而你又无法接受这个错乱,那就别用文件,老老实实上数据库。Caveman 不是让你无视工程边界,而是让你知道最简单方案的边界在哪里,以便在正确的时候做出取舍。
4. 完整实操:做一个每日巡检小工具
4.1 需求与目录规划
把前面的技巧串起来,我带你做一个完整的、可以直接照抄的小项目:每天凌晨扫描指定应用目录下的错误日志,统计最近 24 小时内各级错误数量,如果超过阈值,就把结果发送到管理员的飞书机器人。这个工具我在几台低配服务器上用过,足够解决“日志太多看不过来”的问题。
需求很简单,目录规划如下:
checker/ ├── log_checker.py # 核心扫描逻辑 ├── notify.py # 飞书通知逻辑 ├── config.json # 日志路径、阈值、Webhook └── run.sh # 入口脚本,供 cron 调用选这个结构是因为它只有四个文件,任何一个人进来都能十分钟看懂。config.json 把变化的部分抽出来,避免以后改阈值还要动代码;notify.py 单独放是因为通知逻辑以后可能从飞书换成企业微信或邮件,单独模块便于替换。
4.2 核心脚本实现
log_checker.py 负责扫描。按 Caveman 思路,我不做复杂的日志解析框架,直接读取文本、匹配关键字、统计数量。
#!/usr/bin/env python3 import json import re import sys from pathlib import Path from collections import Counter CONFIG = Path(__file__).parent / "config.json" def main(): cfg = json.loads(CONFIG.read_text(encoding="utf-8")) log_dir = Path(cfg["log_dir"]) threshold = cfg["threshold"] pattern = re.compile(cfg["keywords"]) stats = Counter() for log_file in log_dir.rglob("*.log"): count = 0 with log_file.open(encoding="utf-8", errors="ignore") as f: for line in f: if pattern.search(line): count += 1 if count: stats[str(log_file)] = count total = sum(stats.values()) result = { "total": total, "threshold": threshold, "files": dict(stats), } if total > threshold: print(json.dumps(result, ensure_ascii=False, indent=2)) return 1 return 0 if __name__ == "__main__": sys.exit(main())这里有一个容易被忽略的点:日志文件很多是长期积累的,一个文件几百 MB 很平常,所以用逐行迭代而不是 read() 一次性读入,内存占用才能保持平稳。errors="ignore" 则是避免某些日志行里有非法字节导致整个解析中断。对,我在处理第三方组件的日志时踩过这个坑,日志里出现乱码字节,Python 默认编码直接抛异常,整个巡检任务挂掉,第二天才发现昨晚根本没跑。
4.3 定时任务与通知
notify.py 的思路更简单:用 urllib 标准库向飞书机器人 Webhook POST 一个 JSON。很多人一上来就引 requests,但这里不需要连接池、重试、代理等复杂特性,urllib 足够了。
#!/usr/bin/env python3 import json import sys import urllib.request def send(webhook_url, content): payload = { "msg_type": "text", "content": {"text": content}, } data = json.dumps(payload).encode("utf-8") req = urllib.request.Request( webhook_url, data=data, headers={"Content-Type": "application/json"}, ) with urllib.request.urlopen(req, timeout=10) as resp: return resp.read() if __name__ == "__main__": webhook = sys.argv[1] content = sys.argv[2] send(webhook, content)run.sh 把两步串在一起:先跑 log_checker.py,根据退出码决定要不要发通知。cron 调用的脚本必须特别注意环境变量,因为 cron 默认的 PATH 很精简,可能找不到 python3 或其他依赖路径。所以 run.sh 里要用绝对路径,或者主动 export PATH。
#!/usr/bin/env bash set -euo pipefail cd /opt/checker OUTPUT=$(python3 log_checker.py || true) if [ -n "$OUTPUT" ]; then python3 notify.py "$WEBHOOK_URL" "$OUTPUT" fi这里我刻意把“绝对路径”写出来:如果你在生产服务器上手动跑脚本能通,但 cron 里死活不执行,十有八九是 PATH 问题,其次是相对路径问题。cd 到项目目录、用 which python3 拿到真实路径,这两个动作能解决九成 cron 故障。
4.4 测试与上线
上线前我会做三步验证。第一步是准备一个小样本日志文件,故意放几条超过阈值的关键字行,跑一遍 log_checker.py,确认返回码是 1;第二步是把阈值调成无穷大,确认返回码是 0,同时不会误发通知;第三步是手动执行一次 notify.py 的真实发送,确认 Webhook 配置正确、消息内容里的中文没有乱码。
一切正常后,用 crontab -e 加一行:
0 2 * * * /opt/checker/run.sh >> /opt/checker/run.log 2>&1这就是 Caveman 式上线:没有灰度、没有监控大盘,但该有的“确认按钮”一个不少。我个人还会把 run.log 保留下来,哪天巡检没跑或失败了,看日志立刻能定位,不用抓瞎。
5. 常见问题与排查技巧实录
5.1 一份抄走就能用的问题速查表
这一节我把实操里高频踩到的坑整理成速查表,每条背后都有一段心酸史。
| 问题 | 典型原因 | 解决方式 |
|---|---|---|
| print 在线上没输出 | Python 输出缓冲 | 加 flush=True,或改用 logging + StreamHandler |
| rsync 同步后权限不对 | 源文件权限被本机 umask 改变 | 服务目录用专属账号,避免 root 拉取后权限过窄 |
| JSON 文件并发写入损坏 | 两个进程同时写同一文件 | 用临时文件 + os.replace 原子替换,必要时加文件锁 |
| cron 里脚本不执行 | PATH 太干净、工作目录不对 | 脚本开头 cd 到固定目录,python3 用绝对路径 |
| 日志读一半报编码错误 | 日志里有非法字节 | open 时加 errors="ignore",或按二进制逐行解码 |
| 通知发不出去但脚本没报错 | Webhook 超时、防火墙限制 | urllib 设 timeout,通知失败也在本地写日志 |
5.2 我的排查心法
逐条解释一下要点。print 的缓冲问题出现最多,尤其在容器环境里,最后几行日志很可能随容器一起消失,排查的时候永远看不到尾部,所以线上环境千万别裸用 print。rsync 权限问题的典型症状是部署后站点返回 500,因为 nginx 或应用进程的用户读不了新文件,你 ssh 上去看却一切正常,特别迷惑人。JSON 文件写入是我在计数器服务里真正踩过的:两个 worker 同时写同一个文件,偶尔就出现半个 JSON,启动时解析直接崩。
还想分享一个额外的排查技巧:当你怀疑脚本卡在哪里或超时时,在关键节点打一行带时间戳的日志,然后对比时间差。这个方法比任何 profiling 工具都更快帮你缩小范围。Caveman 调试的本质,就是用最小成本换取最大可观测性,这句话值得你贴工位上。
6. 什么时候不该当 Caveman
6.1 必须“现代”的场景
Caveman 是一种策略,不是信念。下面这些场景,我会毫不犹豫地选现代工具。第一是多人协作,当五个人以上同时改一个代码库,没有测试、没有 CI、没有代码评审,就等于给后面每个人埋地雷;这时引入工具不是过度工程,而是保命。第二是高并发一致性核心业务,比如订单扣减、支付转账,文件存储和 print 调试都满足不了,必须上数据库和分布式追踪。第三是合规审计,需要完整操作日志、权限隔离、不可变发布记录,这些是流程平台才能提供的,脚本应付不来。
6.2 我的边界判断清单
我给自己定了几个问题,每次想“上重型工具”之前都先过一遍,你也可以抄走。
第一,如果我只有一台普通服务器、三周工期、两个对接人,这个方案能不能上线?第二,如果这个服务挂了,我手动恢复的时间是否能控制在 30 分钟以内?第三,这个架构最关键的业务逻辑,新来一个实习生要多久能看懂?如果三个问题里有两个回答是“不能”,就说明要么是需求被过度复杂化了,要么是当前团队确实不具备驾驭这个程度的平台能力,需要分阶段补齐,而不是一次到位。
我个人实际用 Caveman 风格做了三年内部工具和边缘服务,最大的体会是:它省下的不止是服务器和人力,更是开发者的心智带宽。每次当我克制住“顺手加一个 Redis”“顺手封装一个框架”的冲动,把版本先做出来,后续反而发现大多数情况下根本不需要那些东西。这个思路后来也被我用到了数据迁移、自动化测试设计上,核心原则始终不变:先完成,再完善,永远不要在一开始就追求完美。