When PHP's unserialize silently truncated our 40MB session payload

这篇内容暂时只有 English 版本,以下是原文。

PHP 8.2 on Ubuntu 22.04, PHP-FPM with Redis session handler, sessions stored via session.serialize_handler = php. A customer reported being logged out at random, roughly once a day, always after a long editing session. Sessions were about 40MB (a draft document with embedded image blobs, don’t ask). No errors in the log, no exceptions, the session just came back empty.

My first instinct was Redis eviction. INFO stats showed evicted_keys: 0, maxmemory-policy: noeviction. So the key was there. I pulled the raw value with redis-cli --raw GET sess:<id> | wc -c and got 4194304 bytes exactly. Not 40MB. Exactly 4MiB. That number is too round to be a coincidence.

The session key wasn’t truncated by Redis — it was truncated before it was written. Redis SET with a value that big is fine. What I found: session_write_close() returned true, no warning. But the data being serialized was already broken. The culprit was our own code doing this:

$data = serialize($payload);
if (strlen($data) > 4194304) {
    $data = substr($data, 0, 4194304);
}
file_put_contents($path, $data);

Written by someone (possibly me, two years ago) who assumed truncating a serialized string was safe. It is not. unserialize() on a truncated string returns false and, depending on error_reporting, may or may not log anything. Our handler treated false as “no session data” and started a fresh one.

So the mechanism: PHP’s session serializer does not guarantee atomicity across the write. If you truncate, you get a string that looks plausible but parses to false. The session handler then behaves as if the user is new. The cost of “fixing” it with a bigger limit is memory: 40MB sessions in Redis, 40MB read back on every request, and PHP-FPM workers holding that in memory_limit. We had memory_limit = 256M, so a few concurrent large sessions would have pushed workers into OOM.

What actually worked: stop storing blobs in the session. Move the draft body to a separate drafts table keyed by user id, keep only the draft id in $_SESSION. That cut the session to under 2KB. The migration was boring, which is what you want.

Where this approach fails: if you genuinely need large per-request state and cannot persist it, you are back to the same memory math, just with a different storage backend. Also, session.serialize_handler = php versus php_serialize behave differently on nested arrays with special characters in keys — php uses | as a separator and mangles keys containing |. We hit that later with a key like filter|status. The fix there was switching to php_serialize and bumping session.serialize_handler in php.ini, but that changes the on-disk format, so existing sessions become unreadable. Plan for a forced logout window.

If you have ever had a session handler return success while writing garbage, what did you use to catch it — a checksum, a length prefix, or just a unserialize() check before trusting the read?

0
赞同度 100% · 0 条评论 · 9 阅读

0 条评论

支持 Markdown · 回复会实时推送给对方

还没有评论,写下第一条。