How PHP OPcache Silently Degrades Bitrix in Production (and the 3-Line Fix)
For three months we chased a bottleneck in a Bitrix store. Rewrote MySQL indexes. Added Redis caching where it hadn't been. Pages got a bit faster, but the catalog still hung at 800-900ms on production with no obvious reason.
Then we ran opcache_get_status() and looked at one number: opcache_hit_rate — 61%.
A well-configured OPcache should be above 95%.
Twenty minutes in php.ini and p95 dropped by half. We never touched SQL.
Why OPcache and Bitrix make a tricky pair
OPcache is a PHP extension that stores compiled bytecode in shared memory, eliminating the need to parse and compile PHP source files on every request. At a 61% hit rate, roughly one in three file accesses still goes to disk. With Bitrix, that's expensive: every catalog page request touches hundreds of module, component, and template files.
The default OPcache config was written for PHP 7 and modest applications. Bitrix's core is heavier than most frameworks. The defaults don't account for this.
The tricky part: symptoms are vague. Pages are sometimes slow, sometimes fine. Things get worse under load. Teams dig into MySQL query time, nginx config, background workers — and miss the obvious: the PHP layer is running at half capacity.
The key metric: hit ratio below 95%
opcache_hit_rate is the percentage of PHP file accesses served from the in-memory cache rather than compiled from disk. Values below 95% indicate a configuration problem. Drop this into a temporary PHP file on the server to check yours:
<?php
$status = opcache_get_status();
echo 'Hit rate: ' . round($status['opcache_statistics']['opcache_hit_rate'], 2) . '%' . PHP_EOL;
echo 'Cached files: ' . $status['opcache_statistics']['num_cached_scripts'] . PHP_EOL;
echo 'Memory used: ' . round($status['memory_usage']['used_memory'] / 1024 / 1024) . 'M' . PHP_EOL;
echo 'Memory free: ' . round($status['memory_usage']['free_memory'] / 1024 / 1024) . 'M' . PHP_EOL;
echo 'OOM restarts: ' . $status['opcache_statistics']['oom_restarts'] . PHP_EOL;
If opcache_hit_rate is below 95%, you have a configuration problem. If oom_restarts is above zero, OPcache is running out of memory and flushing the entire cache periodically. That's what creates the instability: after a flush, the first requests go to disk, performance drops, then recovers — and the team blames an "unstable server."
opcache.memory_consumption: the default 128M isn't enough
The default opcache.memory_consumption = 128 is reasonable for a small PHP project. Bitrix isn't small.
A fresh Bitrix 23.x installation has 8,000–12,000 PHP files just in the core (/bitrix/modules). Add your custom code, third-party modules, templates. On the project I'm describing, we had 9,400 files under /bitrix/ alone.
Each compiled file takes roughly 8–25 KB in OPcache memory. Quick math: 9,400 × 15 KB ≈ 141 MB. That's only the core. With a 128M limit, OPcache can't hold the full core in memory. It starts evicting files. On the next request it compiles them again. Repeat. The cycle creates constant minor cache misses that accumulate into visible latency.
For Bitrix on production, 256M is the minimum. High-traffic shops should consider 512M.
opcache.memory_consumption = 256
opcache.max_accelerated_files: Bitrix core has 8,000+ PHP files
This one gets overlooked. The default varies by distribution — sometimes 10,000, sometimes as low as 4,000. At 9,400+ files that's already a problem.
Check your actual file count:
find /path/to/bitrix -name "*.php" | wc -l
opcache.max_accelerated_files needs to be higher than that number, with room for growth. Also: OPcache uses a hash table internally, and prime numbers reduce collisions. Use values like 7963, 16229, or 20011.
opcache.max_accelerated_files = 20011
validate_timestamps=0 in production: scary but correct
With opcache.validate_timestamps = 1 (the default), PHP checks whether each source file has changed on disk before serving it from the cache. On every request. Even though the file almost certainly hasn't changed.
In production this is wasted work. Bitrix core files don't change between deploys. Your custom code doesn't change while the site is running.
With validate_timestamps = 0, OPcache skips the disk check entirely and returns cached bytecode directly. The only requirement: you need to flush the cache manually after each deploy via opcache_reset() or a PHP-FPM restart.
opcache.validate_timestamps = 0
Worried about forgetting to flush? Add opcache_reset() to your deploy script. One line.
The three lines that fix it
The minimal set for Bitrix in production:
opcache.memory_consumption = 256
opcache.max_accelerated_files = 20011
opcache.validate_timestamps = 0
After the change, restart PHP-FPM (systemctl restart php8.1-fpm), then check opcache_get_status() again. If hit rate hasn't crossed 90%, look at oom_restarts and num_cached_scripts. You might need to push memory_consumption higher.
On our project, hit rate went from 61% to 98% with these three lines.
What we measured after the fix
Server: 4-core, 8 GB RAM, PHP 8.1, Bitrix store with ~1,500 DAU.
Before:
opcache_hit_rate: 61%- p95 page time (catalog): ~900 ms
oom_restartsper day: 12
After:
opcache_hit_rate: 98%- p95 page time (catalog): ~420 ms
oom_restartsper day: 0
No SQL queries changed. No nginx config changed. Three lines in php.ini.
Takeaway
OPcache is the layer people check last. It should be first.
If Bitrix is slow on production, run opcache_get_status() before touching MySQL or nginx. Five seconds to read the output, and you know immediately: is this a PHP layer problem or not? Then investigate accordingly.
For what to look at next inside Bitrix itself, see Bitrix doesn't slow down. The way teams use it does. For cache warming as a second layer of defense, see Cache warming via Cron.