05-数据库与缓存

10 阅读4分钟

数据库与缓存

1. 数据库连接池

1.1 为什么需要连接池?

传统 PHP-FPM 模式

请求1 → 创建连接 → 查询 → 关闭连接
请求2 → 创建连接 → 查询 → 关闭连接
请求3 → 创建连接 → 查询 → 关闭连接

每次都要:
1. TCP 三次握手
2. MySQL 认证
3. TCP 四次挥手

Hyperf 连接池模式

启动时:创建 10 个连接,放入连接池

请求1 → 从池中获取连接 → 查询 → 归还连接
请求2 → 从池中获取连接 → 查询 → 归还连接
请求3 → 从池中获取连接 → 查询 → 归还连接

复用连接,无需重复创建和销毁

1.2 连接池配置

config/autoload/databases.php

<?php
return [
    'default' => [
        'driver' => 'mysql',
        'host' => env('DB_HOST', '127.0.0.1'),
        'port' => env('DB_PORT', 3306),
        'database' => env('DB_DATABASE', 'hyperf'),
        'username' => env('DB_USERNAME', 'root'),
        'password' => env('DB_PASSWORD', ''),
        'charset' => 'utf8mb4',
        'collation' => 'utf8mb4_unicode_ci',
        'prefix' => '',
        'pool' => [
            'min_connections' => 10,        // 最小连接数(预热)
            'max_connections' => 32,        // 最大连接数
            'connect_timeout' => 10.0,      // 连接超时时间
            'wait_timeout' => 3.0,          // 等待超时时间
            'heartbeat' => -1,              // 心跳检测间隔(-1 表示禁用)
            'max_idle_time' => 60,          // 最大空闲时间
        ],
        'commands' => [
            'gen:model' => [
                'path' => 'app/Model',
                'force_casts' => true,
                'inheritance' => 'Model',
            ],
        ],
    ],
];

参数说明

参数说明推荐值
min_connections最小连接数,启动时创建10
max_connections最大连接数,超过则等待32(CPU 核心数 * 2)
connect_timeout连接超时时间(秒)10.0
wait_timeout等待可用连接的超时时间(秒)3.0
heartbeat心跳检测间隔(秒),-1 表示禁用-1
max_idle_time连接最大空闲时间(秒)60

1.3 连接池监控

<?php
use Hyperf\DbConnection\Pool\PoolFactory;
use Hyperf\Di\Annotation\Inject;

class MonitorService
{
    #[Inject]
    private PoolFactory $poolFactory;
    
    public function getPoolStatus()
    {
        $pool = $this->poolFactory->getPool('default');
        
        return [
            'current_connections' => $pool->getCurrentConnections(),  // 当前连接数
            'max_connections' => $pool->getOption()->getMaxConnections(),
            'min_connections' => $pool->getOption()->getMinConnections(),
            'idle_count' => $pool->getConnectionsInChannel(),  // 空闲连接数
        ];
    }
}

2. 数据库查询优化

2.1 避免 N+1 查询

问题代码

<?php
// 查询所有用户(1 次查询)
$users = User::all();

// 循环查询每个用户的订单(N 次查询)
foreach ($users as $user) {
    echo $user->orders->count();  // 每次都查询数据库
}

// 总查询次数:1 + N

优化方案:使用预加载

<?php
// 只查询 2 次(1 次用户 + 1 次订单)
$users = User::with('orders')->get();

foreach ($users as $user) {
    echo $user->orders->count();  // 直接从内存读取
}

2.2 使用索引

问题查询

<?php
// 查询邮箱(未建索引)
$user = User::where('email', 'test@example.com')->first();

// 执行计划:全表扫描,扫描 10 万行

优化方案

<?php
// 1. 创建索引
Schema::table('users', function (Blueprint $table) {
    $table->index('email');
});

// 2. 查询
$user = User::where('email', 'test@example.com')->first();

// 执行计划:索引扫描,只扫描 1 行

2.3 批量操作

问题代码

<?php
// 循环插入(1000 次 SQL)
foreach ($data as $item) {
    User::create($item);
}

优化方案

<?php
// 批量插入(1 次 SQL)
User::insert($data);

// 或者使用 chunk 分批插入(避免单次数据过大)
collect($data)->chunk(100)->each(function ($chunk) {
    User::insert($chunk->toArray());
});

2.4 只查询需要的字段

问题代码

<?php
// 查询所有字段
$users = User::where('status', 1)->get();

// 实际只需要 id 和 name

优化方案

<?php
// 只查询需要的字段
$users = User::where('status', 1)->select('id', 'name')->get();

// 或者使用 pluck(只返回单列)
$names = User::where('status', 1)->pluck('name');

2.5 使用 chunk 处理大量数据

问题代码

<?php
// 一次性加载 100 万条数据到内存
$users = User::all();

foreach ($users as $user) {
    // 处理用户
}

// 内存占用:几百 MB

优化方案

<?php
// 分批处理,每次 1000 条
User::chunk(1000, function ($users) {
    foreach ($users as $user) {
        // 处理用户
    }
});

// 内存占用:几 MB

3. 缓存策略

3.1 缓存使用场景

适合缓存的数据

  • ✅ 读多写少的数据(商品信息、配置项)
  • ✅ 计算复杂的数据(统计数据、排行榜)
  • ✅ 热点数据(首页数据、热门商品)

不适合缓存的数据

  • ❌ 实时性要求高的数据(订单状态、库存)
  • ❌ 个性化数据(用户偏好设置)
  • ❌ 写多读少的数据

3.2 缓存注解

@Cacheable(生成缓存)
<?php
use Hyperf\Cache\Annotation\Cacheable;

class ProductService
{
    /**
     * 查询商品(首次查询数据库,后续查询缓存)
     */
    #[Cacheable(
        prefix: 'product',           // 缓存前缀
        ttl: 3600,                   // 过期时间(秒)
        value: '#{id}',              // 缓存 key(支持 SpEL 表达式)
        listener: 'product-update'   // 监听器(可选)
    )]
    public function getProduct(int $id)
    {
        return Product::find($id);
    }
}

生成的缓存 keyc:product:1(c 是固定前缀)

@CachePut(更新缓存)
<?php
use Hyperf\Cache\Annotation\CachePut;

class ProductService
{
    /**
     * 更新商品(每次都执行方法,并更新缓存)
     */
    #[CachePut(
        prefix: 'product',
        ttl: 3600,
        value: '#{product.id}'       // 从方法参数获取 key
    )]
    public function updateProduct(array $product)
    {
        $model = Product::find($product['id']);
        $model->update($product);
        return $model;
    }
}
@CacheEvict(删除缓存)
<?php
use Hyperf\Cache\Annotation\CacheEvict;

class ProductService
{
    /**
     * 删除商品(删除对应的缓存)
     */
    #[CacheEvict(
        prefix: 'product',
        value: '#{id}'
    )]
    public function deleteProduct(int $id)
    {
        Product::destroy($id);
    }
    
    /**
     * 删除所有商品缓存
     */
    #[CacheEvict(
        prefix: 'product',
        all: true                    // 删除所有缓存
    )]
    public function clearAllCache()
    {
        // 清空缓存
    }
}

3.3 手动操作缓存

<?php
use Hyperf\Redis\Redis;
use Hyperf\Di\Annotation\Inject;

class CacheService
{
    #[Inject]
    private Redis $redis;
    
    /**
     * 获取缓存
     */
    public function get(string $key, callable $callback, int $ttl = 3600)
    {
        // 1. 尝试从缓存获取
        $value = $this->redis->get($key);
        
        if ($value !== null) {
            return json_decode($value, true);
        }
        
        // 2. 缓存未命中,执行回调
        $data = $callback();
        
        // 3. 保存到缓存
        if ($data !== null) {
            $this->redis->setex($key, $ttl, json_encode($data));
        }
        
        return $data;
    }
    
    /**
     * 设置缓存
     */
    public function set(string $key, $value, int $ttl = 3600)
    {
        $this->redis->setex($key, $ttl, json_encode($value));
    }
    
    /**
     * 删除缓存
     */
    public function delete(string $key)
    {
        $this->redis->del($key);
    }
    
    /**
     * 批量删除(模糊匹配)
     */
    public function deletePattern(string $pattern)
    {
        $keys = $this->redis->keys($pattern);
        if ($keys) {
            $this->redis->del(...$keys);
        }
    }
}

3.4 缓存穿透、击穿、雪崩

缓存穿透(查询不存在的数据)

问题

// 用户恶意查询不存在的 ID(如 -1)
$product = $this->getProduct(-1);

// 每次都查询数据库,缓存无效

解决方案:缓存空值

<?php
public function getProduct(int $id)
{
    $key = "product:{$id}";
    $value = $this->redis->get($key);
    
    // 缓存命中(包括空值)
    if ($value !== false) {
        return $value === 'null' ? null : json_decode($value, true);
    }
    
    // 查询数据库
    $product = Product::find($id);
    
    if ($product === null) {
        // 缓存空值(短时间,如 60 秒)
        $this->redis->setex($key, 60, 'null');
    } else {
        // 缓存数据
        $this->redis->setex($key, 3600, json_encode($product));
    }
    
    return $product;
}
缓存击穿(热点 key 过期)

问题

时间:2:00:00 - 热点商品的缓存过期
瞬间 10000 个请求同时到达
所有请求都去查询数据库
数据库压力骤增,可能宕机

解决方案:分布式锁

<?php
public function getProduct(int $id)
{
    $key = "product:{$id}";
    $value = $this->redis->get($key);
    
    if ($value !== false) {
        return json_decode($value, true);
    }
    
    // 获取分布式锁
    $lockKey = "lock:{$key}";
    $lockValue = uniqid();
    $locked = $this->redis->set($lockKey, $lockValue, ['NX', 'EX' => 10]);
    
    if (!$locked) {
        // 未获取到锁,等待后重试
        usleep(50000);  // 等待 50ms
        return $this->getProduct($id);
    }
    
    try {
        // 双重检查
        $value = $this->redis->get($key);
        if ($value !== false) {
            return json_decode($value, true);
        }
        
        // 查询数据库
        $product = Product::find($id);
        
        // 保存到缓存
        $this->redis->setex($key, 3600, json_encode($product));
        
        return $product;
    } finally {
        // 释放锁(Lua 脚本保证原子性)
        $script = <<<LUA
            if redis.call('get', KEYS[1]) == ARGV[1] then
                return redis.call('del', KEYS[1])
            else
                return 0
            end
        LUA;
        $this->redis->eval($script, [$lockKey, $lockValue], 1);
    }
}
缓存雪崩(大量 key 同时过期)

问题

时间:2:00:00 - 1000 个商品的缓存同时过期
瞬间大量请求查询数据库
数据库压力骤增

解决方案:过期时间加随机值

<?php
public function setCache(string $key, $value, int $baseTTL = 3600)
{
    // 过期时间加 0-10 分钟的随机值
    $ttl = $baseTTL + mt_rand(0, 600);
    $this->redis->setex($key, $ttl, json_encode($value));
}

3.5 多级缓存

<?php
use Psr\SimpleCache\CacheInterface;  // 本地缓存

class ProductService
{
    #[Inject]
    private Redis $redis;  // Redis 缓存
    
    #[Inject]
    private CacheInterface $cache;  // 本地缓存(进程内存)
    
    public function getProduct(int $id)
    {
        $key = "product:{$id}";
        
        // 1. 本地缓存(最快,但只在单个进程有效)
        $product = $this->cache->get($key);
        if ($product) {
            return $product;
        }
        
        // 2. Redis 缓存(较快,全局共享)
        $product = $this->redis->get($key);
        if ($product) {
            $product = json_decode($product, true);
            $this->cache->set($key, $product, 60);  // 缓存 1 分钟
            return $product;
        }
        
        // 3. 数据库(最慢)
        $product = Product::find($id);
        if ($product) {
            $this->redis->setex($key, 3600, json_encode($product));
            $this->cache->set($key, $product, 60);
        }
        
        return $product;
    }
}

优势

  • 本地缓存:无网络开销,极快
  • Redis 缓存:全局共享,持久化
  • 数据库:数据源

4. 事务处理

4.1 基本事务

<?php
use Hyperf\DbConnection\Db;

// 方式一:闭包
Db::transaction(function () {
    // 创建订单
    $order = Order::create([...]);
    
    // 扣减库存
    Product::where('id', $productId)->decrement('stock');
    
    // 如果发生异常,自动回滚
});

// 方式二:手动控制
Db::beginTransaction();
try {
    // 业务逻辑
    $order = Order::create([...]);
    Product::where('id', $productId)->decrement('stock');
    
    Db::commit();
} catch (\Exception $e) {
    Db::rollBack();
    throw $e;
}

4.2 事务注解(AOP)

<?php
namespace App\Aspect;

use App\Annotation\Transaction;
use Hyperf\DbConnection\Db;
use Hyperf\Di\Annotation\Aspect;
use Hyperf\Di\Aop\AbstractAspect;
use Hyperf\Di\Aop\ProceedingJoinPoint;

#[Aspect]
class TransactionAspect extends AbstractAspect
{
    public array $annotations = [
        Transaction::class,
    ];
    
    public function process(ProceedingJoinPoint $proceedingJoinPoint)
    {
        return Db::transaction(function () use ($proceedingJoinPoint) {
            return $proceedingJoinPoint->process();
        });
    }
}

使用:

<?php
use App\Annotation\Transaction;

class OrderService
{
    #[Transaction]
    public function createOrder(array $data)
    {
        // 自动开启事务
        $order = Order::create($data);
        Product::where('id', $data['product_id'])->decrement('stock');
        
        // 方法执行成功,自动提交
        // 如果抛出异常,自动回滚
        return $order;
    }
}

5. 要点

必须掌握

  • 连接池的作用和配置
  • N+1 查询问题及解决方案
  • 缓存的使用场景
  • 缓存穿透、击穿、雪崩的区别和解决方案
  • 事务的使用

加分项

  • 数据库索引优化
  • 分布式锁的实现
  • 多级缓存架构
  • 读写分离和分库分表

高频题

1. 什么是 N+1 查询?如何解决?

答:N+1 查询是指查询主表 1 次,然后循环查询关联表 N 次,导致大量数据库查询。

解决方案:使用预加载(with)一次性查询所有数据。

2. 如何避免缓存雪崩?

答:缓存雪崩是大量缓存同时过期,导致数据库压力骤增。

解决方案:

  1. 过期时间加随机值,避免同时过期
  2. 使用多级缓存
  3. 设置热点数据永不过期

3. 分布式锁如何实现?

答:使用 Redis 的 SET NX EX 命令:

$locked = $redis->set($key, $value, ['NX', 'EX' => 10]);
  • NX:只在 key 不存在时设置
  • EX:设置过期时间

释放锁时使用 Lua 脚本保证原子性。


下一步:阅读 06-微服务架构.md 了解微服务