最近有朋友问我,博客访问速度慢怎么办?我第一反应就是:上缓存啊!作为一个天天跟服务器打交道的人,我深知缓存对网站性能的重要性。今天就来分享一下我最近给自己的Typecho博客写的一个Redis缓存插件的经验。

说实话,网上关于Typecho插件开发的资料真的不多,特别是涉及到Redis这种高级功能的。我也是摸着石头过河,踩了不少坑才搞出来这个插件。不过好在最终效果还不错,页面加载速度提升了好几倍呢。

为什么选择Redis做缓存

可能有人会问,为什么不用文件缓存或者Memcached?我来说说我的想法。

文件缓存确实简单,但是在高并发情况下,磁盘IO会成为瓶颈。而且清理缓存也比较麻烦,你得去遍历文件夹删除文件。

Memcached虽然也不错,但是Redis功能更强大啊!不仅可以做缓存,还能做消息队列、计数器什么的。而且Redis的数据结构更丰富,扩展性更好。

最重要的是,Redis现在基本上是标配了,大部分VPS都能很方便地安装。我自己的服务器上早就跑着Redis,用来做各种缓存和数据存储。

插件的整体设计思路

在开始写代码之前,我先理了理思路。一个缓存插件需要做什么事情呢?

无非就是这几个步骤:用户访问页面时,先检查Redis里有没有缓存的内容;如果有,直接返回;如果没有,正常生成页面,然后把内容存到Redis里。当内容更新时,要记得清除相关缓存。

听起来很简单对吧?但是实际写起来,细节问题可多了。比如什么时候该缓存,什么时候不该缓存?缓存的键怎么设计?过期时间怎么设置?这些都需要仔细考虑。

核心代码解析

让我来详细说说这个插件的实现。

Redis连接初始化

public static function initRedis()
{
    if (self::$redis !== null) {
        return self::$redis;
    }
    
    $options = Helper::options();
    $config = $options->plugin('RedisCache');
    
    // 如果禁用缓存,直接返回
    if (isset($config->enableCache) && $config->enableCache == '0') {
        return null;
    }

这里我做了个单例模式,避免重复连接Redis。还加了个开关,可以随时禁用缓存功能。这个设计在调试的时候特别有用。

连接Redis的时候,我加了很多错误处理:

try {
    // 检查Redis扩展是否加载
    if (!extension_loaded('redis')) {
        throw new Exception('PHP Redis扩展未安装');
    }
    
    // 尝试连接Redis
    $redis = new Redis();
    $connected = $redis->connect($config->host, $config->port, 2); // 2秒超时
    
    if (!$connected) {
        throw new Exception('无法连接到Redis服务器');
    }

这里设置了2秒的连接超时,避免Redis服务器出问题时网站卡死。我之前就遇到过Redis服务器挂了,结果整个网站都访问不了的情况。

缓存读取逻辑

public static function beforeRender($archive)
{
    // 管理员登录时不使用缓存
    if (Typecho_Widget::widget('Widget_User')->hasLogin()) {
        return;
    }
    
    // 初始化Redis
    $redis = self::initRedis();
    if (!$redis) {
        return;
    }
    
    // 获取当前请求的唯一标识
    $requestUri = $_SERVER['REQUEST_URI'];
    $cacheKey = self::$prefix . 'page:' . md5($requestUri);
    
    // 尝试从缓存获取内容
    $cachedContent = $redis->get($cacheKey);
    
    if ($cachedContent !== false) {
        // 缓存命中,输出内容并结束执行
        echo $cachedContent;
        exit;
    }
    
    // 缓存未命中,开始输出缓冲
    ob_start();
}

这段代码是整个插件的核心。我用REQUEST_URI的MD5值作为缓存键,这样可以确保每个页面都有唯一的缓存标识。

注意这里有个细节:管理员登录时不使用缓存。这是因为管理员看到的页面可能包含一些特殊内容,比如编辑链接什么的,这些不应该被缓存。

缓存写入逻辑

public static function afterRender()
{
    // 管理员登录时不缓存
    if (Typecho_Widget::widget('Widget_User')->hasLogin()) {
        return;
    }
    
    // 初始化Redis
    $redis = self::initRedis();
    if (!$redis) {
        return;
    }
    
    // 获取输出内容
    $content = ob_get_contents();
    
    // 获取当前请求的唯一标识
    $requestUri = $_SERVER['REQUEST_URI'];
    $cacheKey = self::$prefix . 'page:' . md5($requestUri);
    
    // 将内容写入缓存
    $redis->setex($cacheKey, self::$expire, $content);
}

这里用了PHP的输出缓冲机制。在beforeRender里开启缓冲,在afterRender里获取缓冲内容并写入Redis。这样就能完整地缓存整个页面的HTML内容了。

配置面板的设计

一个好用的插件必须要有友好的配置界面。我设计了这些配置项:

public static function config(Typecho_Widget_Helper_Form $form)
{
    $host = new Typecho_Widget_Helper_Form_Element_Text(
        'host', 
        null, 
        '127.0.0.1', 
        _t('Redis主机地址'), 
        _t('输入Redis服务器的主机地址,默认为127.0.0.1')
    );
    $form->addInput($host);
    
    $port = new Typecho_Widget_Helper_Form_Element_Text(
        'port', 
        null, 
        '6379', 
        _t('Redis端口'), 
        _t('输入Redis服务器的端口,默认为6379')
    );
    $form->addInput($port);

基本的Redis连接参数肯定要有。还有缓存过期时间、键前缀这些高级选项。特别是键前缀,如果你的Redis服务器上跑着多个应用,这个就很重要了。

我还加了个调试模式开关:

$debug = new Typecho_Widget_Helper_Form_Element_Radio(
    'debug',
    array('1' => _t('启用'), '0' => _t('禁用')),
    '1',
    _t('调试模式'),
    _t('启用调试模式会记录更详细的日志信息')
);
$form->addInput($debug);

调试模式会记录详细的日志,包括缓存命中、缓存写入、缓存清除等操作。这对于排查问题特别有用。

缓存清除机制

缓存虽然能提升性能,但是也带来了数据一致性的问题。当文章或页面更新时,必须及时清除相关缓存,否则用户看到的还是旧内容。

public static function clearCache($content, $widget)
{
    // 初始化Redis
    $redis = self::initRedis();
    if (!$redis) {
        return $content;
    }
    
    // 获取所有缓存键
    $pattern = self::$prefix . 'page:*';
    $keys = $redis->keys($pattern);
    
    // 删除所有匹配的缓存
    if (!empty($keys)) {
        $redis->del($keys);
    }
    
    return $content;
}

这里我采用了比较简单粗暴的方式:一旦有内容更新,就清除所有页面缓存。虽然不够精细,但是简单可靠。

更精细的做法是只清除相关页面的缓存,比如文章页面、分类页面、标签页面等。但是这样实现起来会复杂很多,而且容易出错。对于大多数个人博客来说,全部清除的方式已经够用了。

日志记录功能

为了方便调试和监控,我加了详细的日志记录功能:

// 创建日志目录
$logDir = __TYPECHO_ROOT_DIR__ . '/usr/plugins/RedisCache/logs';
if (!is_dir($logDir)) {
    mkdir($logDir, 0755, true);
}

$logFile = $logDir . '/redis-' . date('Y-m-d') . '.log';

日志按天分割,记录Redis连接状态、缓存命中情况等信息。这样出问题时可以快速定位原因。

我还在缓存的HTML里加了个注释,显示缓存生成时间和剩余时间:

$cachedContent .= "\n<!-- 页面来自Redis缓存,生成于: " . date('Y-m-d H:i:s', time() - ($redis->ttl($cacheKey))) . ",剩余时间: " . $redis->ttl($cacheKey) . "秒 -->";

这样在浏览器里查看源码就能知道页面是否来自缓存了。

实际使用效果

插件写好后,我在自己的博客上测试了一下效果。没开缓存之前,首页加载时间大概是800ms左右;开启缓存后,降到了100ms以内!效果还是很明显的。

当然,这个提升幅度跟你的服务器配置、数据库性能等因素都有关系。如果你的数据库查询本来就很快,缓存的效果可能就没那么明显。

我还用Redis的监控命令看了看缓存的命中率,基本上能达到90%以上。这说明大部分访问都是重复的,缓存确实起到了作用。

需要注意的问题

在实际使用过程中,我也发现了一些需要注意的问题。

内存使用量是个需要考虑的因素。每个页面的HTML内容可能有几十KB,如果页面很多,占用的Redis内存也不少。不过现在内存都比较便宜,这个问题不算太严重。

另外就是缓存穿透的问题。如果有人恶意访问大量不存在的页面,会导致缓存无效,给数据库造成压力。不过Typecho本身有404处理机制,这个问题也不算严重。

还有就是Redis服务器的稳定性。如果Redis挂了,插件会自动降级到不使用缓存,不会影响网站正常访问。但是性能肯定会下降。所以Redis服务器的监控和备份还是很重要的。

后续优化方向

这个插件目前功能还比较基础,后续可以考虑一些优化:

比如可以加个缓存预热功能,在内容更新后自动生成新的缓存,而不是等用户访问时再生成。

还可以加个缓存统计功能,显示命中率、内存使用量等信息,方便监控缓存效果。

对于大型网站,还可以考虑分级缓存,把热点内容缓存更长时间,冷门内容缓存时间短一些。

完整源码

在typecho项目/usr/plugins/路径下,新建RedisCache文件夹,在RedisCache文件夹里创建Plugin.php文件。将下面代码复制进去。给上对应的权限。

<?php
/**
 * Redis 缓存插件 - 将Typecho内容缓存到Redis
 * 
 * @package RedisCache
 * @author 悠悠
 * @version 1.0.0
 * @link https://www.fuzhoupyy.work/
 */
class RedisCache_Plugin implements Typecho_Plugin_Interface
{
    /**
     * Redis实例
     */
    private static $redis = null;
    
    /**
     * 缓存前缀
     */
    private static $prefix = 'typecho_cache:';
    
    /**
     * 缓存过期时间(秒)
     */
    private static $expire = 3600; // 默认1小时
    
    /**
     * 激活插件方法,如果激活失败,直接抛出异常
     */
    public static function activate()
    {
        // 初始化Redis连接
        Typecho_Plugin::factory('index.php')->begin = array('RedisCache_Plugin', 'initRedis');
        
        // 在内容渲染前尝试从缓存获取
        Typecho_Plugin::factory('Widget_Archive')->beforeRender = array('RedisCache_Plugin', 'beforeRender');
        
        // 在内容渲染后缓存内容
        Typecho_Plugin::factory('Widget_Archive')->afterRender = array('RedisCache_Plugin', 'afterRender');
        
        // 当内容更新时清除缓存
        Typecho_Plugin::factory('Widget_Contents_Post_Edit')->finishPublish = array('RedisCache_Plugin', 'clearCache');
        Typecho_Plugin::factory('Widget_Contents_Page_Edit')->finishPublish = array('RedisCache_Plugin', 'clearCache');
        
        // 当评论更新时清除缓存
        Typecho_Plugin::factory('Widget_Feedback')->finishComment = array('RedisCache_Plugin', 'clearCache');
        
        return _t('Redis缓存插件已启用');
    }
    
    /**
     * 禁用插件方法,如果禁用失败,直接抛出异常
     */
    public static function deactivate()
    {
        Helper::removePanel(1, 'RedisCache/manage-cache.php');
        return _t('Redis缓存插件已禁用');
    }
    
    /**
     * 获取插件配置面板
     */
    public static function config(Typecho_Widget_Helper_Form $form)
    {
        $host = new Typecho_Widget_Helper_Form_Element_Text(
            'host', 
            null, 
            '127.0.0.1', 
            _t('Redis主机地址'), 
            _t('输入Redis服务器的主机地址,默认为127.0.0.1')
        );
        $form->addInput($host);
        
        $port = new Typecho_Widget_Helper_Form_Element_Text(
            'port', 
            null, 
            '6379', 
            _t('Redis端口'), 
            _t('输入Redis服务器的端口,默认为6379')
        );
        $form->addInput($port);
        
        $password = new Typecho_Widget_Helper_Form_Element_Password(
            'password', 
            null, 
            '', 
            _t('Redis密码'), 
            _t('如果Redis设置了密码,请在此输入')
        );
        $form->addInput($password);
        
        $expire = new Typecho_Widget_Helper_Form_Element_Text(
            'expire', 
            null, 
            '3600', 
            _t('缓存过期时间(秒)'), 
            _t('缓存过期时间,默认为3600秒(1小时)')
        );
        $form->addInput($expire);
        
        $prefix = new Typecho_Widget_Helper_Form_Element_Text(
            'prefix', 
            null, 
            'typecho_cache:', 
            _t('缓存键前缀'), 
            _t('Redis缓存键的前缀,用于区分不同应用的缓存')
        );
        $form->addInput($prefix);
        
        $enableCache = new Typecho_Widget_Helper_Form_Element_Radio(
            'enableCache',
            array('1' => _t('启用'), '0' => _t('禁用')),
            '1',
            _t('是否启用缓存'),
            _t('选择是否启用Redis缓存功能')
        );
        $form->addInput($enableCache);
        
        $debug = new Typecho_Widget_Helper_Form_Element_Radio(
            'debug',
            array('1' => _t('启用'), '0' => _t('禁用')),
            '1',
            _t('调试模式'),
            _t('启用调试模式会记录更详细的日志信息')
        );
        $form->addInput($debug);
    }
    
    /**
     * 个人用户的配置面板
     */
    public static function personalConfig(Typecho_Widget_Helper_Form $form)
    {
    }
    
    /**
     * 初始化Redis连接
     */
    public static function initRedis()
    {
        if (self::$redis !== null) {
            return self::$redis;
        }
        
        $options = Helper::options();
        $config = $options->plugin('RedisCache');
        
        // 如果禁用缓存,直接返回
        if (isset($config->enableCache) && $config->enableCache == '0') {
            return null;
        }
        
        // 设置缓存参数
        if (isset($config->expire)) {
            self::$expire = intval($config->expire);
        }
        
        if (isset($config->prefix)) {
            self::$prefix = $config->prefix;
        }
        
        // 创建日志目录
        $logDir = __TYPECHO_ROOT_DIR__ . '/usr/plugins/RedisCache/logs';
        if (!is_dir($logDir)) {
            mkdir($logDir, 0755, true);
        }
        
        $logFile = $logDir . '/redis-' . date('Y-m-d') . '.log';
        
        try {
            // 检查Redis扩展是否加载
            if (!extension_loaded('redis')) {
                throw new Exception('PHP Redis扩展未安装');
            }
            
            // 尝试连接Redis
            $redis = new Redis();
            $connected = $redis->connect($config->host, $config->port, 2); // 2秒超时
            
            if (!$connected) {
                throw new Exception('无法连接到Redis服务器');
            }
            
            // 如果设置了密码,进行验证
            if (!empty($config->password)) {
                $authResult = $redis->auth($config->password);
                if (!$authResult) {
                    throw new Exception('Redis认证失败');
                }
            }
            
            // 检查连接
            $pong = $redis->ping();
            if ($pong !== '+PONG' && $pong !== true) {
                throw new Exception('Redis ping失败');
            }
            
            $logMessage = date('[Y-m-d H:i:s]') . " Redis连接成功: " . $config->host . ":" . $config->port;
            
            // 写入测试数据
            $testKey = self::$prefix . 'test';
            $testValue = 'Hello Typecho! ' . date('Y-m-d H:i:s');
            $redis->set($testKey, $testValue);
            $retrievedValue = $redis->get($testKey);
            
            if ($retrievedValue !== $testValue) {
                throw new Exception('Redis测试数据写入失败');
            }
            
            $logMessage .= "\n" . date('[Y-m-d H:i:s]') . " 测试数据写入成功: " . $retrievedValue;
            
            // 写入日志
            file_put_contents($logFile, $logMessage . "\n", FILE_APPEND);
            
            self::$redis = $redis;
            return $redis;
            
        } catch (Exception $e) {
            // 连接失败记录日志,但不影响系统运行
            $errorMessage = date('[Y-m-d H:i:s]') . " Redis连接失败: " . $e->getMessage();
            file_put_contents($logFile, $errorMessage . "\n", FILE_APPEND);
            return null;
        }
    }
    
    /**
     * 在渲染前检查缓存
     * 
     * @param Widget_Archive $archive
     * @return void
     */
    public static function beforeRender($archive)
    {
        // 管理员登录时不使用缓存
        if (Typecho_Widget::widget('Widget_User')->hasLogin()) {
            return;
        }
        
        // 初始化Redis
        $redis = self::initRedis();
        if (!$redis) {
            return;
        }
        
        // 获取当前请求的唯一标识
        $requestUri = $_SERVER['REQUEST_URI'];
        $cacheKey = self::$prefix . 'page:' . md5($requestUri);
        
        // 尝试从缓存获取内容
        $cachedContent = $redis->get($cacheKey);
        
        if ($cachedContent !== false) {
            // 缓存命中,输出内容并结束执行
            $options = Helper::options();
            $config = $options->plugin('RedisCache');
            
            if (isset($config->debug) && $config->debug == '1') {
                $logFile = __TYPECHO_ROOT_DIR__ . '/usr/plugins/RedisCache/logs/cache-' . date('Y-m-d') . '.log';
                $logMessage = date('[Y-m-d H:i:s]') . " 缓存命中: " . $requestUri . " (键: " . $cacheKey . ")";
                file_put_contents($logFile, $logMessage . "\n", FILE_APPEND);
            }
            
                        // 添加缓存标记
            $cachedContent .= "\n<!-- 页面来自Redis缓存,生成于: " . date('Y-m-d H:i:s', time() - ($redis->ttl($cacheKey))) . ",剩余时间: " . $redis->ttl($cacheKey) . "秒 -->";
            
            echo $cachedContent;
            exit;
        }
        
        // 缓存未命中,开始输出缓冲
        ob_start();
    }
    
    /**
     * 在渲染后保存缓存
     * 
     * @return void
     */
    public static function afterRender()
    {
        // 管理员登录时不缓存
        if (Typecho_Widget::widget('Widget_User')->hasLogin()) {
            return;
        }
        
        // 初始化Redis
        $redis = self::initRedis();
        if (!$redis) {
            return;
        }
        
        // 获取输出内容
        $content = ob_get_contents();
        
        // 获取当前请求的唯一标识
        $requestUri = $_SERVER['REQUEST_URI'];
        $cacheKey = self::$prefix . 'page:' . md5($requestUri);
        
        // 将内容写入缓存
        $redis->setex($cacheKey, self::$expire, $content);
        
        $options = Helper::options();
        $config = $options->plugin('RedisCache');
        
        if (isset($config->debug) && $config->debug == '1') {
            $logFile = __TYPECHO_ROOT_DIR__ . '/usr/plugins/RedisCache/logs/cache-' . date('Y-m-d') . '.log';
            $logMessage = date('[Y-m-d H:i:s]') . " 缓存写入: " . $requestUri . " (键: " . $cacheKey . ")";
            file_put_contents($logFile, $logMessage . "\n", FILE_APPEND);
        }
    }
    
    /**
     * 清除缓存
     * 
     * @param mixed $content 内容
     * @param mixed $widget 组件
     * @return mixed
     */
    public static function clearCache($content, $widget)
    {
        // 初始化Redis
        $redis = self::initRedis();
        if (!$redis) {
            return $content;
        }
        
        // 获取所有缓存键
        $pattern = self::$prefix . 'page:*';
        $keys = $redis->keys($pattern);
        
        // 删除所有匹配的缓存
        if (!empty($keys)) {
            $redis->del($keys);
            
            $options = Helper::options();
            $config = $options->plugin('RedisCache');
            
            if (isset($config->debug) && $config->debug == '1') {
                $logFile = __TYPECHO_ROOT_DIR__ . '/usr/plugins/RedisCache/logs/cache-' . date('Y-m-d') . '.log';
                $logMessage = date('[Y-m-d H:i:s]') . " 缓存已清除: " . count($keys) . " 个页面";
                file_put_contents($logFile, $logMessage . "\n", FILE_APPEND);
            }
        }
        
        return $content;
    }
    /**
 * 获取缓存前缀
 * 
 * @return string
 */
 public static function getPrefix()
 {
     return self::$prefix;
 }
}

在控制台启用插件:

image-20250805202634912

设置插件连接信息

image-20250805202653861

首次连接会有写入测试,多点几篇文章就有写入记录了!

image-20250805202958633

要是有故障可以在插件目录下查看日志:

/usr/plugins/RedisCache/log

image-20250805203146821

总结

写这个Redis缓存插件的过程让我对Typecho的架构有了更深入的了解。Typecho的插件机制还是很灵活的,通过钩子函数可以在各个关键节点插入自定义逻辑。

Redis作为缓存方案确实很不错,性能高、功能强大、使用简单。对于个人博客来说,部署一个Redis服务器的成本也不高,但是带来的性能提升却很明显。

当然,缓存不是万能的。网站性能优化是个系统工程,需要从前端优化、数据库优化、服务器配置等多个方面入手。但是缓存确实是其中最有效的手段之一。

如果你也在用Typecho,不妨试试这个插件。代码我已经放在上面了,直接复制保存为PHP文件,放到插件目录就能用。记得先安装Redis服务器和PHP的Redis扩展哦。


如果这篇文章对你有帮助,别忘了点赞转发支持一下!想了解更多运维实战经验和技术干货,记得关注微信公众号@运维躬行录,领取学习大礼包!!!我会持续分享更多接地气的运维知识和踩坑经验。让我们一起在运维这条路上互相学习,共同进步!

公众号:运维躬行录

个人博客:躬行笔记

标签: none