☰
Python 保存 JSON 全攻略:从编码到云端落地,避开乱码和权限坑
2026/10/7 5:09:44 网站建设 项目流程

最近在 HoRain云 上部署了好几个 Python 定时任务,有爬虫采集、有数据清洗、也有模型特征的中间结果,所有数据最后都要落到磁盘上。试了一圈格式之后,我最终还是统一选了 JSON:它够通用,Python 里的 dict、list 可以天然对应,别的语言也能一眼读明白,排查问题的时候非常方便。这篇“全攻略”要讲的核心就是三件事:什么数据能存成 JSON、怎么存得规整不乱码、存完之后遇到问题怎么查。

内容围绕 Python 的 json 标准库展开,配合爬虫、配置文件、结构化数据三个真实场景,代码可以直接抄走。不管你是刚入门 Python 的小白,还是在云服务器上做自动化的老手,这套方案基本都能拿来就用。

1. 项目概述:这组“保存 JSON”操作到底在解决什么问题

1.1 为什么几乎所有项目最终都绕不开 JSON

JSON 这种格式最大的优势就是“自描述”:打开一个文件,哪怕完全没有注释,你也能从键名和结构里猜出它记录的是一批用户、一组配置,还是一次任务的结果。它不像 pickle 那样只有 Python 自己认识,也不像 CSV 那样遇到嵌套结构就抓瞎,这让它在跨语言、跨系统、前后端协作的场景里几乎是默认选择。

我见过不少新手习惯用 pickle 或者干脆自己在文件里拼字符串来保存数据,刚开始数据量小的时候感觉挺顺手,一换环境或者要跟其他人对接,问题就全冒出来了。pickle 文件换个 Python 小版本都可能读不了,自己拼的字符串一旦格式变动,解析逻辑全得重写。JSON 就不一样了,Python、Java、Node 都能直接处理,人工检查文件内容也完全无障碍,这就是它作为“通用交换格式”的底气。

1.2 HoRain云场景下保存 JSON 的特殊性

在云服务器上写文件和本地开发机完全不是一回事。本地代码跑在 Windows 或 macOS 上,目录结构你熟悉,权限基本都是你说了算,写文件失败的概率很低。但到了云服务器上,环境变成了 Linux,路径是/data/app/result/xxx.json这种绝对路径,文件属主可能不是你当前用户,目录也可能压根不存在。

更要命的是无人值守。本地写完文件报错,你盯着屏幕马上就能看到;云服务器上定时任务半夜跑,写文件失败进程直接退出,第二天你才发现昨晚的数据全没了。所以“保存 JSON”这种看起来只有三行代码的操作,在云环境下反而要认真设计:路径要自动创建、权限要提前确认、日志要留得够清楚、写入过程要尽可能避免数据损坏。这也是我把这套“全攻略”整理出来的原因,它解决的是实际部署中最容易漏掉的环节。

2. 保存 JSON 的核心 API 选型与原理

2.1 json.dump 和 json.dumps 到底用哪个

很多初学者对这两个方法分不清,其实区别就一句话:dump是直接把序列化结果写进文件对象,dumps是先返回一个字符串,文件写入由你自己完成。两者底层用的序列化逻辑完全一样,差别只在于“谁来负责和文件打交道”。

import json data = {"name": "task", "count": 10} # dumps:先序列化成字符串,再自己写文件 text = json.dumps(data, ensure_ascii=False, indent=2) with open("result.json", "w", encoding="utf-8") as f: f.write(text) # dump:直接把序列化结果写入文件对象 with open("result.json", "w", encoding="utf-8") as f: json.dump(data, f, ensure_ascii=False, indent=2)

我自己在绝大多数场景下都用dump,因为它少一步中间变量,代码更短,文件对象生命周期也更清晰。但有一种情况我会专门用dumps:当我不需要立刻落盘,而是要把序列化后的字符串传给下游,比如放进数据库、通过接口返回给前端、或者跟别的字符串拼接后二次加工,这时候字符串就是载体,dumps就比dump灵活得多。

2.2 ensure_ascii=False 解决中文乱码的根因

这个参数是新手踩坑的重灾区。默认情况下json.dump的ensure_ascii=True,也就是说序列化时会把所有非 ASCII 字符转成\uXXXX这种转义序列。结果是文件打开后全是“天书”,明明存的是中文标题,看到的是长长一串\u6807\u9898。

# 没有 ensure_ascii=False 的结果 with open("cn.json", "w", encoding="utf-8") as f: json.dump({"标题": "保存JSON"}, f, indent=2) # 文件里变成: {"\u6807\u9898": "\u4fdd\u5b58JSON"}

这种转义并不是“乱码错误”,JSON 官方规范是允许这么写的,而且很多解析库照样能读回来。问题是它严重损害可读性,你没法直接在终端里用cat或tail看内容,人工排查问题的时候特别痛苦。所以我写文件时几乎永远带上ensure_ascii=False,配合encoding="utf-8"指定写文件编码,这样文件里就是真正的中文,谁打开都能看懂。

2.3 indent、sort_keys、separators:排版与体积的取舍

indent控制缩进。开发调试阶段我习惯用indent=2或4,这样嵌套的 dict、list 层次分明,配合 Git 看代码变更时也非常直观。但要注意,缩进会让文件体积变大不少,如果数据量上来了,比如一次保存几万条记录,同样的内容带缩进和不带缩进体积能差好几倍。

sort_keys=True会按字典键名排序输出。这个参数很多人忽略,但它有个隐藏价值:只要数据结构相同,生成的文件内容就是确定性的。对于做配置比较、接口快照、结果校验的场景,这能让 diff 变得极其干净,你一眼就能看出哪里有变化,而不是每次都被随机顺序干扰。

separators则是反方向的优化。设置成separators=(",", ":")会去掉多余空格和换行,把整个文件压缩成一行,节省磁盘空间和传输带宽。我的习惯是:人要读的文件用indent=2,机器读的日志类大文件用压缩格式,两者用同一个dump工具函数,只是对外暴露的参数不同。

# 开发模式:方便阅读 json.dump(data, f, ensure_ascii=False, indent=2, sort_keys=True) # 生产模式:压缩省空间 json.dump(data, f, ensure_ascii=False, separators=(",", ":"))

2.4 哪些数据能存进 JSON,哪些不能

JSON 原生支持的基本类型有限:字符串、数字、布尔、null、数组、对象。对应到 Python 里就是str、int、float、bool、None、list、dict。超出这个范围的类型,直接丢给json.dump一定会报TypeError: Object of type X is not JSON serializable。

典型的坑有:set不能存、datetime不能存、Decimal不能存、numpy 的数组和数值类型不能存。很多人写矩阵或者量化策略参数时习惯用 numpy,保存时直接炸掉,就是因为 numpy 的int64、float64、ndarray都不在 JSON 的原生支持列表里。

解决办法有两种。简单粗暴的是先把数据转成原生类型:numpy 数组用.tolist(),日期转成字符串或时间戳,Decimal 用float()。但如果数据里藏了大量嵌套结构,一个个转就太累了,更合适的做法是给json.dump挂一个default参数,写一个统一的类型转换函数:

import datetime from decimal import Decimal def json_default(obj): if isinstance(obj, (datetime.datetime, datetime.date)): return obj.isoformat() if isinstance(obj, Decimal): return float(obj) if isinstance(obj, (set, frozenset)): return list(obj) raise TypeError(f"type {type(obj)} not serializable") json.dump(data, f, ensure_ascii=False, default=json_default)

这样嵌套在 dict、list 里面的特殊类型也会被自动处理。核心原则是:尽可能往 JSON 原生类型靠拢,存进去的东西要么是最终展示结果,要么能被稳妥地反序列化回来。

3. 三个实际场景的落盘方案(可直接复制)

3.1 场景一:爬虫结果增量保存

爬虫任务很少是一次性的,大部分是每天或者每小时增量运行。最简单的写法是直接json.dump把整个结果覆盖写进文件,但这有个问题:如果采集过程中断了,之前保存的数据就全没了。所以我的爬虫保存逻辑一直按“先读旧文件、合并新数据、再原子写回”这个流程设计。

import json import os def load_json_list(path): if not os.path.exists(path): return [] with open(path, "r", encoding="utf-8") as f: return json.load(f) def save_json_list(path, items): os.makedirs(os.path.dirname(os.path.abspath(path)), exist_ok=True) with open(path, "w", encoding="utf-8") as f: json.dump(items, f, ensure_ascii=False, indent=2) # 使用示例 new_data = [{"id": 1, "title": "测试"}, {"id": 2, "title": "样例"}] old_data = load_json_list("items.json") old_data.extend(new_data) save_json_list("items.json", old_data)

这个方案能防止数据整体丢失,但每次全量写回,文件会越来越大,性能也会慢慢变差。数据量超过几十万条以后,建议换更专业的存储,比如 SQLite。不过在爬虫初期的量级下,这个方案完全够用,而且很简单。

3.2 场景二:应用配置文件读写

JSON 做配置文件的频率相当高,几乎每个项目都会用到。我推荐的做法是“有默认值兜底,写入时只改动需要变的部分”。这样即使配置文件被删了、改坏了,程序也能用内置默认值跑起来,不会直接崩溃。

DEFAULTS = { "interval": 60, "retry_times": 3, "output_dir": "./data", "debug": False, } def load_config(path): if not os.path.exists(path): return DEFAULTS.copy() with open(path, "r", encoding="utf-8") as f: user_config = json.load(f) # 合并:用户配置覆盖默认配置 config = {**DEFAULTS, **user_config} return config def save_config(path, config): os.makedirs(os.path.dirname(os.path.abspath(path)), exist_ok=True) with open(path, "w", encoding="utf-8") as f: json.dump(config, f, ensure_ascii=False, indent=2)

这里有一个经验:不要每次修改配置都全量覆盖文件。如果任务是无人值守的,程序运行时改了参数,需要先读旧配置、更新对应键、再写回。直接用默认配置覆盖文件,会把别人手工改动过的内容冲掉。这个问题的典型表现就是同事改了服务器上的配置,你的任务跑了一遍之后给改回去了,排查起来非常费劲。

3.3 场景三:结构化数据与矩阵类结果

很多人以为 JSON 只能存简单配置,实际上它完全能存矩阵、邻接表、量化策略参数这类结构化数据。以邻接矩阵为例,Python 里的常规表示就是list[list],直接存 JSON 完全没问题。

adj_matrix = [ [0, 1, 0], [1, 0, 1], [0, 1, 0], ] payload = { "size": len(adj_matrix), "matrix": adj_matrix, } with open("graph.json", "w", encoding="utf-8") as f: json.dump(payload, f, ensure_ascii=False, indent=2)

这里刻意加了size字段,就是为了读取时快速校验维度一致性,不用真的去数行数。如果矩阵数据本身是 numpy 生成的,一定要先.tolist()再交给json.dump,否则就会撞上 2.4 里说的类型问题。数据量特别大、单个 JSON 文件超过几百 MB 的时候,我会拆成多个分片文件加一个索引文件来存,索引文件记录分片路径和记录范围,读取时按需加载,比一次性全部怼进内存稳得多。

3.4 云服务器部署要点

把这些保存方案放到 HoRain云 上用,有几个点必须提前处理好。

第一是路径。服务器上的 Python 进程不一定由你自己手动启动,cron 定时任务的工作目录可能完全不固定。所以保存文件尽量用绝对路径,别用相对路径。我习惯用Path来拼路径,同时用mkdir(parents=True, exist_ok=True)保证目录一定存在:

from pathlib import Path output_dir = Path("/data/app/result") output_dir.mkdir(parents=True, exist_ok=True) output_path = output_dir / "latest.json"

第二是权限。如果是定时任务跑出来的文件,文件属主可能是 root 或者某个服务账号,下次用另一个用户覆盖写时会出现 PermissionError。最简单的做法是确认运行账号有目录的写权限,或者提前把目录属主改成运行用户。文件权限控制在 644(所有人可读、属主可写)通常够用,别一上来就 666,安全习惯还是要有的。

第三是日志。无人值守的任务如果写文件失败,最好能把原因写进日志文件,不然第二天完全无从查起。配合 logging 模块,几行配置就够:

import logging logging.basicConfig(level=logging.INFO, filename="/data/app/logs/save.log", format="%(asctime)s %(levelname)s %(message)s") logger = logging.getLogger(__name__) logger.info("saved %s", output_path)

4. 常见问题与排查技巧实录

现象排查方向解决手段
文件全是一堆\uXXXX没设置ensure_ascii=False写入时加上该参数
读取时JSONDecodeError文件是空文件或写入过程中被截断用 try/except 兜底,做备份恢复
PermissionError: [Errno 13]运行用户对目录无写权限检查用户与目录权限
FileNotFoundError目录不存在mkdir(parents=True, exist_ok=True)
中文报UnicodeEncodeErroropen()默认编码是 gbk写文件时指定encoding="utf-8"
set is not JSON serializable有不支持的 Python 类型用 default 转换函数

4.1 文件内容损坏:写了一半进程挂了

这是我在云服务器上实际踩过的坑。程序跑着跑着机器断电或者进程被 kill,写文件那条语句执行了一半,JSON 文件就成了截断状态,下次读取直接报JSONDecodeError: Expecting value。要解决这个问题,最稳妥的是“原子写入”方式:先写一个临时文件,写完再通过os.replace覆盖目标文件。因为os.replace是原子性的,目标路径上要么是完整的旧文件,要么是完整的新文件,不会出现半截状态。

import os import tempfile def atomic_write_json(path, data): dir_path = os.path.dirname(os.path.abspath(path)) os.makedirs(dir_path, exist_ok=True) fd, tmp_path = tempfile.mkstemp(dir=dir_path, suffix=".tmp") try: with os.fdopen(fd, "w", encoding="utf-8") as f: json.dump(data, f, ensure_ascii=False, indent=2) os.replace(tmp_path, path) except Exception: os.unlink(tmp_path) raise

这个函数我现在几乎在所有需要落盘的地方都直接用。它避免了一个看起来很傻但其实很容易出现的问题:进程运行到with open()之前正常,打开文件后还没写完就崩了,原来的好文件被覆盖成坏文件。用了临时文件 +os.replace,坏情况最多是临时文件残留,正式文件永远是完整的。

4.2 中文编码报错:UnicodeEncodeError

这个问题在 Windows 本地环境特别常见。原因是 Python 的open()函数在不指定编码时,Windows 默认用 gbk,Unix 默认用 utf-8。如果代码里写成open("data.json", "w"),在 Windows 上遇到中文就会爆UnicodeEncodeError: 'gbk' codec can't encode character。解决办法就一条:写文件时永远显式写encoding="utf-8",不要依赖系统默认值。在 Linux 云服务器上虽然默认是 utf-8 很少出问题,但把编码写清楚也是好习惯,程序迁移到别的环境才不会突然出岔子。

4.3 文件总被覆盖:全量写入的副作用

我帮人排查过一个很典型的问题:两个 Python 进程都在写同一个配置文件,后写完的覆盖先写完的,中间读出来的配置总是对不上,甚至出现数据合并冲突。这个问题的根因是“多个写者共用一个文件”。解决方案有两种思路。一种是把任务粒度拆细,每个任务只写自己的专属文件,比如按日期生成log-2026-01-01.json,互不干扰。另一种是明确读写角色:一个进程只负责写,其他进程只负责读,别让谁都能随便覆盖同一个文件。多人并行改配置时,“谁负责写、谁只读”这个边界一定要定清楚。

4.4 set、numpy 类型报错:统一转换函数救场

如果你在代码里保存过字典的keys()视图、集合或者 numpy 标量,一定见过TypeError: Object of type set is not JSON serializable。这个报错指向很明确,就是有类型不支持。解决办法就是 2.4 里的default参数。我把json_default这个函数放在项目的工具模块里,所有项目统一引用,遇到新类型就在里面加一行,维护成本几乎为零,总比每处代码都重新写转换逻辑要省事得多。

5. 实操体会

5.1 我在 HoRain云 上落地这套方案的经验

最开始我在云服务器上部署定时爬虫,就是随手写了个json.dump,结果第二天发现文件是空白的。查了半天才发现目录没有权限,进程抛异常后没人看日志,任务默默失败了一个晚上。从那之后,我把“保存 JSON”这件事彻底标准化了:统一用atomic_write_json函数、统一encoding="utf-8"、统一ensure_ascii=False、统一留日志。后续所有新项目都从这套基础函数上复用,再也没出过类似的问题。

如果你的任务也是无人值守运行,建议先把这套“标准动作”搭起来,再处理业务逻辑。基础写文件环节稳了,后面才能安心折腾爬虫解析、数据清洗这些更复杂的东西。

5.2 一个小技巧:JSON Lines 做增量日志

最后再分享一个小技巧。如果你要保存的数据是“不断追加”的,比如每次采集一批记录,用普通 JSON 数组格式会很尴尬:每次都得读整个文件、追加、再重写。这种情况下我推荐使用 JSON Lines 格式,也就是后缀.jsonl,每行一个独立的 JSON 对象:

{"id": 1, "title": "第一条"} {"id": 2, "title": "第二条"}

Python 写入时用普通文本模式,每行json.dumps(item, ensure_ascii=False)加上换行符即可。读取时逐行解析,遇到损坏的行可以跳过,不影响整体数据。这种格式对日志类数据特别友好,既能增量追加,又方便用tail命令实时观察,还能被很多日志采集工具直接读取。如果你的任务也是增量追加型数据,我建议优先考虑 JSON Lines,而不是每次全量重写一个数组文件。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询