前言
把物件丟進快取,讀出來卻壞了——這件事在 PHP、Java、Python、Ruby 都會發生。
快取系統(Redis、Memcached)只存字串。所以你要把資料存進快取之前,得先轉成字串,這個過程叫序列化(serialize)。讀出來的時候再轉回原本的資料結構,叫反序列化(unserialize / deserialize)。
PHP 用 serialize() / unserialize(),Java 用 ObjectOutputStream / ObjectInputStream,Python 用 pickle。各語言的實作不同,但概念一樣:存的時候轉成字串,讀的時候轉回來。
純資料(陣列、數字、字串)轉過去轉回來不會有事。但物件不一樣,它帶著 class 身份,轉回來的時候程式必須找到那個 class 才行。這就是問題的根源。
這篇從 PHP 的角度出發,講為什麼快取裡不該有物件,以及其他語言踩到的是同一個坑。
物件序列化帶了什麼
用 PHP 的 serialize() 來看差異:
// 純陣列
serialize(['name' => 'Laravel', 'version' => 13]);
// → 'a:2:{s:4:"name";s:7:"Laravel";s:7:"version";i:13;}'
// 物件
serialize(new UserResource($user));
// → 'O:28:"App\Http\Resources\UserResource":1:{s:8:"resource";O:15:"App\Models\User":{...}}'
陣列只有 key 和 value。物件多了 O:28:"App\Http\Resources\UserResource" — class 的完整名稱、內部狀態、依賴的其他物件,全部都被塞進去了。
反序列化時,PHP 要找到 App\Http\Resources\UserResource 這個 class 才能還原。找不到就壞了。
為什麼不該存物件
耦合
你重構了 namespace,或改了 class 的屬性結構。程式碼都沒問題,但快取裡的舊資料還指向舊的 class。
// 快取裡存的還是這個
'O:28:"App\Http\Resources\UserResource":{...}'
// 但 class 已經搬到 App\Resources\V2\UserResource
// 反序列化 → 找不到 class → 失敗
這種 bug 本地開發抓不到,因為本地快取是空的。只有線上環境的舊快取會出事。
安全
反序列化攻擊是各語言都有的問題,不只是理論。攻擊者偽造一段序列化字串,塞進你的 Redis。程式 unserialize() 的時候,物件的 __wakeup() 或 __destruct() 被觸發,就能執行任意程式碼。
陣列和字串沒有 magic method,反序列化的時候不會執行任何東西。
可移植性
PHP serialize() 產出的格式,只有 PHP 讀得懂。Java 的 ObjectOutputStream 也一樣。但 JSON 誰都能讀。如果快取存的是純資料,換語言、加 consumer 都不用擔心格式問題。
真實案例:Laravel 13
Laravel 13 在 config/cache.php 加了一個預設設定:
'serializable_classes' => false,效果是 unserialize() 讀快取時,禁止還原任何物件。快取裡如果有物件,讀出來會變成 __PHP_Incomplete_Class:
{
"main_image": {
"__PHP_Incomplete_Class_Name": "App\\Http\\Resources\\ImageResource",
"resource": {
"__PHP_Incomplete_Class_Name": "App\\Models\\Media"
}
}
}這個問題在 Laravel 12 就存在了,只是以前 unserialize() 默默幫你還原所有物件,沒人發現快取裡混進了不該有的東西。
踩雷過程和修法:升級 Laravel 13 後,快取讀出來的資料全變了
其他語言的情況
PHP 的問題不特殊。各語言的官方文件對自家的序列化機制都有安全警告。
PHP
$data = unserialize($cached, ['allowed_classes' => false]);官方文件寫得很直接:「Do not pass untrusted user input to unserialize()」。unserialize() 還原物件時會觸發 __wakeup(),攻擊者可以利用這點執行程式碼。
Java
ObjectInputStream ois = new ObjectInputStream(inputStream);
Object obj = ois.readObject();Java 的反序列化攻擊大概是最有名的。Apache Commons Collections 的漏洞 CVE-2015-7501 影響了非常多企業應用。
Python
import pickle
data = pickle.loads(cached_bytes)官方文件直接說:「The pickle module is not secure. Only unpickle data you trust.」pickle 反序列化時可以呼叫 os.system() 之類的函式。
Ruby
data = Marshal.load(cached_bytes)官方文件:「By design, Marshal.load can deserialize almost any class loaded into the Ruby process.」建議改用 JSON。
怎麼修
PHP
// 存物件 — 有問題
Cache::put('products', $products->map(fn ($p) => [
'id' => $p->id,
'cover' => new ImageResource($p->coverImage),
])->toArray(), 600);
// 存純陣列 — OK
Cache::put('products', $products->map(fn ($p) => [
'id' => $p->id,
'cover' => $p->coverImage
? ImageResource::make($p->coverImage)->resolve()
: null,
])->toArray(), 600);resolve() 會把 Resource 立刻轉成陣列。
Python
# 存物件 — 有問題
cache.set('user', user_object)
# 轉成 dict — OK
from dataclasses import asdict
cache.set('user', asdict(user_object))Java
// 存 Serializable — 有問題
redisTemplate.opsForValue().set("user", userEntity);
// 轉成 JSON string — OK
Map<String, Object> data = Map.of(
"id", user.getId(),
"name", user.getName()
);
redisTemplate.opsForValue().set("user",
objectMapper.writeValueAsString(data));判斷標準
存進快取前問自己:這個東西 json_encode() 得過去嗎?過不了就不該存。
JavaScript 為什麼沒有這個問題
JSON.stringify() 只處理純資料。不記錄 class 名稱,不保留 prototype chain,反序列化時不觸發任何函式。JSON 的設計從一開始就排除了物件身份,所以 JS 開發者不太會踩到這個坑。
換個角度想:其他語言的開發者需要手動做的事(物件轉純資料再存),在 JS 的生態裡是預設行為。
結語
PHP、Java、Python、Ruby 的官方文件都在警告同一件事:不要對不信任的資料做反序列化。快取裡存純資料,從源頭避開這整類問題。
Laravel 開發者可以看另一篇實際案例:升級 Laravel 13 後,快取讀出來的資料全變了。