3 * This program is free software; you can redistribute it and/or modify
4 * it under the terms of the GNU General Public License as published by
5 * the Free Software Foundation; either version 2 of the License, or
6 * (at your option) any later version.
8 * This program is distributed in the hope that it will be useful,
9 * but WITHOUT ANY WARRANTY; without even the implied warranty of
10 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 * GNU General Public License for more details.
13 * You should have received a copy of the GNU General Public License along
14 * with this program; if not, write to the Free Software Foundation, Inc.,
15 * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
16 * http://www.gnu.org/copyleft/gpl.html
20 namespace Wikimedia\ObjectCache
;
28 * Store data in Redis.
30 * This requires the php-redis PECL extension (2.2.4 or later) and
31 * a Redis server (2.6.12 or later).
33 * @see http://redis.io/
34 * @see https://github.com/phpredis/phpredis/blob/d310ed7c8/Changelog.md
36 * @note Avoid use of Redis::MULTI transactions for twemproxy support
40 * @phan-file-suppress PhanTypeComparisonFromArray It's unclear whether exec() can return false
42 class RedisBagOStuff
extends MediumSpecificBagOStuff
{
43 /** @var RedisConnectionPool */
45 /** @var array List of server names */
47 /** @var array Map of (tag => server name) */
48 protected $serverTagMap;
50 protected $automaticFailover;
53 * Construct a RedisBagOStuff object. Parameters are:
55 * - servers: An array of server names. A server name may be a hostname,
56 * a hostname/port combination or the absolute path of a UNIX socket.
57 * If a hostname is specified but no port, the standard port number
58 * 6379 will be used. Arrays keys can be used to specify the tag to
59 * hash on in place of the host/port. Required.
61 * - connectTimeout: The timeout for new connections, in seconds. Optional,
62 * default is 1 second.
64 * - persistent: Set this to true to allow connections to persist across
65 * multiple web requests. False by default.
67 * - password: The authentication password, will be sent to Redis in
68 * clear text. Optional, if it is unspecified, no AUTH command will be
71 * - automaticFailover: If this is false, then each key will be mapped to
72 * a single server, and if that server is down, any requests for that key
73 * will fail. If this is true, a connection failure will cause the client
74 * to immediately try the next server in the list (as determined by a
75 * consistent hashing algorithm). True by default. This has the
76 * potential to create consistency issues if a server is slow enough to
77 * flap, for example if it is in swap death.
79 * @param array $params
81 public function __construct( $params ) {
82 parent
::__construct( $params );
83 $redisConf = [ 'serializer' => 'none' ]; // manage that in this class
84 foreach ( [ 'connectTimeout', 'persistent', 'password' ] as $opt ) {
85 if ( isset( $params[$opt] ) ) {
86 $redisConf[$opt] = $params[$opt];
89 $this->redisPool
= RedisConnectionPool
::singleton( $redisConf );
91 $this->servers
= $params['servers'];
92 foreach ( $this->servers
as $key => $server ) {
93 $this->serverTagMap
[is_int( $key ) ?
$server : $key] = $server;
96 $this->automaticFailover
= $params['automaticFailover'] ??
true;
98 // ...and uses rdb snapshots (redis.conf default)
99 $this->attrMap
[self
::ATTR_DURABILITY
] = self
::QOS_DURABILITY_DISK
;
102 protected function doGet( $key, $flags = 0, &$casToken = null ) {
103 $getToken = ( $casToken === self
::PASS_BY_REF
);
106 $conn = $this->getConnection( $key );
113 $blob = $conn->get( $key );
114 if ( $blob !== false ) {
115 $value = $this->unserialize( $blob );
116 $valueSize = strlen( $blob );
121 if ( $getToken && $value !== false ) {
124 } catch ( RedisException
$e ) {
127 $this->handleException( $conn, $e );
130 $this->logRequest( 'get', $key, $conn->getServer(), $e );
132 $this->updateOpStats( self
::METRIC_OP_GET
, [ $key => [ 0, $valueSize ] ] );
137 protected function doSet( $key, $value, $exptime = 0, $flags = 0 ) {
138 $conn = $this->getConnection( $key );
143 $ttl = $this->getExpirationAsTTL( $exptime );
144 $serialized = $this->getSerialized( $value, $key );
145 $valueSize = strlen( $serialized );
150 $result = $conn->setex( $key, $ttl, $serialized );
152 $result = $conn->set( $key, $serialized );
154 } catch ( RedisException
$e ) {
156 $this->handleException( $conn, $e );
159 $this->logRequest( 'set', $key, $conn->getServer(), $e );
161 $this->updateOpStats( self
::METRIC_OP_SET
, [ $key => [ $valueSize, 0 ] ] );
166 protected function doDelete( $key, $flags = 0 ) {
167 $conn = $this->getConnection( $key );
174 // Note that redis does not return false if the key was not there
175 $result = ( $conn->del( $key ) !== false );
176 } catch ( RedisException
$e ) {
178 $this->handleException( $conn, $e );
181 $this->logRequest( 'delete', $key, $conn->getServer(), $e );
183 $this->updateOpStats( self
::METRIC_OP_DELETE
, [ $key ] );
188 protected function doGetMulti( array $keys, $flags = 0 ) {
191 [ $keysByServer, $connByServer ] = $this->getConnectionsForKeys( $keys );
192 foreach ( $keysByServer as $server => $batchKeys ) {
193 $conn = $connByServer[$server];
197 // Avoid mget() to reduce CPU hogging from a single request
198 $conn->multi( Redis
::PIPELINE
);
199 foreach ( $batchKeys as $key ) {
202 $batchResult = $conn->exec();
203 if ( $batchResult === false ) {
204 $this->logRequest( 'get', implode( ',', $batchKeys ), $server, true );
208 foreach ( $batchResult as $i => $blob ) {
209 if ( $blob !== false ) {
210 $blobsFound[$batchKeys[$i]] = $blob;
213 } catch ( RedisException
$e ) {
214 $this->handleException( $conn, $e );
217 $this->logRequest( 'get', implode( ',', $batchKeys ), $server, $e );
220 // Preserve the order of $keys
222 $valueSizesByKey = [];
223 foreach ( $keys as $key ) {
224 if ( array_key_exists( $key, $blobsFound ) ) {
225 $blob = $blobsFound[$key];
226 $value = $this->unserialize( $blob );
227 if ( $value !== false ) {
228 $result[$key] = $value;
230 $valueSize = strlen( $blob );
234 $valueSizesByKey[$key] = [ 0, $valueSize ];
237 $this->updateOpStats( self
::METRIC_OP_GET
, $valueSizesByKey );
242 protected function doSetMulti( array $data, $exptime = 0, $flags = 0 ) {
243 $ttl = $this->getExpirationAsTTL( $exptime );
244 $op = $ttl ?
'setex' : 'set';
246 $keys = array_keys( $data );
247 $valueSizesByKey = [];
249 [ $keysByServer, $connByServer, $result ] = $this->getConnectionsForKeys( $keys );
250 foreach ( $keysByServer as $server => $batchKeys ) {
251 $conn = $connByServer[$server];
255 // Avoid mset() to reduce CPU hogging from a single request
256 $conn->multi( Redis
::PIPELINE
);
257 foreach ( $batchKeys as $key ) {
258 $serialized = $this->getSerialized( $data[$key], $key );
260 $conn->setex( $key, $ttl, $serialized );
262 $conn->set( $key, $serialized );
264 $valueSizesByKey[$key] = [ strlen( $serialized ), 0 ];
266 $batchResult = $conn->exec();
267 if ( $batchResult === false ) {
269 $this->logRequest( $op, implode( ',', $batchKeys ), $server, true );
273 $result = $result && !in_array( false, $batchResult, true );
274 } catch ( RedisException
$e ) {
275 $this->handleException( $conn, $e );
279 $this->logRequest( $op, implode( ',', $batchKeys ), $server, $e );
282 $this->updateOpStats( self
::METRIC_OP_SET
, $valueSizesByKey );
287 protected function doDeleteMulti( array $keys, $flags = 0 ) {
288 [ $keysByServer, $connByServer, $result ] = $this->getConnectionsForKeys( $keys );
289 foreach ( $keysByServer as $server => $batchKeys ) {
290 $conn = $connByServer[$server];
294 // Avoid delete() with array to reduce CPU hogging from a single request
295 $conn->multi( Redis
::PIPELINE
);
296 foreach ( $batchKeys as $key ) {
299 $batchResult = $conn->exec();
300 if ( $batchResult === false ) {
302 $this->logRequest( 'delete', implode( ',', $batchKeys ), $server, true );
305 // Note that redis does not return false if the key was not there
306 $result = $result && !in_array( false, $batchResult, true );
307 } catch ( RedisException
$e ) {
308 $this->handleException( $conn, $e );
312 $this->logRequest( 'delete', implode( ',', $batchKeys ), $server, $e );
315 $this->updateOpStats( self
::METRIC_OP_DELETE
, array_values( $keys ) );
320 public function doChangeTTLMulti( array $keys, $exptime, $flags = 0 ) {
321 $relative = $this->isRelativeExpiration( $exptime );
322 $op = ( $exptime == self
::TTL_INDEFINITE
)
324 : ( $relative ?
'expire' : 'expireAt' );
326 [ $keysByServer, $connByServer, $result ] = $this->getConnectionsForKeys( $keys );
327 foreach ( $keysByServer as $server => $batchKeys ) {
328 $conn = $connByServer[$server];
332 $conn->multi( Redis
::PIPELINE
);
333 foreach ( $batchKeys as $key ) {
334 if ( $exptime == self
::TTL_INDEFINITE
) {
335 $conn->persist( $key );
336 } elseif ( $relative ) {
337 $conn->expire( $key, $this->getExpirationAsTTL( $exptime ) );
339 $conn->expireAt( $key, $this->getExpirationAsTimestamp( $exptime ) );
342 $batchResult = $conn->exec();
343 if ( $batchResult === false ) {
345 $this->logRequest( $op, implode( ',', $batchKeys ), $server, true );
348 $result = in_array( false, $batchResult, true ) ?
false : $result;
349 } catch ( RedisException
$e ) {
350 $this->handleException( $conn, $e );
354 $this->logRequest( $op, implode( ',', $batchKeys ), $server, $e );
357 $this->updateOpStats( self
::METRIC_OP_CHANGE_TTL
, array_values( $keys ) );
362 protected function doAdd( $key, $value, $exptime = 0, $flags = 0 ) {
363 $conn = $this->getConnection( $key );
368 $ttl = $this->getExpirationAsTTL( $exptime );
369 $serialized = $this->getSerialized( $value, $key );
370 $valueSize = strlen( $serialized );
373 $result = $conn->set(
376 $ttl ?
[ 'nx', 'ex' => $ttl ] : [ 'nx' ]
378 } catch ( RedisException
$e ) {
380 $this->handleException( $conn, $e );
383 $this->logRequest( 'add', $key, $conn->getServer(), $result );
385 $this->updateOpStats( self
::METRIC_OP_ADD
, [ $key => [ $valueSize, 0 ] ] );
390 protected function doIncrWithInit( $key, $exptime, $step, $init, $flags ) {
391 $conn = $this->getConnection( $key );
396 $ttl = $this->getExpirationAsTTL( $exptime );
402 local ttl, step, init = unpack( ARGV )
403 if redis.call( 'exists', key ) == 1 then
404 return redis.call( 'incrBy', key, step )
407 redis.call( 'setex', key, ttl, init )
409 redis.call( 'set', key, init )
413 $result = $conn->luaEval( $script, [ $key, $ttl, $step, $init ], 1 );
414 } catch ( RedisException
$e ) {
416 $this->handleException( $conn, $e );
418 $this->logRequest( 'incrWithInit', $key, $conn->getServer(), $result );
423 protected function doChangeTTL( $key, $exptime, $flags ) {
424 $conn = $this->getConnection( $key );
429 $relative = $this->isRelativeExpiration( $exptime );
431 if ( $exptime == self
::TTL_INDEFINITE
) {
432 $result = $conn->persist( $key );
433 $this->logRequest( 'persist', $key, $conn->getServer(), $result );
434 } elseif ( $relative ) {
435 $result = $conn->expire( $key, $this->getExpirationAsTTL( $exptime ) );
436 $this->logRequest( 'expire', $key, $conn->getServer(), $result );
438 $result = $conn->expireAt( $key, $this->getExpirationAsTimestamp( $exptime ) );
439 $this->logRequest( 'expireAt', $key, $conn->getServer(), $result );
441 } catch ( RedisException
$e ) {
443 $this->handleException( $conn, $e );
446 $this->updateOpStats( self
::METRIC_OP_CHANGE_TTL
, [ $key ] );
452 * @param string[] $keys
454 * @return array ((server => redis handle wrapper), (server => key batch), success)
455 * @phan-return array{0:array<string,string[]>,1:array<string,RedisConnRef|Redis>,2:bool}
457 protected function getConnectionsForKeys( array $keys ) {
461 foreach ( $keys as $key ) {
462 $candidateTags = $this->getCandidateServerTagsForKey( $key );
465 // Find a suitable server for this key...
466 // phpcs:ignore Generic.CodeAnalysis.AssignmentInCondition.FoundInWhileCondition
467 while ( ( $tag = array_shift( $candidateTags ) ) !== null ) {
468 $server = $this->serverTagMap
[$tag];
469 // Reuse connection handles for keys mapping to the same server
470 if ( isset( $connByServer[$server] ) ) {
471 $conn = $connByServer[$server];
473 $conn = $this->redisPool
->getConnection( $server, $this->logger
);
477 // If automatic failover is enabled, check that the server's link
478 // to its master (if any) is up -- but only if there are other
479 // viable candidates left to consider. Also, getMasterLinkStatus()
480 // does not work with twemproxy, though $candidates will be empty
481 // by now in such cases.
482 if ( $this->automaticFailover
&& $candidateTags ) {
484 /** @var string[] $info */
485 $info = $conn->info();
486 if ( ( $info['master_link_status'] ??
null ) === 'down' ) {
487 // If the master cannot be reached, fail-over to the next server.
488 // If masters are in data-center A, and replica DBs in data-center B,
489 // this helps avoid the case were fail-over happens in A but not
490 // to the corresponding server in B (e.g. read/write mismatch).
493 } catch ( RedisException
$e ) {
494 // Server is not accepting commands
495 $this->redisPool
->handleError( $conn, $e );
499 // Use this connection handle
500 $connByServer[$server] = $conn;
502 // Use this server for this key
503 $keysByServer[$server][] = $key;
508 // No suitable server found for this key
510 $this->setLastError( BagOStuff
::ERR_UNREACHABLE
);
514 return [ $keysByServer, $connByServer, $success ];
520 * @return RedisConnRef|Redis|null Redis handle wrapper for the key or null on failure
522 protected function getConnection( $key ) {
523 [ , $connByServer ] = $this->getConnectionsForKeys( [ $key ] );
525 return reset( $connByServer ) ?
: null;
528 private function getCandidateServerTagsForKey( string $key ): array {
529 $candidates = array_keys( $this->serverTagMap
);
531 if ( count( $this->servers
) > 1 ) {
532 ArrayUtils
::consistentHashSort( $candidates, $key, '/' );
533 if ( !$this->automaticFailover
) {
534 $candidates = array_slice( $candidates, 0, 1 );
546 protected function logError( $msg ) {
547 $this->logger
->error( "Redis error: $msg" );
551 * The redis extension throws an exception in response to various read, write
552 * and protocol errors. Sometimes it also closes the connection, sometimes
553 * not. The safest response for us is to explicitly destroy the connection
554 * object and let it be reopened during the next request.
556 * @param RedisConnRef $conn
557 * @param RedisException $e
559 protected function handleException( RedisConnRef
$conn, RedisException
$e ) {
560 $this->setLastError( BagOStuff
::ERR_UNEXPECTED
);
561 $this->redisPool
->handleError( $conn, $e );
565 * Send information about a single request to the debug log
568 * @param string $keys
569 * @param string $server
570 * @param Exception|bool|null $e
572 public function logRequest( $op, $keys, $server, $e = null ) {
573 $this->debug( "$op($keys) on $server: " . ( $e ?
"failure" : "success" ) );
577 /** @deprecated class alias since 1.43 */
578 class_alias( RedisBagOStuff
::class, 'RedisBagOStuff' );