码桶

发现社区成员的开源项目

geekssl /

wstats

公开
main
Db.php5.9 KB
<?php
/**
 * 数据库访问封装(PDO 单例 + 批量写入)。
 * 说明:业务写库统一走 scripts/worker.php 的批量事务;本类同时提供直写接口作为降级路径。
 */
declare(strict_types=1);

namespace Wstat\Support;

use PDO;
use RuntimeException;

class Db
{
    private static ?PDO $pdo = null;

    private static function cfg(): array
    {
        return wstat_config('db');
    }

    public static function pdo(): PDO
    {
        if (self::$pdo !== null) {
            return self::$pdo;
        }
        $c = self::cfg();
        $dsn = sprintf('mysql:host=%s;port=%d;dbname=%s;charset=%s', $c['host'], $c['port'], $c['name'], $c['charset']);
        try {
            $pdo = new PDO($dsn, $c['user'], $c['pass'], [
                PDO::ATTR_ERRMODE            => PDO::ERRMODE_EXCEPTION,
                PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
                PDO::ATTR_EMULATE_PREPARES   => false,
                PDO::MYSQL_ATTR_INIT_COMMAND => "SET time_zone = '+00:00', sql_mode=''",
            ]);
        } catch (\PDOException $e) {
            throw new RuntimeException('DB connect failed: ' . $e->getMessage());
        }
        self::$pdo = $pdo;
        return $pdo;
    }

    /** 查询多行 */
    public static function select(string $sql, array $params = []): array
    {
        $st = self::pdo()->prepare($sql);
        $st->execute($params);
        return $st->fetchAll();
    }

    /** 查询单行 */
    public static function first(string $sql, array $params = []): ?array
    {
        $rows = self::select($sql, $params);
        return $rows[0] ?? null;
    }

    /** 查询单值 */
    public static function value(string $sql, array $params = [])
    {
        $row = self::first($sql, $params);
        if ($row === null) {
            return null;
        }
        return reset($row);
    }

    /** 表列名集合(进程内缓存;SHOW COLUMNS 一次,供写入前过滤未知列) */
    private static array $tableCols = [];

    public static function tableColumns(string $table): array
    {
        if (!isset(self::$tableCols[$table])) {
            $set = [];
            foreach (self::select('SHOW COLUMNS FROM `' . $table . '`') as $r) {
                $set[(string) $r['Field']] = true;
            }
            self::$tableCols[$table] = $set;
        }
        return self::$tableCols[$table];
    }

    /** 仅保留表中真实存在的列(未跑增量迁移时自动容错,避免 INSERT 报未知列) */
    public static function filterColumns(string $table, array $row): array
    {
        $cols = self::tableColumns($table);
        return array_intersect_key($row, $cols);
    }

    /** 执行写语句,返回受影响行数 */
    public static function execute(string $sql, array $params = []): int
    {
        $st = self::pdo()->prepare($sql);
        $st->execute($params);
        return $st->rowCount();
    }

    /** 单行插入 */
    public static function insert(string $table, array $row): int
    {
        self::insertBatch($table, [$row]);
        return (int) self::pdo()->lastInsertId();
    }

    /**
     * 批量插入(多行一条 SQL,分批执行)。$rows 为空则跳过。
     * 健壮性:
     *  - 丢弃空数组/非数组行(历史或异构载荷经列过滤后可能为空),避免
     *    `(``)` 空名列 + 0 值元组触发 MySQL 1136「列数不匹配」;
     *  - 列集合取各行键的并集(保序),兼容异构事件行不丢列(如批首行无 geo、
     *    后续行带 geo 时旧实现会静默丢弃)。
     */
    public static function insertBatch(string $table, array $rows, int $chunk = 200, bool $ignore = false): int
    {
        if (empty($rows)) {
            return 0;
        }
        // 1) 只保留非空关联行
        $rows = array_values(array_filter($rows, static fn ($r) => is_array($r) && $r !== []));
        if (empty($rows)) {
            return 0;
        }
        // 2) 键并集(保持首次出现顺序)作为列集合
        $merged = [];
        foreach ($rows as $row) {
            $merged += $row;
        }
        $cols = array_keys($merged);
        $colSql = '`' . implode('`,`', $cols) . '`';
        $verb = $ignore ? 'INSERT IGNORE' : 'INSERT';
        $pdo = self::pdo();
        $n = 0;
        foreach (array_chunk($rows, $chunk) as $batch) {
            $placeholders = [];
            $flat = [];
            foreach ($batch as $row) {
                $ph = [];
                foreach ($cols as $c) {
                    $flat[] = $row[$c] ?? null;
                    $ph[] = '?';
                }
                $placeholders[] = '(' . implode(',', $ph) . ')';
            }
            $sql = sprintf('%s INTO `%s` (%s) VALUES %s', $verb, $table, $colSql, implode(',', $placeholders));
            try {
                $st = $pdo->prepare($sql);
                $st->execute($flat);
                $n += $st->rowCount();
            } catch (\PDOException $e) {
                // 带上表名/行数/列数便于定位(事件批 100 行内嵌上下文)
                throw new \PDOException(sprintf(
                    '[Db::insertBatch:%s] rows=%d cols=%d | %s',
                    $table,
                    count($batch),
                    count($cols),
                    $e->getMessage()
                ), (int) $e->getCode(), $e);
            }
        }
        return $n;
    }

    /** 单行 INSERT IGNORE */
    public static function insertIgnore(string $table, array $row): int
    {
        return self::insertBatch($table, [$row], 200, true);
    }

    /** 事务回调 */
    public static function transaction(callable $fn)
    {
        $pdo = self::pdo();
        $pdo->beginTransaction();
        try {
            $r = $fn($pdo);
            $pdo->commit();
            return $r;
        } catch (\Throwable $e) {
            if ($pdo->inTransaction()) {
                $pdo->rollBack();
            }
            throw $e;
        }
    }
}