Cache hit rate 98 percent. The database still fell over. Both facts are true, and the second one does not care about the first.
What happened. One key holds the result of a heavy query for the main page. TTL one minute. At some second the key expires. In that second a few hundred requests all get a miss, and all of them go to recompute the same heavy query. Redis is fine. MySQL is not. This is a cache stampede, and hit rate will not warn you, because hit rate is an average. The stampede lives in the worst second.
The straightforward fix is a lock. On miss, SET lock:key 1 NX EX 10. Whoever wins recomputes. Fine, but now decide what the losers do. If they wait in a sleep loop, you moved the pile-up from the database into PHP-FPM workers, and those run out faster.
Better: keep serving the old value while one process refreshes it. Store the data with a long Redis TTL and put the logical expiry inside the value. Expired logically, still present physically. One request takes the lock and refreshes, everyone else eats slightly stale data. For a main page, ten seconds of stale is nothing.
There is also a probabilistic trick: each request may volunteer to refresh a bit before expiry, with probability growing as the deadline comes closer. No lock at all, and the expiry moment stops being a cliff.
One warning. The lock is now part of your failure model. The process that took it can die. So the lock must have a TTL, and the code must survive the case where nobody refreshed in time. Do not skip that branch. It fires at night, and the first version of mine did not have it.