RedisBagOStuff.php 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522
  1. <?php
  2. /**
  3. * Object caching using Redis (http://redis.io/).
  4. *
  5. * This program is free software; you can redistribute it and/or modify
  6. * it under the terms of the GNU General Public License as published by
  7. * the Free Software Foundation; either version 2 of the License, or
  8. * (at your option) any later version.
  9. *
  10. * This program is distributed in the hope that it will be useful,
  11. * but WITHOUT ANY WARRANTY; without even the implied warranty of
  12. * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  13. * GNU General Public License for more details.
  14. *
  15. * You should have received a copy of the GNU General Public License along
  16. * with this program; if not, write to the Free Software Foundation, Inc.,
  17. * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
  18. * http://www.gnu.org/copyleft/gpl.html
  19. *
  20. * @file
  21. */
  22. /**
  23. * Redis-based caching module for redis server >= 2.6.12 and phpredis >= 2.2.4
  24. *
  25. * @see https://github.com/phpredis/phpredis/blob/d310ed7c8/Changelog.md
  26. * @note Avoid use of Redis::MULTI transactions for twemproxy support
  27. *
  28. * @ingroup Cache
  29. * @ingroup Redis
  30. * @phan-file-suppress PhanTypeComparisonFromArray It's unclear whether exec() can return false
  31. */
  32. class RedisBagOStuff extends MediumSpecificBagOStuff {
  33. /** @var RedisConnectionPool */
  34. protected $redisPool;
  35. /** @var array List of server names */
  36. protected $servers;
  37. /** @var array Map of (tag => server name) */
  38. protected $serverTagMap;
  39. /** @var bool */
  40. protected $automaticFailover;
  41. /**
  42. * Construct a RedisBagOStuff object. Parameters are:
  43. *
  44. * - servers: An array of server names. A server name may be a hostname,
  45. * a hostname/port combination or the absolute path of a UNIX socket.
  46. * If a hostname is specified but no port, the standard port number
  47. * 6379 will be used. Arrays keys can be used to specify the tag to
  48. * hash on in place of the host/port. Required.
  49. *
  50. * - connectTimeout: The timeout for new connections, in seconds. Optional,
  51. * default is 1 second.
  52. *
  53. * - persistent: Set this to true to allow connections to persist across
  54. * multiple web requests. False by default.
  55. *
  56. * - password: The authentication password, will be sent to Redis in
  57. * clear text. Optional, if it is unspecified, no AUTH command will be
  58. * sent.
  59. *
  60. * - automaticFailover: If this is false, then each key will be mapped to
  61. * a single server, and if that server is down, any requests for that key
  62. * will fail. If this is true, a connection failure will cause the client
  63. * to immediately try the next server in the list (as determined by a
  64. * consistent hashing algorithm). True by default. This has the
  65. * potential to create consistency issues if a server is slow enough to
  66. * flap, for example if it is in swap death.
  67. * @param array $params
  68. */
  69. function __construct( $params ) {
  70. parent::__construct( $params );
  71. $redisConf = [ 'serializer' => 'none' ]; // manage that in this class
  72. foreach ( [ 'connectTimeout', 'persistent', 'password' ] as $opt ) {
  73. if ( isset( $params[$opt] ) ) {
  74. $redisConf[$opt] = $params[$opt];
  75. }
  76. }
  77. $this->redisPool = RedisConnectionPool::singleton( $redisConf );
  78. $this->servers = $params['servers'];
  79. foreach ( $this->servers as $key => $server ) {
  80. $this->serverTagMap[is_int( $key ) ? $server : $key] = $server;
  81. }
  82. $this->automaticFailover = $params['automaticFailover'] ?? true;
  83. $this->attrMap[self::ATTR_SYNCWRITES] = self::QOS_SYNCWRITES_NONE;
  84. }
  85. protected function doGet( $key, $flags = 0, &$casToken = null ) {
  86. $casToken = null;
  87. $conn = $this->getConnection( $key );
  88. if ( !$conn ) {
  89. return false;
  90. }
  91. $e = null;
  92. try {
  93. $value = $conn->get( $key );
  94. $casToken = $value;
  95. $result = $this->unserialize( $value );
  96. } catch ( RedisException $e ) {
  97. $result = false;
  98. $this->handleException( $conn, $e );
  99. }
  100. $this->logRequest( 'get', $key, $conn->getServer(), $e );
  101. return $result;
  102. }
  103. protected function doSet( $key, $value, $exptime = 0, $flags = 0 ) {
  104. $conn = $this->getConnection( $key );
  105. if ( !$conn ) {
  106. return false;
  107. }
  108. $ttl = $this->getExpirationAsTTL( $exptime );
  109. $e = null;
  110. try {
  111. if ( $ttl ) {
  112. $result = $conn->setex( $key, $ttl, $this->serialize( $value ) );
  113. } else {
  114. $result = $conn->set( $key, $this->serialize( $value ) );
  115. }
  116. } catch ( RedisException $e ) {
  117. $result = false;
  118. $this->handleException( $conn, $e );
  119. }
  120. $this->logRequest( 'set', $key, $conn->getServer(), $e );
  121. return $result;
  122. }
  123. protected function doDelete( $key, $flags = 0 ) {
  124. $conn = $this->getConnection( $key );
  125. if ( !$conn ) {
  126. return false;
  127. }
  128. $e = null;
  129. try {
  130. // Note that redis does not return false if the key was not there
  131. $result = ( $conn->del( $key ) !== false );
  132. } catch ( RedisException $e ) {
  133. $result = false;
  134. $this->handleException( $conn, $e );
  135. }
  136. $this->logRequest( 'delete', $key, $conn->getServer(), $e );
  137. return $result;
  138. }
  139. protected function doGetMulti( array $keys, $flags = 0 ) {
  140. /** @var RedisConnRef[]|Redis[] $conns */
  141. $conns = [];
  142. $batches = [];
  143. foreach ( $keys as $key ) {
  144. $conn = $this->getConnection( $key );
  145. if ( $conn ) {
  146. $server = $conn->getServer();
  147. $conns[$server] = $conn;
  148. $batches[$server][] = $key;
  149. }
  150. }
  151. $result = [];
  152. foreach ( $batches as $server => $batchKeys ) {
  153. $conn = $conns[$server];
  154. $e = null;
  155. try {
  156. // Avoid mget() to reduce CPU hogging from a single request
  157. $conn->multi( Redis::PIPELINE );
  158. foreach ( $batchKeys as $key ) {
  159. $conn->get( $key );
  160. }
  161. $batchResult = $conn->exec();
  162. if ( $batchResult === false ) {
  163. $this->logRequest( 'get', implode( ',', $batchKeys ), $server, true );
  164. continue;
  165. }
  166. foreach ( $batchResult as $i => $value ) {
  167. if ( $value !== false ) {
  168. $result[$batchKeys[$i]] = $this->unserialize( $value );
  169. }
  170. }
  171. } catch ( RedisException $e ) {
  172. $this->handleException( $conn, $e );
  173. }
  174. $this->logRequest( 'get', implode( ',', $batchKeys ), $server, $e );
  175. }
  176. return $result;
  177. }
  178. protected function doSetMulti( array $data, $exptime = 0, $flags = 0 ) {
  179. /** @var RedisConnRef[]|Redis[] $conns */
  180. $conns = [];
  181. $batches = [];
  182. foreach ( $data as $key => $value ) {
  183. $conn = $this->getConnection( $key );
  184. if ( $conn ) {
  185. $server = $conn->getServer();
  186. $conns[$server] = $conn;
  187. $batches[$server][] = $key;
  188. }
  189. }
  190. $ttl = $this->getExpirationAsTTL( $exptime );
  191. $op = $ttl ? 'setex' : 'set';
  192. $result = true;
  193. foreach ( $batches as $server => $batchKeys ) {
  194. $conn = $conns[$server];
  195. $e = null;
  196. try {
  197. // Avoid mset() to reduce CPU hogging from a single request
  198. $conn->multi( Redis::PIPELINE );
  199. foreach ( $batchKeys as $key ) {
  200. if ( $ttl ) {
  201. $conn->setex( $key, $ttl, $this->serialize( $data[$key] ) );
  202. } else {
  203. $conn->set( $key, $this->serialize( $data[$key] ) );
  204. }
  205. }
  206. $batchResult = $conn->exec();
  207. if ( $batchResult === false ) {
  208. $this->logRequest( $op, implode( ',', $batchKeys ), $server, true );
  209. continue;
  210. }
  211. $result = $result && !in_array( false, $batchResult, true );
  212. } catch ( RedisException $e ) {
  213. $this->handleException( $conn, $e );
  214. $result = false;
  215. }
  216. $this->logRequest( $op, implode( ',', $batchKeys ), $server, $e );
  217. }
  218. return $result;
  219. }
  220. protected function doDeleteMulti( array $keys, $flags = 0 ) {
  221. /** @var RedisConnRef[]|Redis[] $conns */
  222. $conns = [];
  223. $batches = [];
  224. foreach ( $keys as $key ) {
  225. $conn = $this->getConnection( $key );
  226. if ( $conn ) {
  227. $server = $conn->getServer();
  228. $conns[$server] = $conn;
  229. $batches[$server][] = $key;
  230. }
  231. }
  232. $result = true;
  233. foreach ( $batches as $server => $batchKeys ) {
  234. $conn = $conns[$server];
  235. $e = null;
  236. try {
  237. // Avoid delete() with array to reduce CPU hogging from a single request
  238. $conn->multi( Redis::PIPELINE );
  239. foreach ( $batchKeys as $key ) {
  240. $conn->del( $key );
  241. }
  242. $batchResult = $conn->exec();
  243. if ( $batchResult === false ) {
  244. $this->logRequest( 'delete', implode( ',', $batchKeys ), $server, true );
  245. continue;
  246. }
  247. // Note that redis does not return false if the key was not there
  248. $result = $result && !in_array( false, $batchResult, true );
  249. } catch ( RedisException $e ) {
  250. $this->handleException( $conn, $e );
  251. $result = false;
  252. }
  253. $this->logRequest( 'delete', implode( ',', $batchKeys ), $server, $e );
  254. }
  255. return $result;
  256. }
  257. public function changeTTLMulti( array $keys, $exptime, $flags = 0 ) {
  258. /** @var RedisConnRef[]|Redis[] $conns */
  259. $conns = [];
  260. $batches = [];
  261. foreach ( $keys as $key ) {
  262. $conn = $this->getConnection( $key );
  263. if ( $conn ) {
  264. $server = $conn->getServer();
  265. $conns[$server] = $conn;
  266. $batches[$server][] = $key;
  267. }
  268. }
  269. $relative = $this->isRelativeExpiration( $exptime );
  270. $op = ( $exptime == self::TTL_INDEFINITE )
  271. ? 'persist'
  272. : ( $relative ? 'expire' : 'expireAt' );
  273. $result = true;
  274. foreach ( $batches as $server => $batchKeys ) {
  275. $conn = $conns[$server];
  276. $e = null;
  277. try {
  278. $conn->multi( Redis::PIPELINE );
  279. foreach ( $batchKeys as $key ) {
  280. if ( $exptime == self::TTL_INDEFINITE ) {
  281. $conn->persist( $key );
  282. } elseif ( $relative ) {
  283. $conn->expire( $key, $this->getExpirationAsTTL( $exptime ) );
  284. } else {
  285. $conn->expireAt( $key, $this->getExpirationAsTimestamp( $exptime ) );
  286. }
  287. }
  288. $batchResult = $conn->exec();
  289. if ( $batchResult === false ) {
  290. $this->logRequest( $op, implode( ',', $batchKeys ), $server, true );
  291. continue;
  292. }
  293. $result = in_array( false, $batchResult, true ) ? false : $result;
  294. } catch ( RedisException $e ) {
  295. $this->handleException( $conn, $e );
  296. $result = false;
  297. }
  298. $this->logRequest( $op, implode( ',', $batchKeys ), $server, $e );
  299. }
  300. return $result;
  301. }
  302. protected function doAdd( $key, $value, $expiry = 0, $flags = 0 ) {
  303. $conn = $this->getConnection( $key );
  304. if ( !$conn ) {
  305. return false;
  306. }
  307. $ttl = $this->getExpirationAsTTL( $expiry );
  308. try {
  309. $result = $conn->set(
  310. $key,
  311. $this->serialize( $value ),
  312. $ttl ? [ 'nx', 'ex' => $ttl ] : [ 'nx' ]
  313. );
  314. } catch ( RedisException $e ) {
  315. $result = false;
  316. $this->handleException( $conn, $e );
  317. }
  318. $this->logRequest( 'add', $key, $conn->getServer(), $result );
  319. return $result;
  320. }
  321. public function incr( $key, $value = 1, $flags = 0 ) {
  322. $conn = $this->getConnection( $key );
  323. if ( !$conn ) {
  324. return false;
  325. }
  326. try {
  327. if ( !$conn->exists( $key ) ) {
  328. return false;
  329. }
  330. // @FIXME: on races, the key may have a 0 TTL
  331. $result = $conn->incrBy( $key, $value );
  332. } catch ( RedisException $e ) {
  333. $result = false;
  334. $this->handleException( $conn, $e );
  335. }
  336. $this->logRequest( 'incr', $key, $conn->getServer(), $result );
  337. return $result;
  338. }
  339. public function decr( $key, $value = 1, $flags = 0 ) {
  340. $conn = $this->getConnection( $key );
  341. if ( !$conn ) {
  342. return false;
  343. }
  344. try {
  345. if ( !$conn->exists( $key ) ) {
  346. return false;
  347. }
  348. // @FIXME: on races, the key may have a 0 TTL
  349. $result = $conn->decrBy( $key, $value );
  350. } catch ( RedisException $e ) {
  351. $result = false;
  352. $this->handleException( $conn, $e );
  353. }
  354. $this->logRequest( 'decr', $key, $conn->getServer(), $result );
  355. return $result;
  356. }
  357. protected function doChangeTTL( $key, $exptime, $flags ) {
  358. $conn = $this->getConnection( $key );
  359. if ( !$conn ) {
  360. return false;
  361. }
  362. $relative = $this->isRelativeExpiration( $exptime );
  363. try {
  364. if ( $exptime == self::TTL_INDEFINITE ) {
  365. $result = $conn->persist( $key );
  366. $this->logRequest( 'persist', $key, $conn->getServer(), $result );
  367. } elseif ( $relative ) {
  368. $result = $conn->expire( $key, $this->getExpirationAsTTL( $exptime ) );
  369. $this->logRequest( 'expire', $key, $conn->getServer(), $result );
  370. } else {
  371. $result = $conn->expireAt( $key, $this->getExpirationAsTimestamp( $exptime ) );
  372. $this->logRequest( 'expireAt', $key, $conn->getServer(), $result );
  373. }
  374. } catch ( RedisException $e ) {
  375. $result = false;
  376. $this->handleException( $conn, $e );
  377. }
  378. return $result;
  379. }
  380. /**
  381. * @param string $key
  382. * @return RedisConnRef|Redis|null Redis handle wrapper for the key or null on failure
  383. */
  384. protected function getConnection( $key ) {
  385. $candidates = array_keys( $this->serverTagMap );
  386. if ( count( $this->servers ) > 1 ) {
  387. ArrayUtils::consistentHashSort( $candidates, $key, '/' );
  388. if ( !$this->automaticFailover ) {
  389. $candidates = array_slice( $candidates, 0, 1 );
  390. }
  391. }
  392. while ( ( $tag = array_shift( $candidates ) ) !== null ) {
  393. $server = $this->serverTagMap[$tag];
  394. $conn = $this->redisPool->getConnection( $server, $this->logger );
  395. if ( !$conn ) {
  396. continue;
  397. }
  398. // If automatic failover is enabled, check that the server's link
  399. // to its master (if any) is up -- but only if there are other
  400. // viable candidates left to consider. Also, getMasterLinkStatus()
  401. // does not work with twemproxy, though $candidates will be empty
  402. // by now in such cases.
  403. if ( $this->automaticFailover && $candidates ) {
  404. try {
  405. /** @var string[] $info */
  406. $info = $conn->info();
  407. if ( ( $info['master_link_status'] ?? null ) === 'down' ) {
  408. // If the master cannot be reached, fail-over to the next server.
  409. // If masters are in data-center A, and replica DBs in data-center B,
  410. // this helps avoid the case were fail-over happens in A but not
  411. // to the corresponding server in B (e.g. read/write mismatch).
  412. continue;
  413. }
  414. } catch ( RedisException $e ) {
  415. // Server is not accepting commands
  416. $this->redisPool->handleError( $conn, $e );
  417. continue;
  418. }
  419. }
  420. return $conn;
  421. }
  422. $this->setLastError( BagOStuff::ERR_UNREACHABLE );
  423. return null;
  424. }
  425. /**
  426. * Log a fatal error
  427. * @param string $msg
  428. */
  429. protected function logError( $msg ) {
  430. $this->logger->error( "Redis error: $msg" );
  431. }
  432. /**
  433. * The redis extension throws an exception in response to various read, write
  434. * and protocol errors. Sometimes it also closes the connection, sometimes
  435. * not. The safest response for us is to explicitly destroy the connection
  436. * object and let it be reopened during the next request.
  437. * @param RedisConnRef $conn
  438. * @param RedisException $e
  439. */
  440. protected function handleException( RedisConnRef $conn, RedisException $e ) {
  441. $this->setLastError( BagOStuff::ERR_UNEXPECTED );
  442. $this->redisPool->handleError( $conn, $e );
  443. }
  444. /**
  445. * Send information about a single request to the debug log
  446. * @param string $op
  447. * @param string $keys
  448. * @param string $server
  449. * @param Exception|bool|null $e
  450. */
  451. public function logRequest( $op, $keys, $server, $e = null ) {
  452. $this->debug( "$op($keys) on $server: " . ( $e ? "failure" : "success" ) );
  453. }
  454. }