SaltController.php 7.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272
  1. <?php
  2. namespace console\controllers;
  3. use bizGhs\order\models\Order as GhsOrder;
  4. use bizHd\purchase\models\Purchase;
  5. use common\components\stringUtil;
  6. use Yii;
  7. use yii\console\Controller;
  8. use yii\console\ExitCode;
  9. /**
  10. * 补充历史订单 salt 字段
  11. *
  12. * 大表安全说明:
  13. * - 不做 COUNT(*),避免全表统计
  14. * - 默认 empty 模式只查 salt='' 的记录,补完后即停止,不会扫全表
  15. * - 查询均为普通 SELECT(MVCC 快照读),不会锁表;UPDATE 仅锁单行
  16. * - 大表建议先加索引: ALTER TABLE xhGhsOrder ADD INDEX idx_salt_id (salt, id);
  17. *
  18. * 用法:
  19. * php yii salt/fill
  20. * php yii salt/fill --dryRun=1
  21. * php yii salt/fill --batchSize=1000 --sleepMs=200
  22. * php yii salt/fill --mode=pk --scanSize=1000
  23. * php yii salt/fill-ghs-order
  24. * php yii salt/fill-cg
  25. */
  26. class SaltController extends Controller
  27. {
  28. /** @var int 每批处理条数 */
  29. public $batchSize = 1000;
  30. /** @var int pk 模式下每次主键游标扫描行数 */
  31. public $scanSize = 1000;
  32. /**
  33. * 扫描模式:
  34. * - empty: 只查 salt='',适合待补数据量较少(默认)
  35. * - pk: 按主键分段扫描,适合 salt 列无索引且 empty 模式执行计划不佳时
  36. */
  37. public $mode = 'empty';
  38. /** @var int 每批之间的休眠毫秒数,降低白天执行时的数据库压力 */
  39. public $sleepMs = 100;
  40. /** @var int 最多处理条数,0 表示全部 */
  41. public $limit = 0;
  42. /** @var int 是否仅预览,1=是 */
  43. public $dryRun = 0;
  44. public function options($actionID)
  45. {
  46. return array_merge(parent::options($actionID), [
  47. 'batchSize',
  48. 'scanSize',
  49. 'mode',
  50. 'sleepMs',
  51. 'limit',
  52. 'dryRun',
  53. ]);
  54. }
  55. /**
  56. * 补充 xhGhsOrder、xhCg 历史数据的 salt 字段
  57. */
  58. public function actionFill()
  59. {
  60. return $this->runFill([
  61. 'xhGhsOrder' => GhsOrder::tableName(),
  62. 'xhCg' => Purchase::tableName(),
  63. ]);
  64. }
  65. /**
  66. * 仅补充 xhGhsOrder
  67. */
  68. public function actionFillGhsOrder()
  69. {
  70. return $this->runFill([
  71. 'xhGhsOrder' => GhsOrder::tableName(),
  72. ]);
  73. }
  74. /**
  75. * 仅补充 xhCg
  76. */
  77. public function actionFillCg()
  78. {
  79. return $this->runFill([
  80. 'xhCg' => Purchase::tableName(),
  81. ]);
  82. }
  83. private function runFill(array $tables)
  84. {
  85. ini_set('memory_limit', '512M');
  86. set_time_limit(0);
  87. if ($this->batchSize < 1) {
  88. $this->stderr("batchSize 必须大于 0\n");
  89. return ExitCode::UNSPECIFIED_ERROR;
  90. }
  91. if ($this->scanSize < 1) {
  92. $this->stderr("scanSize 必须大于 0\n");
  93. return ExitCode::UNSPECIFIED_ERROR;
  94. }
  95. if (!in_array($this->mode, ['empty', 'pk'], true)) {
  96. $this->stderr("mode 仅支持 empty 或 pk\n");
  97. return ExitCode::UNSPECIFIED_ERROR;
  98. }
  99. $exitCode = ExitCode::OK;
  100. foreach ($tables as $label => $tableName) {
  101. $code = $this->fillTable($label, $tableName);
  102. if ($code !== ExitCode::OK) {
  103. $exitCode = $code;
  104. }
  105. }
  106. return $exitCode;
  107. }
  108. private function fillTable($label, $tableName)
  109. {
  110. $db = Yii::$app->db;
  111. $db->createCommand('SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED')->execute();
  112. $this->stdout("{$label}({$tableName}) 开始补充 salt,模式: {$this->mode}\n");
  113. if ($this->mode === 'empty') {
  114. $processed = $this->fillByEmptySalt($label, $tableName);
  115. } else {
  116. $processed = $this->fillByPrimaryKeyScan($label, $tableName);
  117. }
  118. $this->stdout("{$label} 完成,本次处理 {$processed} 条\n");
  119. return ExitCode::OK;
  120. }
  121. /**
  122. * 只查 salt='' 的记录,从最大 id 向小 id 分批处理
  123. */
  124. private function fillByEmptySalt($label, $tableName)
  125. {
  126. $db = Yii::$app->db;
  127. $processed = 0;
  128. $lastId = null;
  129. while (true) {
  130. if ($this->limit > 0 && $processed >= $this->limit) {
  131. break;
  132. }
  133. $batchLimit = $this->limit > 0 ? min($this->batchSize, $this->limit - $processed) : $this->batchSize;
  134. if ($lastId === null) {
  135. $rows = $db->createCommand(
  136. "SELECT id FROM {$tableName} WHERE salt = '' ORDER BY id DESC LIMIT {$batchLimit}"
  137. )->queryColumn();
  138. } else {
  139. $rows = $db->createCommand(
  140. "SELECT id FROM {$tableName} WHERE salt = '' AND id < :lastId ORDER BY id DESC LIMIT {$batchLimit}"
  141. )->bindValue(':lastId', $lastId)->queryColumn();
  142. }
  143. if (empty($rows)) {
  144. break;
  145. }
  146. $processed += $this->updateIds($label, $tableName, $rows);
  147. $lastId = (int)min($rows);
  148. $this->stdout("{$label} 已处理 {$processed} 条,当前 id >= {$lastId}\n");
  149. $this->sleepBetweenBatch();
  150. }
  151. return $processed;
  152. }
  153. /**
  154. * 按主键从最大 id 向小 id 分段扫描;适合 empty 模式执行计划不佳时使用
  155. */
  156. private function fillByPrimaryKeyScan($label, $tableName)
  157. {
  158. $db = Yii::$app->db;
  159. $processed = 0;
  160. $lastId = null;
  161. while (true) {
  162. if ($this->limit > 0 && $processed >= $this->limit) {
  163. break;
  164. }
  165. if ($lastId === null) {
  166. $ids = $db->createCommand(
  167. "SELECT id FROM {$tableName} ORDER BY id DESC LIMIT {$this->scanSize}"
  168. )->queryColumn();
  169. } else {
  170. $ids = $db->createCommand(
  171. "SELECT id FROM {$tableName} WHERE id < :lastId ORDER BY id DESC LIMIT {$this->scanSize}"
  172. )->bindValue(':lastId', $lastId)->queryColumn();
  173. }
  174. if (empty($ids)) {
  175. break;
  176. }
  177. $lastId = (int)min($ids);
  178. $minId = (int)min($ids);
  179. $maxId = (int)max($ids);
  180. $emptyIds = $db->createCommand(
  181. "SELECT id FROM {$tableName} WHERE id >= :minId AND id <= :maxId AND salt = '' ORDER BY id DESC"
  182. )->bindValues([
  183. ':minId' => $minId,
  184. ':maxId' => $maxId,
  185. ])->queryColumn();
  186. if (!empty($emptyIds)) {
  187. if ($this->limit > 0) {
  188. $emptyIds = array_slice($emptyIds, 0, $this->limit - $processed);
  189. }
  190. $processed += $this->updateIds($label, $tableName, $emptyIds);
  191. }
  192. $this->stdout("{$label} 已处理 {$processed} 条,已扫描到 id >= {$lastId}\n");
  193. $this->sleepBetweenBatch();
  194. }
  195. return $processed;
  196. }
  197. private function updateIds($label, $tableName, array $ids)
  198. {
  199. $db = Yii::$app->db;
  200. $updated = 0;
  201. foreach ($ids as $id) {
  202. $id = (int)$id;
  203. if ($id <= 0) {
  204. continue;
  205. }
  206. if ($this->dryRun) {
  207. $this->stdout("[dry-run] {$label} id={$id}\n");
  208. $updated++;
  209. continue;
  210. }
  211. $salt = stringUtil::charsShuffleLowerCase(10);
  212. $affected = $db->createCommand(
  213. "UPDATE {$tableName} SET salt = :salt WHERE id = :id AND salt = ''"
  214. )->bindValues([
  215. ':salt' => $salt,
  216. ':id' => $id,
  217. ])->execute();
  218. if ($affected > 0) {
  219. $updated++;
  220. }
  221. }
  222. return $updated;
  223. }
  224. private function sleepBetweenBatch()
  225. {
  226. if ($this->sleepMs > 0) {
  227. usleep($this->sleepMs * 1000);
  228. }
  229. }
  230. }