没有不值得去解决的问题,也没有不值得去学习的技术!

How We Reduced a WordPress Dynamic Homepage From 19 Seconds to Under 1 Second: Troubleshooting Post Views Counter Query Performance With an MU Plugin

How We Reduced a WordPress Dynamic Homepage From 19 Seconds to Under 1 Second: Troubleshooting Post Views Counter Query Performance With an MU Plugin

作者:

在

,

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:

INI
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:

https://www.shuijingwanwq.com

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:

Plaintext
/?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:

INI
request_slowlog_timeout = 3s

I proceeded directly to check the slowlog.

Several consecutive slow requests all ended up on a similar call chain:

Plaintext
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:

Plaintext
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:

SQL
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_views was 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:

SQL
SELECT wp_posts.*

And the subsequent:

SQL
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:

SQL
wp_posts.*

Result:

Plaintext
18.305059 seconds

Meanwhile, the database status showed:

Plaintext
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:

Plaintext
0.022080 seconds

And:

Plaintext
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:

SQL
SELECT *
FROM wp_posts
WHERE ID IN (...)

Fetching only the 10 articles finally selected for the ranking.

Time taken:

Plaintext
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_content
  • post_excerpt
  • post_password
  • post_title
  • GUID
  • And a large number of other fields

Among these, the particularly important ones are:

Plaintext
post_content
post_excerpt

These are TEXT/LONGTEXT type fields.

The current database is MySQL 5.7.

The original query combined the full:

SQL
wp_posts.*

With:

SQL
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:

Plaintext
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:

Plaintext
wp-content/mu-plugins/swq-pvc-most-viewed-optimization.php

However, the first version took an approach that seemed natural but was actually flawed:

PHP
$args['fields'] = 'ids';

The idea was to have WordPress return only IDs in the first phase.

As a result, the query was indeed fast:

Plaintext
elapsed=0.003310 seconds

But:

Plaintext
count=0

The ranking was directly empty.

After further capturing the actual SQL, I found:

SQL
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:

SQL
SUM(...) AS post_views

But WordPress’s fields=ids forcibly reduces the SELECT to:

SQL
wp_posts.ID

Ultimately:

Plaintext
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:

Plaintext
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:

SQL
wp_posts.ID,
wp_posts.post_date,
SUM(COALESCE(pvc.count, 0)) AS post_views

Then:

  1. The first phase yields the sorted Top 10;
  2. Saves the IDs and post_views;
  3. Fetches the complete WP_Post based on the Top 10 IDs;
  4. Reassembles them in the order from the first phase;
  5. Puts post_views back into the post objects.

This way, Post Views Counter’s subsequent rendering logic barely needs to change.

Current MU Plugin version:

Plaintext
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:

Plaintext
pvc_get_most_viewed_posts()

Result:

Plaintext
elapsed=0.379339 seconds
count=10

More importantly, the ranking data was completely identical.

The top few remained:

  1. A full review of the process from deactivating LetsVPN to setting up a self-hosted WireGuard VPN
  2. Telegram SMS Fee and Premium SMS verification code issues
  3. ZgoCloud + Wstunnel + WireGuard speed boost in practice
  4. WireGuard direct connection in China + tunneling abroad
  5. 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:

Plaintext
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:

Plaintext
About 18.6–18.8 seconds

After optimization:

Plaintext
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:

Plaintext
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:

Plaintext
0.876757 seconds
0.927064 seconds
0.885427 seconds

Second round, three times:

Plaintext
0.892663 seconds
0.866815 seconds
0.959465 seconds

All stabilized at:

Plaintext
0.87–0.96 seconds

At the same time, before the second round of testing, I recorded the PHP-FPM slowlog line count:

Plaintext
slowlog_before=37427

After testing:

Plaintext
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:

Plaintext
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:

Plaintext
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:

Plaintext
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:

Plaintext
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:

SQL
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”:

Plaintext
18.3 seconds

Split into:

Plaintext
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:

Plaintext
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.

系列导航

关于作者 · 持续学习与技术实践

我是一名拥有 15 年以上开发经验的技术从业者,自 2013 年起持续运营个人技术博客,记录工作与个人项目中遇到的真实问题,以及解决问题过程中的探索、实践和思考。

除了技术写作,我也在持续开发和维护自己的独立项目,探索新技术和新工具在实际场景中的应用。我始终相信,没有不值得去解决的问题,也没有不值得去学习的技术。

如果这篇文章对你有所帮助,欢迎浏览博客中的其他文章,也欢迎交流技术经验与想法。

关于我  ·  GitHub  ·  邮件联系