TransactionProfiler.php 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387
  1. <?php
  2. /**
  3. * Transaction profiling for contention
  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. * @ingroup Profiler
  22. */
  23. namespace Wikimedia\Rdbms;
  24. use Psr\Log\LoggerInterface;
  25. use Psr\Log\LoggerAwareInterface;
  26. use Psr\Log\NullLogger;
  27. use RuntimeException;
  28. /**
  29. * Helper class that detects high-contention DB queries via profiling calls
  30. *
  31. * This class is meant to work with an IDatabase object, which manages queries
  32. *
  33. * @since 1.24
  34. */
  35. class TransactionProfiler implements LoggerAwareInterface {
  36. /** @var float Seconds */
  37. protected $dbLockThreshold = 3.0;
  38. /** @var float Seconds */
  39. protected $eventThreshold = 0.25;
  40. /** @var bool */
  41. protected $silenced = false;
  42. /** @var array transaction ID => (write start time, list of DBs involved) */
  43. protected $dbTrxHoldingLocks = [];
  44. /** @var array transaction ID => list of (query name, start time, end time) */
  45. protected $dbTrxMethodTimes = [];
  46. /** @var array */
  47. protected $hits = [
  48. 'writes' => 0,
  49. 'queries' => 0,
  50. 'conns' => 0,
  51. 'masterConns' => 0
  52. ];
  53. /** @var array */
  54. protected $expect = [
  55. 'writes' => INF,
  56. 'queries' => INF,
  57. 'conns' => INF,
  58. 'masterConns' => INF,
  59. 'maxAffected' => INF,
  60. 'readQueryRows' => INF,
  61. 'readQueryTime' => INF,
  62. 'writeQueryTime' => INF
  63. ];
  64. /** @var array */
  65. protected $expectBy = [];
  66. /**
  67. * @var LoggerInterface
  68. */
  69. private $logger;
  70. public function __construct() {
  71. $this->setLogger( new NullLogger() );
  72. }
  73. public function setLogger( LoggerInterface $logger ) {
  74. $this->logger = $logger;
  75. }
  76. /**
  77. * @param bool $value
  78. * @return bool Old value
  79. * @since 1.28
  80. */
  81. public function setSilenced( $value ) {
  82. $old = $this->silenced;
  83. $this->silenced = $value;
  84. return $old;
  85. }
  86. /**
  87. * Set performance expectations
  88. *
  89. * With conflicting expectations, the most narrow ones will be used
  90. *
  91. * @param string $event (writes,queries,conns,mConns)
  92. * @param int $value Maximum count of the event
  93. * @param string $fname Caller
  94. * @since 1.25
  95. */
  96. public function setExpectation( $event, $value, $fname ) {
  97. $this->expect[$event] = isset( $this->expect[$event] )
  98. ? min( $this->expect[$event], $value )
  99. : $value;
  100. if ( $this->expect[$event] == $value ) {
  101. $this->expectBy[$event] = $fname;
  102. }
  103. }
  104. /**
  105. * Set one or multiple performance expectations
  106. *
  107. * With conflicting expectations, the most narrow ones will be used
  108. *
  109. * Use this to initialize expectations or make them stricter mid-request
  110. *
  111. * @param array $expects Map of (event => limit)
  112. * @param string $fname
  113. * @since 1.26
  114. */
  115. public function setExpectations( array $expects, $fname ) {
  116. foreach ( $expects as $event => $value ) {
  117. $this->setExpectation( $event, $value, $fname );
  118. }
  119. }
  120. /**
  121. * Reset all performance expectations and hit counters
  122. *
  123. * Use this for unit testing or before applying a totally different set of expectations
  124. * for a different part of the request, such as during "post-send" (execution after HTTP
  125. * response completion)
  126. *
  127. * @since 1.25
  128. */
  129. public function resetExpectations() {
  130. foreach ( $this->hits as &$val ) {
  131. $val = 0;
  132. }
  133. unset( $val );
  134. foreach ( $this->expect as &$val ) {
  135. $val = INF;
  136. }
  137. unset( $val );
  138. $this->expectBy = [];
  139. }
  140. /**
  141. * Clear all expectations and hit counters and set new performance expectations
  142. *
  143. * Use this to apply a totally different set of expectations for a different part
  144. * of the request, such as during "post-send" (execution after HTTP response completion)
  145. *
  146. * @param array $expects Map of (event => limit)
  147. * @param string $fname
  148. * @since 1.33
  149. */
  150. public function redefineExpectations( array $expects, $fname ) {
  151. $this->resetExpectations();
  152. $this->setExpectations( $expects, $fname );
  153. }
  154. /**
  155. * Mark a DB as having been connected to with a new handle
  156. *
  157. * Note that there can be multiple connections to a single DB.
  158. *
  159. * @param string $server DB server
  160. * @param string $db DB name
  161. * @param bool $isMaster
  162. */
  163. public function recordConnection( $server, $db, $isMaster ) {
  164. // Report when too many connections happen...
  165. if ( $this->hits['conns']++ >= $this->expect['conns'] ) {
  166. $this->reportExpectationViolated(
  167. 'conns', "[connect to $server ($db)]", $this->hits['conns'] );
  168. }
  169. if ( $isMaster && $this->hits['masterConns']++ >= $this->expect['masterConns'] ) {
  170. $this->reportExpectationViolated(
  171. 'masterConns', "[connect to $server ($db)]", $this->hits['masterConns'] );
  172. }
  173. }
  174. /**
  175. * Mark a DB as in a transaction with one or more writes pending
  176. *
  177. * Note that there can be multiple connections to a single DB.
  178. *
  179. * @param string $server DB server
  180. * @param string $db DB name
  181. * @param string $id ID string of transaction
  182. */
  183. public function transactionWritingIn( $server, $db, $id ) {
  184. $name = "{$server} ({$db}) (TRX#$id)";
  185. if ( isset( $this->dbTrxHoldingLocks[$name] ) ) {
  186. $this->logger->warning( "Nested transaction for '$name' - out of sync." );
  187. }
  188. $this->dbTrxHoldingLocks[$name] = [
  189. 'start' => microtime( true ),
  190. 'conns' => [], // all connections involved
  191. ];
  192. $this->dbTrxMethodTimes[$name] = [];
  193. foreach ( $this->dbTrxHoldingLocks as $name => &$info ) {
  194. // Track all DBs in transactions for this transaction
  195. $info['conns'][$name] = 1;
  196. }
  197. }
  198. /**
  199. * Register the name and time of a method for slow DB trx detection
  200. *
  201. * This assumes that all queries are synchronous (non-overlapping)
  202. *
  203. * @param string|GeneralizedSql $query Function name or generalized SQL
  204. * @param float $sTime Starting UNIX wall time
  205. * @param bool $isWrite Whether this is a write query
  206. * @param int $n Number of affected/read rows
  207. */
  208. public function recordQueryCompletion( $query, $sTime, $isWrite = false, $n = 0 ) {
  209. $eTime = microtime( true );
  210. $elapsed = ( $eTime - $sTime );
  211. if ( $isWrite && $n > $this->expect['maxAffected'] ) {
  212. $this->logger->warning(
  213. "Query affected $n row(s):\n" . self::queryString( $query ) . "\n" .
  214. ( new RuntimeException() )->getTraceAsString() );
  215. } elseif ( !$isWrite && $n > $this->expect['readQueryRows'] ) {
  216. $this->logger->warning(
  217. "Query returned $n row(s):\n" . self::queryString( $query ) . "\n" .
  218. ( new RuntimeException() )->getTraceAsString() );
  219. }
  220. // Report when too many writes/queries happen...
  221. if ( $this->hits['queries']++ >= $this->expect['queries'] ) {
  222. $this->reportExpectationViolated( 'queries', $query, $this->hits['queries'] );
  223. }
  224. if ( $isWrite && $this->hits['writes']++ >= $this->expect['writes'] ) {
  225. $this->reportExpectationViolated( 'writes', $query, $this->hits['writes'] );
  226. }
  227. // Report slow queries...
  228. if ( !$isWrite && $elapsed > $this->expect['readQueryTime'] ) {
  229. $this->reportExpectationViolated( 'readQueryTime', $query, $elapsed );
  230. }
  231. if ( $isWrite && $elapsed > $this->expect['writeQueryTime'] ) {
  232. $this->reportExpectationViolated( 'writeQueryTime', $query, $elapsed );
  233. }
  234. if ( !$this->dbTrxHoldingLocks ) {
  235. // Short-circuit
  236. return;
  237. } elseif ( !$isWrite && $elapsed < $this->eventThreshold ) {
  238. // Not an important query nor slow enough
  239. return;
  240. }
  241. foreach ( $this->dbTrxHoldingLocks as $name => $info ) {
  242. $lastQuery = end( $this->dbTrxMethodTimes[$name] );
  243. if ( $lastQuery ) {
  244. // Additional query in the trx...
  245. $lastEnd = $lastQuery[2];
  246. if ( $sTime >= $lastEnd ) { // sanity check
  247. if ( ( $sTime - $lastEnd ) > $this->eventThreshold ) {
  248. // Add an entry representing the time spent doing non-queries
  249. $this->dbTrxMethodTimes[$name][] = [ '...delay...', $lastEnd, $sTime ];
  250. }
  251. $this->dbTrxMethodTimes[$name][] = [ $query, $sTime, $eTime ];
  252. }
  253. } else {
  254. // First query in the trx...
  255. if ( $sTime >= $info['start'] ) { // sanity check
  256. $this->dbTrxMethodTimes[$name][] = [ $query, $sTime, $eTime ];
  257. }
  258. }
  259. }
  260. }
  261. /**
  262. * Mark a DB as no longer in a transaction
  263. *
  264. * This will check if locks are possibly held for longer than
  265. * needed and log any affected transactions to a special DB log.
  266. * Note that there can be multiple connections to a single DB.
  267. *
  268. * @param string $server DB server
  269. * @param string $db DB name
  270. * @param string $id ID string of transaction
  271. * @param float $writeTime Time spent in write queries
  272. * @param int $affected Number of rows affected by writes
  273. */
  274. public function transactionWritingOut( $server, $db, $id, $writeTime = 0.0, $affected = 0 ) {
  275. $name = "{$server} ({$db}) (TRX#$id)";
  276. if ( !isset( $this->dbTrxMethodTimes[$name] ) ) {
  277. $this->logger->warning( "Detected no transaction for '$name' - out of sync." );
  278. return;
  279. }
  280. $slow = false;
  281. // Warn if too much time was spend writing...
  282. if ( $writeTime > $this->expect['writeQueryTime'] ) {
  283. $this->reportExpectationViolated(
  284. 'writeQueryTime',
  285. "[transaction $id writes to {$server} ({$db})]",
  286. $writeTime
  287. );
  288. $slow = true;
  289. }
  290. // Warn if too many rows were changed...
  291. if ( $affected > $this->expect['maxAffected'] ) {
  292. $this->reportExpectationViolated(
  293. 'maxAffected',
  294. "[transaction $id writes to {$server} ({$db})]",
  295. $affected
  296. );
  297. }
  298. // Fill in the last non-query period...
  299. $lastQuery = end( $this->dbTrxMethodTimes[$name] );
  300. if ( $lastQuery ) {
  301. $now = microtime( true );
  302. $lastEnd = $lastQuery[2];
  303. if ( ( $now - $lastEnd ) > $this->eventThreshold ) {
  304. $this->dbTrxMethodTimes[$name][] = [ '...delay...', $lastEnd, $now ];
  305. }
  306. }
  307. // Check for any slow queries or non-query periods...
  308. foreach ( $this->dbTrxMethodTimes[$name] as $info ) {
  309. $elapsed = ( $info[2] - $info[1] );
  310. if ( $elapsed >= $this->dbLockThreshold ) {
  311. $slow = true;
  312. break;
  313. }
  314. }
  315. if ( $slow ) {
  316. $trace = '';
  317. foreach ( $this->dbTrxMethodTimes[$name] as $i => $info ) {
  318. list( $query, $sTime, $end ) = $info;
  319. $trace .= sprintf(
  320. "%d\t%.6f\t%s\n", $i, ( $end - $sTime ), self::queryString( $query ) );
  321. }
  322. $this->logger->warning( "Sub-optimal transaction on DB(s) [{dbs}]: \n{trace}", [
  323. 'dbs' => implode( ', ', array_keys( $this->dbTrxHoldingLocks[$name]['conns'] ) ),
  324. 'trace' => $trace
  325. ] );
  326. }
  327. unset( $this->dbTrxHoldingLocks[$name] );
  328. unset( $this->dbTrxMethodTimes[$name] );
  329. }
  330. /**
  331. * @param string $expect
  332. * @param string|GeneralizedSql $query
  333. * @param string|float|int $actual
  334. */
  335. protected function reportExpectationViolated( $expect, $query, $actual ) {
  336. if ( $this->silenced ) {
  337. return;
  338. }
  339. $this->logger->warning(
  340. "Expectation ({measure} <= {max}) by {by} not met (actual: {actual}):\n{query}\n" .
  341. ( new RuntimeException() )->getTraceAsString(),
  342. [
  343. 'measure' => $expect,
  344. 'max' => $this->expect[$expect],
  345. 'by' => $this->expectBy[$expect],
  346. 'actual' => $actual,
  347. 'query' => self::queryString( $query )
  348. ]
  349. );
  350. }
  351. /**
  352. * @param GeneralizedSql|string $query
  353. * @return string
  354. */
  355. private static function queryString( $query ) {
  356. return $query instanceof GeneralizedSql ? $query->stringify() : $query;
  357. }
  358. }