After upgrading to PHP 8.5 a few days ago, I have been continuing to address compatibility and performance issues on my WordPress site.
I previously published a record related to PHP 8.5 and PHP-FPM tuning:
This time, my initial focus was still on PHP 8.5, but by the end of the troubleshooting process, I discovered that the real cause of the abnormally slow dynamic homepage generation was neither PHP 8.5, insufficient PHP-FPM parameters, nor a lack of server CPU or memory.
The final problem was concentrated in a very specific location:
The “Most Viewed Posts” ranking query in Post Views Counter.
After the optimization:
- The Chinese dynamic homepage load time decreased from 18.6–18.8 seconds to 0.83–0.89 seconds
- The English dynamic homepage stabilized at 0.87–0.96 seconds
- The ranking articles, their order, and view counts all remained consistent
- PHP-FPM no longer generates slow requests exceeding 3 seconds for these two homepages
This result is much better than my initial expectation of “keeping it under 10 seconds if possible.”
1. At first, I thought the issue might be with PHP 8.5
My current WordPress runtime environment includes:
- WordPress 7.0.3
- PHP 8.5.9
- Nginx 1.30.4
- Redis 8.10.0
- MySQL 5.7.32
- Twenty Twenty-Five theme
- Polylang
- W3 Total Cache
- Post Views Counter 1.7.13
The server itself does not have high specifications, but after checking system resources, I found no obvious pressure.
There is about 2 GB of memory available, Swap is barely used, and the system load is very low.
PHP-FPM is already using dynamic process management, configured with:
pm = dynamic
pm.max_children = 10
pm.start_servers = 2
pm.min_spare_servers = 2
pm.max_spare_servers = 4
pm.max_requests = 500
request_slowlog_timeout = 3s
slowlog = /usr/local/php/var/log/slow.log
OPcache is also properly enabled.
Therefore, if a WordPress homepage takes nearly 20 seconds to generate dynamically, simply increasing the number of PHP-FPM workers cannot explain the problem.
2. You must bypass the CDN and page cache first, otherwise the test results are meaningless
My Chinese site uses EdgeOne, the English site uses Cloudflare, and W3 Total Cache is enabled on the origin server.
Therefore, directly accessing:
and seeing the page load quickly does not prove that the PHP dynamic execution speed is normal.
If the CDN or W3TC has already hit the cache, the request might not have executed the full WordPress homepage generation process at all.
There is also another issue that can easily lead to misjudgment.
Previously, I would try to force a cache MISS by adding a random query string to the homepage, such as:
/?random=123
to attempt to trigger a cache MISS.
However, my EdgeOne cache rules themselves ignore query strings, and they also strip query strings when forwarding requests to the origin.
Therefore, this testing method is actually unreliable.
Later, I switched to using curl’s –resolve parameter to temporarily resolve www.shuijingwanwq.com:443 to 127.0.0.1, completely bypassing EdgeOne.
At the same time, I added a Cookie simulating a WordPress logged-in state, such as wordpress_logged_in_swq_perf_test=1, to make W3 Total Cache bypass the Page Cache.
Only what is tested this way represents the true:
Nginx → PHP-FPM → WordPress → Database → Dynamic page generation
3. The real dynamic homepage surprisingly takes a stable 19 seconds
After bypassing the CDN and W3TC page cache, I continuously tested the Chinese homepage:
The TTFB for the first test was 18.607824 seconds, and the second was 18.820124 seconds.
This was no longer an occasional fluctuation.
The dynamic homepage indeed consistently took nearly 19 seconds.
For comparison, when not forcing a bypass of W3TC:
- First time: about 19 seconds
- Second time: only about 0.016 seconds
In other words, the second request directly hit the page cache.
Caching only hid the problem.
The real WordPress dynamic homepage was still extremely slow.
4. PHP-FPM slowlog points the target to Post Views Counter
Since I had previously configured PHP-FPM with:
request_slowlog_timeout = 3s
I proceeded directly to check the slowlog.
Several consecutive slow requests all ended up on a similar call chain:
mysqli_query()
wpdb::_do_query()
wpdb::query()
wpdb::get_results()
WP_Query::get_posts()
get_posts()
pvc_get_most_viewed_posts()
pvc_most_viewed_posts()
most_viewed_posts_render_callback()
Corresponding plugin:
wp-content/plugins/post-views-counter/
At this point, the troubleshooting scope had narrowed significantly.
The homepage itself has two Post Views Counter usage scenarios:
One is displaying the view count for each post in a standard post list.
The other is displaying in the sidebar:
Most Viewed Posts / Most Viewed Posts ranking
The one that ultimately had the real problem was the latter.
5. What SQL did the homepage ranking actually execute?
When Post Views Counter fetches the “10 most viewed posts,” the core SQL looks like:
SELECT DISTINCT
wp_posts.*,
SUM(COALESCE(pvc.count, 0)) AS post_views
FROM wp_posts
LEFT JOIN wp_post_views pvc
ON pvc.id = wp_posts.ID
AND pvc.type = 4
WHERE wp_posts.post_type = 'post'
AND wp_posts.post_status = 'publish'
GROUP BY wp_posts.ID
HAVING post_views > 0
ORDER BY post_views DESC, wp_posts.post_date DESC
LIMIT 0, 10;
Because the Chinese homepage also uses Polylang, the actual query also includes language taxonomy conditions.
At first, I suspected:
- Whether the data volume in
wp_post_viewswas too large - Whether indexes were missing
- Whether the Polylang JOIN was too slow
But upon further inspection, I found these were not the main causes.
wp_post_views has only a little over 150,000 rows, and the relevant composite indexes already exist.
EXPLAIN also shows that indexes are being used.
What is truly noteworthy is:
SELECT wp_posts.*
And the subsequent:
GROUP BY
ORDER BY
SUM()
6. The real breakthrough: it’s not GROUP BY itself that is slow, but wp_posts.*
I then ran three direct database performance tests.
Original ranking query
Keeping:
wp_posts.*
Result:
18.305059 seconds
Meanwhile, the database status showed:
Created_tmp_tables +1
Created_tmp_disk_tables +1
That is:
This query created a disk temporary table.
Selecting only the fields actually needed for ranking
I then reduced the SELECT to:
- ID
- post_date
- post_views
The ranking logic, JOIN, language filtering, GROUP BY, and ORDER BY all remained unchanged.
Result:
0.022080 seconds
And:
Created_tmp_tables +1
Created_tmp_disk_tables +0
This time, although a temporary table was still created, it no longer spilled to disk.
Fetching full articles based on the final 10 IDs
Then, an additional query was executed:
SELECT *
FROM wp_posts
WHERE ID IN (...)
Fetching only the 10 articles finally selected for the ranking.
Time taken:
0.003841 seconds
Therefore, the two-phase query combined took only tens of milliseconds.
And the top 10 ranking and their view counts were completely identical to the original query.
7. Why did wp_posts.* drag the query from 22 milliseconds to 18 seconds?
This explains the entire problem.
WordPress’s wp_posts is not a lightweight table with only titles and IDs.
It contains:
post_contentpost_excerptpost_passwordpost_title- GUID
- And a large number of other fields
Among these, the particularly important ones are:
post_content
post_excerpt
These are TEXT/LONGTEXT type fields.
The current database is MySQL 5.7.
The original query combined the full:
wp_posts.*
With:
SUM()
GROUP BY
ORDER BY
into a single operation.
Ultimately, MySQL had to involve data containing large text fields in intermediate aggregation and sorting, causing the internal temporary table to spill to disk.
What actually needs ranking is clearly only:
Post ID + view count + date
But the database was moving the entire article body during the sorting phase.
This is the core source of the 18-second consumption.
8. A two-phase query is a more reasonable structure
From a practical standpoint, the most viewed posts ranking can easily be split into two steps.
The first phase is solely responsible for:
Finding which posts should make it into the Top 10.
The required data is minimal:
ID
post_date
post_views
After sorting, the final 10 IDs are obtained.
The second phase then fetches:
The complete WP_Post objects for these 10 posts.
This way, even if the ranking ultimately needs to display:
- Title
- View count
- Excerpt
- Author
- Thumbnail
there is no need to involve these fields in advance in the GROUP BY and ORDER BY of thousands of posts.
This is also why:
Which fields are selected in the first phase, and which fields are ultimately displayed on the webpage, should inherently be two different questions.
9. The first MU Plugin implementation actually failed
I did not intend to directly modify the Post Views Counter plugin source code.
The reason is simple:
When the plugin is updated in the future, the modifications will be overwritten.
Therefore, I decided to temporarily use an MU Plugin:
wp-content/mu-plugins/swq-pvc-most-viewed-optimization.php
However, the first version took an approach that seemed natural but was actually flawed:
$args['fields'] = 'ids';
The idea was to have WordPress return only IDs in the first phase.
As a result, the query was indeed fast:
elapsed=0.003310 seconds
But:
count=0
The ranking was directly empty.
After further capturing the actual SQL, I found:
SELECT DISTINCT wp_posts.ID
...
GROUP BY ...
HAVING post_views > 0
ORDER BY post_views DESC
The problem became immediately clear.
Post Views Counter originally adds:
SUM(...) AS post_views
But WordPress’s fields=ids forcibly reduces the SELECT to:
wp_posts.ID
Ultimately:
HAVING post_views
ORDER BY post_views
still existed, while post_views in the SELECT had disappeared.
Therefore, the first version of the solution was abandoned.
10. MU Plugin 1.1.0: No longer modifying fields=ids
The second version changed its strategy.
It no longer tells WP_Query:
Return only IDs
Instead, it lets Post Views Counter construct as before:
- JOIN
- WHERE
- Polylang language filtering
- GROUP BY
- HAVING
- ORDER BY
Finally, it only narrows the SELECT list via posts_fields to:
wp_posts.ID,
wp_posts.post_date,
SUM(COALESCE(pvc.count, 0)) AS post_views
Then:
- The first phase yields the sorted Top 10;
- Saves the IDs and
post_views; - Fetches the complete
WP_Postbased on the Top 10 IDs; - Reassembles them in the order from the first phase;
- Puts
post_viewsback into the post objects.
This way, Post Views Counter’s subsequent rendering logic barely needs to change.
Current MU Plugin version:
1.1.0
11. The Chinese ranking query has been reduced from 18 seconds to 0.38 seconds
After enabling MU Plugin 1.1.0, I directly called again:
pvc_get_most_viewed_posts()
Result:
elapsed=0.379339 seconds
count=10
More importantly, the ranking data was completely identical.
The top few remained:
- A full review of the process from deactivating LetsVPN to setting up a self-hosted WireGuard VPN
- Telegram SMS Fee and Premium SMS verification code issues
- ZgoCloud + Wstunnel + WireGuard speed boost in practice
- WireGuard direct connection in China + tunneling abroad
- WireGuard VPN direct connection optimization in China
The view counts were also completely identical.
This shows that the optimization did not sacrifice data correctness.
12. Chinese dynamic homepage: dropped from 19 seconds to about 0.84 seconds
Next, I bypassed EdgeOne and W3TC Page Cache again and tested three times consecutively.
Results:
request=1 ttfb=0.887508 total=0.888629
request=2 ttfb=0.833429 total=0.834476
request=3 ttfb=0.835441 total=0.836537
Before optimization:
About 18.6–18.8 seconds
After optimization:
About 0.83–0.89 seconds
This is roughly 22 times faster.
Moreover, what was tested here was not a CDN HIT, nor W3TC Page Cache, but the dynamic homepage truly executing WordPress.
13. The English homepage must also be verified
Both my Chinese and English homepages have the Post Views Counter most viewed posts ranking.
Currently, only these two homepages use this ranking.
So testing only the Chinese homepage is not enough.
First, I directly tested the English ranking:
elapsed=0.103985 seconds
count=10
All 10 returned posts were English articles, for example:
- The English article corresponding to the Telegram SMS Fee issue
- The Ubuntu 26.04 Fcitx5 input method article
- The ZgoCloud / Wstunnel / Clash Verge Rev autostart article
- The WireGuard troubleshooting article
This shows that the Polylang English filtering was not broken by this optimization.
14. The English dynamic homepage also stabilized under 1 second
I then bypassed Cloudflare and W3TC Page Cache directly.
First round, three times:
0.876757 seconds
0.927064 seconds
0.885427 seconds
Second round, three times:
0.892663 seconds
0.866815 seconds
0.959465 seconds
All stabilized at:
0.87–0.96 seconds
At the same time, before the second round of testing, I recorded the PHP-FPM slowlog line count:
slowlog_before=37427
After testing:
slowlog_after=37427
new_lines=0
That is to say:
Three consecutive real English dynamic homepage requests did not generate any new PHP-FPM slow logs.
The current PHP-FPM slow log threshold is 3 seconds, so this result perfectly matches the test data of the homepage taking under 1 second.
15. There are still records in the slowlog, but they are no longer related to the homepage ranking
After testing finished, some new records could still be seen in the slowlog.
But the call stacks for these records changed to:
curl_exec()
WordPress HTTP API
WP AI Client
SlyTranslate
swq-glm52-translation-tuning.php
These are slow requests generated by SlyTranslate waiting for network responses when calling the AI interface.
They are no longer related to the Post Views Counter ranking.
This is actually a good validation:
Before optimization, the homepage slowlog would explicitly stop at:
pvc_get_most_viewed_posts()
After optimization, such calls have disappeared.
The remaining slowlogs can be analyzed separately according to your own business scenarios, and can no longer be attributed to the homepage ranking.
16. What are the current limitations of this MU Plugin?
Currently, this MU Plugin is intended to first solve a real problem in the production environment, so I deliberately did not expand the scope of modifications too much.
The biggest limitation is:
Currently, it only optimizes
Views period = 总浏览量(total).
Both of my homepages currently happen to use this configuration.
If in the future I choose:
- Today’s views
- This week’s views
- This month’s views
- A specific time range
The current MU Plugin will continue to use the original Post Views Counter implementation.
This is not because the two-phase query can only handle total, but because these paths have not yet been verified one by one.
For production fixes, I lean more towards:
Precisely solving confirmed issues first, without casually changing untested behavior along the way.
17. Which fields are ultimately displayed is not the biggest limitation of this solution
During the troubleshooting process, I thought of another issue.
The Post Views Counter Most Viewed Posts block can choose to display:
- Post views
- Post excerpt
- Post author
- Post thumbnail
My current homepage only displays view counts.
At first glance, it seems the current optimization only suits my configuration.
But upon closer analysis, this is not the case.
Because what the second phase ultimately fetches is:
The complete WP_Post object
So theoretically, even if it later displays:
- Excerpt
- Author
- Thumbnail
this data can still be read normally.
What truly should not be done, on the contrary, is adding post_excerpt back into the first phase ranking SQL just because the excerpt ultimately needs to be displayed.
That would only once again cause a large number of text fields to be involved in temporary tables and sorting.
Therefore, the more reasonable architecture should still be:
Minimize fields needed for ranking → Get Top N → Then load the full posts for Top N.
However, since I have not currently turned on all display options one by one for complete verification, at this stage I still treat it as “theoretically compatible” rather than a fully tested feature.
18. Why use an MU Plugin temporarily instead of directly modifying the plugin?
Currently, I have not directly modified:
wp-content/plugins/post-views-counter/
The reason is still upgrade maintenance.
If I directly modify the official plugin files:
The next Post Views Counter update will very likely overwrite the modifications.
An MU Plugin, on the other hand, can exist independently:
wp-content/mu-plugins/
If a problem arises, it can also be removed immediately.
This is safer for a temporary performance fix on a production site.
Of course, this does not mean I plan to maintain my own fork permanently.
I have registered a WordPress.org account and originally intended to report this issue and the complete test data to the Post Views Counter author.
However, the account is still under review, so I am not in a hurry to prepare formal feedback for now.
Once the account is fully functional, I will decide whether to submit it.
If the official plugin adopts a similar optimization solution in the future, I would also prefer to simply delete my MU Plugin and return to the official implementation.
19. The most noteworthy conclusions from this troubleshooting
The biggest takeaway this time is not simply optimizing a certain page from 19 seconds to under 1 second.
More important are several troubleshooting approaches.
1. A fast CDN does not mean WordPress itself is fast
A cache HIT might take only a few dozen milliseconds.
But the real dynamic page might be taking 19 seconds to run in the background.
If you do not proactively bypass the CDN and Page Cache, this type of problem can easily remain hidden for a long time.
2. Do not rush to increase server specifications just because WordPress is slow
This time, the server:
- CPU was not maxed out
- Memory was sufficient
- Swap was barely used
- PHP-FPM workers were also sufficient
If I had directly upgraded the ECS, I might have just used more hardware to mask a bad database query.
3. Even if EXPLAIN uses indexes, it does not mean the SQL is definitely fast
This time, the relevant table indexes actually all existed.
The real problem was:
wp_posts.*
After participating in aggregation and sorting, the temporary results containing TEXT/LONGTEXT were pushed to disk.
4. One SQL query is not necessarily faster than two SQL queries
The original “one complete query”:
18.3 seconds
Split into:
Phase 1 ranking: about 0.022 seconds
Phase 2 loading Top 10: about 0.004 seconds
was actually faster by several orders of magnitude.
The number of SQL queries is not the sole criterion for performance.
What each SQL query actually makes the database do is more important.
20. Conclusion
When I first encountered this problem, I did indeed suspect PHP 8.5.
After all, the PHP version upgrade had just been completed recently, and there were indeed some PHP 8.5 Deprecated warnings appearing on the site.
But this complete troubleshooting process proved:
PHP 8.5 was merely the backdrop when the problem was discovered, not the real cause of the dynamic homepage taking nearly 19 seconds.
The real bottleneck was the Post Views Counter most viewed posts query:
Full wp_posts.* + SUM + GROUP BY + ORDER BY
which triggered a very expensive disk temporary table on MySQL 5.7.
Ultimately, the optimization was completed using a “lightweight ranking + Top N full post loading” approach.
This reduced the Chinese dynamic homepage from about 18.6–18.8 seconds to 0.83–0.89 seconds, and stabilized the English homepage at 0.87–0.96 seconds.
Both homepages that actually use the most viewed posts ranking have been verified. At least based on the current results, I have no need to upgrade server specifications for this problem, nor do I need to continue adjusting PHP-FPM parameters; what truly needed optimization this time was not the machine, but a single SQL query.

