├── patches └── colinmollenhour │ └── php-redis-session-abstract │ └── 2.1.2 │ ├── set-php-serialize-handler.patch │ └── implement-write-lock.patch └── README.md /patches/colinmollenhour/php-redis-session-abstract/2.1.2/set-php-serialize-handler.patch: -------------------------------------------------------------------------------- 1 | diff --git a/Session/Config.php b/Session/Config.php 2 | index 8ebed1d..d68b3b7 100644 3 | --- a/Session/Config.php 4 | +++ b/Session/Config.php 5 | @@ -189,6 +189,8 @@ class Config implements ConfigInterface 6 | $this->setOption('session.gc_maxlifetime', $gcMaxlifetime); 7 | } 8 | 9 | + $this->setOption('session.serialize_handler', 'php_serialize'); 10 | + 11 | /** 12 | * Cookie settings: lifetime, path, domain, httpOnly. These govern settings for the session cookie. 13 | */ 14 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Magento 2 Redis session patch 2 | 3 | Redis patch for session management optimization 4 | 5 | ## Compatible with 6 | - Magento 2.4.8 7 | - php-redis-session-abstract 2.1.2 8 | 9 | ## Installation 10 | 11 | To install patches with composer you need to have "cweagans/composer-patches" installed first. 12 | If you don't have it installed you can do it with: 13 | ``` 14 | composer require cweagans/composer-patches 15 | ``` 16 | Then, add the patches to your composer.json 17 | 18 | "extra": { 19 | "magento-force": "override", 20 | "patches": { 21 | "magento/framework": { 22 | "Serialize session": "https://raw.githubusercontent.com/olivertar/m2_redis_patch/refs/heads/main/patches/colinmollenhour/php-redis-session-abstract/2.1.2/set-php-serialize-handler.patch" 23 | }, 24 | "colinmollenhour/php-redis-session-abstract": { 25 | "Session lock write only": "https://raw.githubusercontent.com/olivertar/m2_redis_patch/refs/heads/main/patches/colinmollenhour/php-redis-session-abstract/2.1.2/implement-write-lock.patch" 26 | } 27 | } 28 | } 29 | 30 | Then run the commands 31 | 32 | ``` 33 | composer install 34 | bin/magento setup:upgrade --keep-generated 35 | ``` 36 | 37 | ## What is this patch? 38 | When using Redis as session storage in Magento 2, simultaneous or closely spaced requests to the same session can end up queued due to the locking system that prevents concurrent writes. 39 | 40 | This behavior is particularly affecting environments with multiple AJAX calls (such as checkout) or headless frontends, generating unnecessary delays even when most requests only read the session and do not modify it. 41 | 42 | To improve performance, [Yonn Trimoreau](https://www.linkedin.com/in/yonn-trimoreau-3a9856110/) developed a patch for the [colinmollenhour/php-redis-session-abstract](https://github.com/colinmollenhour/php-redis-session-abstract) library used by Magento 2, which restricts the use of locks to write operations only. This significantly reduces latency in high-concurrency scenarios. 43 | 44 | This repository includes a customized version of this patch, compatible with Magento 2.4.8 and version 2.1.2 of the aforementioned library. 45 | 46 | ## Acknowledgments 47 | 48 | - To [Colin Mollenhour](https://www.linkedin.com/in/colinmollenhour/) for creating and maintaining this library for the entire PHP community. 49 | - To [Rostislav Suleimanov](https://www.linkedin.com/in/rostilos/) Who has masterfully explained the problem and the different options to mitigate it. 50 | - And finally to [Yonn Trimoreau](https://www.linkedin.com/in/yonn-trimoreau-3a9856110/) who has dedicated his time to solve the problem and has created the patch that I have adapted. 51 | 52 | ## Recommended reading 53 | 54 | - [Magento 2 Redis Session Storage: Performance Issues and Solutions](https://www.linkedin.com/pulse/magento-2-redis-session-storage-performance-issues-suleimanov-qfdae/) 55 | - [Implement write lock instead of read lock](https://github.com/colinmollenhour/php-redis-session-abstract/issues/50) 56 | - [Unnecessary Redis Session Locking On All HTTP GET Requests - Affecting PWA Studio Concurrent GraphQL Requests](https://github.com/magento/magento2/issues/34758#issuecomment-1312524791) 57 | - [Use Redis for session storage](https://experienceleague.adobe.com/en/docs/commerce-operations/configuration-guide/cache/redis/redis-session) 58 | -------------------------------------------------------------------------------- /patches/colinmollenhour/php-redis-session-abstract/2.1.2/implement-write-lock.patch: -------------------------------------------------------------------------------- 1 | diff --git a/src/Cm/RedisSession/Handler.php b/src/Cm/RedisSession/Handler.php 2 | index 83aec67..0392ad2 100644 3 | --- a/src/Cm/RedisSession/Handler.php 4 | +++ b/src/Cm/RedisSession/Handler.php 5 | @@ -274,6 +274,10 @@ 6 | private $_readOnly; 7 | 8 | /** 9 | + * @var array 10 | + */ 11 | + private $_lastReadSessionDataById = []; 12 | + /** 13 | * @param ConfigInterface $config 14 | * @param LoggerInterface $logger 15 | * @param boolean $readOnly 16 | @@ -281,6 +285,12 @@ 17 | */ 18 | public function __construct(ConfigInterface $config, LoggerInterface $logger, $readOnly = false) 19 | { 20 | + // we must be able to manipulate session data easily using native serialize/unserialize functions 21 | + // and as long as the default php session serializer doesn't handle object references correctly, we have no choice forcing this option 22 | + if (ini_get('session.serialize_handler') !== 'php_serialize') { 23 | + throw new \Exception('You must set session.serialize_handler ini config to "php_serialize" value'); 24 | + } 25 | + 26 | $this->config = $config; 27 | $this->logger = $logger; 28 | 29 | @@ -495,17 +505,17 @@ 30 | } 31 | 32 | /** 33 | - * Fetch session data 34 | + * Acquire lock 35 | * 36 | * @param string $sessionId 37 | * @return string 38 | * @throws ConcurrentConnectionsExceededException 39 | */ 40 | #[\ReturnTypeWillChange] 41 | - public function read($sessionId) 42 | + protected function _lock($sessionId) 43 | { 44 | // Get lock on session. Increment the "lock" field and if the new value is 1, we have the lock. 45 | - $sessionId = self::SESSION_PREFIX.$sessionId; 46 | + $sessionId = self::SESSION_PREFIX . $sessionId; 47 | $tries = $waiting = $lock = 0; 48 | $lockPid = $oldLockPid = null; // Restart waiting for lock when current lock holder changes 49 | $detectZombies = false; 50 | @@ -514,8 +524,7 @@ 51 | $this->_log(sprintf("Attempting to take lock on ID %s", $sessionId)); 52 | 53 | if (!$this->_useCluster) $this->_redis->select($this->_dbNum); 54 | - while ($this->_useLocking && !$this->_readOnly) 55 | - { 56 | + while (!$this->_readOnly) { 57 | // Increment lock value for this session and retrieve the new value 58 | $oldLock = $lock; 59 | $lock = $this->_redis->hIncrBy($sessionId, 'lock', 1); 60 | @@ -526,25 +535,52 @@ 61 | } 62 | 63 | // If we got the lock, update with our pid and reset lock and expiration 64 | - if ( $lock == 1 // We actually do have the lock 65 | + if ($lock == 1 // We actually do have the lock 66 | || ( 67 | $tries >= $breakAfter // We are done waiting and want to start trying to break it 68 | && $oldLockPid == $lockPid // Nobody else got the lock while we were waiting 69 | ) 70 | ) { 71 | $this->_hasLock = true; 72 | - break; 73 | - } 74 | + $setData = array( 75 | + 'pid' => $this->_getPid(), 76 | + 'lock' => 1, 77 | + ); 78 | 79 | - // Otherwise, add to "wait" counter and continue 80 | - else if ( ! $waiting) { 81 | - $i = 0; 82 | - do { 83 | - $waiting = $this->_redis->hIncrBy($sessionId, 'wait', 1); 84 | - } while (++$i < $this->_maxConcurrency && $waiting < 1); 85 | - } 86 | + // Save request data in session so if a lock is broken we can know which page it was for debugging 87 | + if (empty($_SERVER['REQUEST_METHOD'])) { 88 | + $setData['req'] = @$_SERVER['SCRIPT_NAME']; 89 | + } else { 90 | + $setData['req'] = $_SERVER['REQUEST_METHOD'] . " " . @$_SERVER['SERVER_NAME'] . @$_SERVER['REQUEST_URI']; 91 | + } 92 | + if ($lock != 1) { 93 | + $this->_log( 94 | + sprintf( 95 | + "Successfully broke lock for ID %s after %.5f seconds (%d attempts). Lock: %d\nLast request of broken lock: %s", 96 | + $sessionId, 97 | + (microtime(true) - $timeStart), 98 | + $tries, 99 | + $lock, 100 | + $this->_redis->hGet($sessionId, 'req') 101 | + ), 102 | + LoggerInterface::INFO 103 | + ); 104 | + } 105 | + // Set session data and expiration 106 | + $this->_redis->pipeline(); 107 | + if (!empty($setData)) { 108 | + $this->_redis->hMSet($sessionId, $setData); 109 | + } 110 | + $this->_redis->expire($sessionId, 3600 * 6); // Expiration will be set to correct value when session is written 111 | + 112 | + // This process is no longer waiting for a lock 113 | + if ($tries > 0) { 114 | + $this->_redis->hIncrBy($sessionId, 'wait', -1); 115 | + } 116 | + $this->_redis->exec(); 117 | 118 | - // Handle overloaded sessions 119 | + break; 120 | + } // Otherwise, add to "wait" counter and continue 121 | else { 122 | // Detect broken sessions (e.g. caused by fatal errors) 123 | if ($detectZombies) { 124 | @@ -571,21 +607,13 @@ 125 | // Limit concurrent lock waiters to prevent server resource hogging 126 | if ($waiting >= $this->_maxConcurrency) { 127 | // Overloaded sessions get 503 errors 128 | - try { 129 | - $this->_redis->hIncrBy($sessionId, 'wait', -1); 130 | - $this->_sessionWritten = true; // Prevent session from getting written 131 | - $sessionInfo = $this->_redis->hMGet($sessionId, ['writes','req']); 132 | - } catch (Exception $e) { 133 | - $this->_log("$e", LoggerInterface::WARNING); 134 | - } 135 | + $this->_redis->hIncrBy($sessionId, 'wait', -1); 136 | + $this->_sessionWritten = true; // Prevent session from getting written 137 | + $writes = $this->_redis->hGet($sessionId, 'writes'); 138 | $this->_log( 139 | sprintf( 140 | - 'Session concurrency exceeded for ID %s; displaying HTTP 503 (%s waiting, %s total ' 141 | - . 'requests) - Locked URL: %s', 142 | - $sessionId, 143 | - $waiting, 144 | - isset($sessionInfo['writes']) ? $sessionInfo['writes'] : '-', 145 | - isset($sessionInfo['req']) ? $sessionInfo['req'] : '-' 146 | + 'Session concurrency exceeded for ID %s; displaying HTTP 503 (%s waiting, %s total requests)', 147 | + $sessionId, $waiting, $writes 148 | ), 149 | LoggerInterface::WARNING 150 | ); 151 | @@ -611,7 +639,7 @@ 152 | ); 153 | 154 | $pid = $this->_redis->hGet($sessionId, 'pid'); 155 | - if ($pid && ! $this->_pidExists($pid)) { 156 | + if ($pid && !$this->_pidExists($pid)) { 157 | // Allow a live process to get the lock 158 | $this->_redis->hSet($sessionId, 'lock', 0); 159 | $this->_log( 160 | @@ -637,8 +665,7 @@ 161 | LoggerInterface::NOTICE 162 | ); 163 | break; 164 | - } 165 | - else { 166 | + } else { 167 | $this->_log( 168 | sprintf( 169 | "Waiting %.2f seconds for lock on ID %s (%d tries, lock pid is %s, %.5f seconds elapsed)", 170 | @@ -653,60 +680,31 @@ 171 | } 172 | } 173 | $this->failedLockAttempts = $tries; 174 | + } 175 | 176 | - // Session can be read even if it was not locked by this pid! 177 | + /** 178 | + * Fetch session data 179 | + * 180 | + * @param string $sessionId 181 | + * @return string 182 | + * @throws ConcurrentConnectionsExceededException 183 | + */ 184 | + #[\ReturnTypeWillChange] 185 | + public function read($sessionId) 186 | + { 187 | + $sessionId = self::SESSION_PREFIX . $sessionId; 188 | $timeStart2 = microtime(true); 189 | + $this->_redis->select($this->_dbNum); 190 | list($sessionData, $sessionWrites) = array_values($this->_redis->hMGet($sessionId, array('data','writes'))); 191 | $this->_log(sprintf("Data read for ID %s in %.5f seconds", $sessionId, (microtime(true) - $timeStart2))); 192 | $this->_sessionWrites = (int) $sessionWrites; 193 | 194 | - // This process is no longer waiting for a lock 195 | - if ($tries > 0) { 196 | - $this->_redis->hIncrBy($sessionId, 'wait', -1); 197 | - } 198 | - 199 | - // This process has the lock, save the pid 200 | - if ($this->_hasLock) { 201 | - $setData = array( 202 | - 'pid' => $this->_getPid(), 203 | - 'lock' => 1, 204 | - ); 205 | + $sessionData = $sessionData ? (string) $this->_decodeData($sessionData) : ''; 206 | + $this->_lastReadSessionDataById[$sessionId] = $sessionData; 207 | 208 | - // Save request data in session so if a lock is broken we can know which page it was for debugging 209 | - if (empty($_SERVER['REQUEST_METHOD'])) { 210 | - $setData['req'] = @$_SERVER['SCRIPT_NAME']; 211 | - } else { 212 | - $setData['req'] = $_SERVER['REQUEST_METHOD']." ".@$_SERVER['SERVER_NAME'].@$_SERVER['REQUEST_URI']; 213 | - } 214 | - if ($lock != 1) { 215 | - $this->_log( 216 | - sprintf( 217 | - "Successfully broke lock for ID %s after %.5f seconds (%d attempts). Lock: %d\nLast request of " 218 | - . "broken lock: %s", 219 | - $sessionId, 220 | - (microtime(true) - $timeStart), 221 | - $tries, 222 | - $lock, 223 | - $this->_redis->hGet($sessionId, 'req') 224 | - ), 225 | - LoggerInterface::INFO 226 | - ); 227 | - } 228 | - } 229 | - if ($this->_usePipeline) { 230 | - // Set session data and expiration 231 | - $this->_redis->pipeline(); 232 | - } 233 | - if ( ! empty($setData)) { 234 | - $this->_redis->hMSet($sessionId, $setData); 235 | - } 236 | - $this->_redis->expire($sessionId, 3600*6); // Expiration will be set to correct value when session is written 237 | - if ($this->_usePipeline) { 238 | - $this->_redis->exec(); 239 | - } 240 | // Reset flag in case of multiple session read/write operations 241 | $this->_sessionWritten = false; 242 | - return $sessionData ? (string) $this->_decodeData($sessionData) : ''; 243 | + return $sessionData; 244 | } 245 | 246 | /** 247 | @@ -723,6 +721,11 @@ 248 | $this->_log(sprintf(($this->_sessionWritten ? "Repeated" : "Read-only") . " session write detected; skipping for ID %s", $sessionId)); 249 | return true; 250 | } 251 | + 252 | + if ($this->_useLocking) { 253 | + $this->_lock($sessionId); 254 | + } 255 | + 256 | $this->_sessionWritten = true; 257 | $timeStart = microtime(true); 258 | 259 | @@ -733,6 +736,8 @@ 260 | if ( ! $this->_useLocking 261 | || ( ! ($pid = $this->_redis->hGet('sess_'.$sessionId, 'pid')) || $pid == $this->_getPid()) 262 | ) { 263 | + $sessionData = $this->_getRefreshedSessionDataToWrite($sessionId); 264 | + 265 | $this->_writeRawSession($sessionId, $sessionData, $this->getLifeTime()); 266 | $this->_log(sprintf("Data written to ID %s in %.5f seconds", $sessionId, (microtime(true) - $timeStart))); 267 | 268 | @@ -996,4 +1001,98 @@ 269 | 270 | return $this->_breakAfter; 271 | } 272 | + 273 | + /** 274 | + * Get fresh session data, calculate a diff from last read session and session to write, and apply it onto the fresh 275 | + * 276 | + * @param string $sessionId 277 | + * @return string 278 | + */ 279 | + protected function _getRefreshedSessionDataToWrite(string $sessionId): string 280 | + { 281 | + $currentSessionData = $this->_decodeData($this->_redis->hGet('sess_' . $sessionId, 'data')); 282 | + $lastReadSessionData = $this->_lastReadSessionDataById['sess_' . $sessionId]; 283 | + 284 | + $freshSession = unserialize($currentSessionData) ?: []; 285 | + $lastReadSession = unserialize($lastReadSessionData) ?: []; 286 | + 287 | + $diffToUnset = $this->_arrayDiffRecursive($lastReadSession, $_SESSION); 288 | + if (!empty($diffToUnset)) { 289 | + $freshSession = $this->_arrayRemoveRecursive($freshSession, $diffToUnset); 290 | + } 291 | + 292 | + $diffToSet = $this->_arrayDiffRecursive($_SESSION, $lastReadSession); 293 | + if (!empty($diffToSet)) { 294 | + $freshSession = array_replace_recursive($freshSession, $diffToSet); 295 | + } 296 | + 297 | + return serialize($freshSession); 298 | + } 299 | + 300 | + /** 301 | + * Remove array elements by key recursively (deep down first and removing empty arrays) 302 | + * 303 | + * @param array $arr1 304 | + * @param array $arr2 305 | + * @return array 306 | + */ 307 | + protected function _arrayRemoveRecursive($arr1, $arr2) 308 | + { 309 | + foreach ($arr2 as $key => $item) { 310 | + if (is_array($arr2[$key])) { 311 | + $arr1[$key] = $this->_arrayRemoveRecursive($arr1[$key], $arr2[$key]); 312 | + 313 | + if (count($arr1[$key]) === 0) { 314 | + unset($arr1[$key]); 315 | + } 316 | + } else { 317 | + unset($arr1[$key]); 318 | + } 319 | + } 320 | + 321 | + return $arr1; 322 | + } 323 | + 324 | + /** 325 | + * Array diff recursively 326 | + * 327 | + * @param array $arr1 328 | + * @param array $arr2 329 | + * @return array 330 | + */ 331 | + protected function _arrayDiffRecursive($arr1, $arr2) 332 | + { 333 | + $outputDiff = []; 334 | + 335 | + foreach ($arr1 as $key => $value) { 336 | + if (!array_key_exists($key, $arr2)) { 337 | + $outputDiff[$key] = $value; 338 | + continue; 339 | + } 340 | + 341 | + if ((is_array($value) && !is_array($arr2[$key])) 342 | + || (is_object($value) && !is_object($arr2[$key]))) { 343 | + $outputDiff[$key] = $value; 344 | + continue; 345 | + } 346 | + 347 | + if (is_array($value)) { 348 | + $recursiveDiff = $this->_arrayDiffRecursive($value, $arr2[$key]); 349 | + 350 | + if (count($recursiveDiff)) { 351 | + $outputDiff[$key] = $recursiveDiff; 352 | + } 353 | + } elseif (is_object($value)) { 354 | + if (serialize($value) !== serialize($arr2[$key])) { 355 | + $outputDiff[$key] = $value; 356 | + } 357 | + } else { 358 | + if ($value !== $arr2[$key]) { 359 | + $outputDiff[$key] = $value; 360 | + } 361 | + } 362 | + } 363 | + 364 | + return $outputDiff; 365 | + } 366 | } 367 | --------------------------------------------------------------------------------