电商项目_性能优化_高并发缓存一致性

如果说要对你的项目进行优化,最先想到的就是使用缓存。说到缓存,最先想到Redis, 可是缓存可不仅仅只有Redis。 缓存有哪些类型?如果保存缓存一致性?是本文要回答的问题。

缓存类型

客户端缓存

1.页面缓存

  • 页面自身对某些元素或全部元素进行缓存;
  • 服务端将静态页面或动态页面的元素进行缓存,然后给客户端使用。

当用户二次访问页面时可以避开网络连接,从而减少负载,提升性能和用户体验。

2.浏览器缓存

浏览器缓存是根据一套 与服务器约定的规则 进行工作的。比如:

  • 服务器侧设置 Expires的HTTP头来告诉客户端在重新请求文件之前缓存多久是安全的。
  • 在HTML页面的节点中加入meta标签,可以告诉浏览器当前页面不被缓存

浏览器会在硬盘上专门开辟一个空间来存储资源副本作为缓存。浏览器缓存相关的场景:

  • 在用户触发"后退"操作
  • 点击一个之前看过的链接
  • 访问系统中的同一张图片

3. APP上的缓存

网络缓存

网络中的缓存位于客户端和服务端之间,代理或响应客户端的网络请求,从而对重复的请求返回缓存中的数据资源。同时,接受服务端的请求,更新缓存中的内容。

  • 正向代理:为客户端提供代理服务,即服务器不知道真正的客户端是谁。
  • 反向代理:为服务器提供代理服务,即客户端不知道真正的服务器是谁
  • 透明代理:客户端根本不需要知道有代理服务器的存在。

正向与反向代理 两者区别:

  1. 位置和功能: 正向代理和反向代理都位于客户端和真实服务器之间,它们的主要功能都是将客户端的请求转发给服务器,然后再将服务器的响应转发给客户端。

  2. 提高访问速度: 两者都能通过缓存机制提高访问速度。当客户端请求某个资源时,如果代理服务器已经缓存了该资源,就可以直接从缓存中提供,而无需再次从原始服务器获取,从而节省了时间和带宽。

参考:

图文总结:正向代理与反向代理 - Hello-Brand - 博客园

服务端缓存

这个是后端开发人员最关注的部分。

1. 数据库缓存(InnoDB缓冲池)

在Mysql篇,分析过Mysql Server层的查询缓存用户不大,在Mysql8版本已经去掉。查询缓存,缓存的是SQL语句及结果,移除的原因是:

  • 缓存失效频繁
  • 为保证缓存和DB数据一致性,高并发场景下锁竞争严重
  • 内存管理复杂:缓存占用内存,但是管理效率低下

innodb_buffer_pool_sizeInnoDB 存储引擎的核心缓存配置 ,用于缓存 **表数据、索引、插入缓冲等,与查询缓存完全不同。**需要合理设置值,专用服务器:50%-80% 物理内存,混合部署:25%-50%,避免影响其他服务

  • 减少磁盘 I/O:频繁访问的表数据和索引会被缓存到内存,避免每次查询都访问磁盘

  • 提升查询性能:缓冲池命中率越高,SQL 执行速度越快(理想情况应 >95%)

  • 支持事务和并发:InnoDB 的 MVCC(多版本并发控制)依赖缓冲池管理数据版本

对比项 查询缓存(Query Cache) InnoDB 缓冲池(Buffer Pool)
缓存内容 存储 SQL 语句及其结果 缓存表数据、索引、插入缓冲等
失效机制 表数据修改即失效 LRU 算法管理,按页替换
适用场景 静态数据、极少更新 所有 InnoDB 表
并发影响 全局锁,高并发下性能差 细粒度锁,支持高并发
MySQL 8.0 已移除 核心组件,必须优化

2. 应用级缓存

在Java语言中,缓存框架更多,例如 Guava Cache(google提供的缓存)、Ehcache 、Caffeine等等。

  1. com.google.common.cache.CacheBuilder
java 复制代码
import com.google.common.cache.CacheBuilder;
import com.google.common.cache.CacheLoader;
import com.google.common.cache.LoadingCache;
   

public class DistrictProCacheImpl implements DistrictProCache{
 private LoadingCache<String, String> UPTODATE_ADDRESS =
            CacheBuilder.newBuilder().expireAfterWrite(7, TimeUnit.DAYS).build(new CacheLoader<String, String>() {
                @Override
                public String load(String key) {
                    return queryDistrictNameByCode(key);
                }
     });
}
  1. Ehcache
java 复制代码
import org.ehcache.Cache;
import org.ehcache.CacheManager;
import org.ehcache.config.builders.CacheConfigurationBuilder;
import org.ehcache.config.builders.CacheManagerBuilder;
import org.ehcache.config.builders.ResourcePoolsBuilder;

public class EhcacheExample {
    public static void main(String[] args) {
        // 1. 创建缓存管理器
        CacheManager cacheManager = CacheManagerBuilder.newCacheManagerBuilder().build();
        cacheManager.init();
        
        // 2. 创建缓存
        Cache<String, String> myCache = cacheManager.createCache("myCache",
            CacheConfigurationBuilder.newCacheConfigurationBuilder(
                String.class, String.class,
                ResourcePoolsBuilder.heap(100) // 堆内存中存储100个条目
            ));
        
        // 3. 使用缓存
        myCache.put("key1", "value1");
        String value = myCache.get("key1");
        System.out.println("从缓存中获取的值: " + value);
        
        // 4. 关闭缓存管理器
        cacheManager.close();
    }
}
  1. Caffeine(有资料表明Caffeine性能是Guava Cache的6倍)
java 复制代码
Cache<String, Object> cache = Caffeine.newBuilder()
    .expireAfterWrite(10, TimeUnit.MINUTES)
    .maximumSize(1000)
    .build();

cache.put("key1", "value1");
Object value = cache.getIfPresent("key1");

3.平台级缓存

Redis、 MongoDB、 Memcached都可以作为平台级缓存的重要技术。

Redis、Memcached用的更多一些,MongoDB更多的时候是做为持久化的NoSQL数据库来说使用的。

如何保证缓存数据一致性

缓存一致性指的是有修改数据时,在同一时刻,如何保证缓存和DB的一致性的问题。本地缓存和Redis缓存都存在一致性的问题。

在不使用分布式锁、分布式事务的情况下,分析缓存一致性的几种方案:

1、先更新缓存,再更新数据库

一般不考虑。原因是更新缓存成功,更新数据库出现异常了,因为缓存中数据一直都在,不易察觉。

即使有本地事务,也无法回滚缓存。

2、先更新数据库,再更新缓存

同上。

说明:如果更新缓存失败,虽然可以使用本地事务回滚数据库,保持数据的最终一致性。但是在正常操作情况下,数据不一致的问题仍然存在。

3、先删除缓存,后更新数据库

  1. 线程1:删缓存 -》 写主库
  2. 线程2:读缓存 -》 读从库
  3. 数据库:写主库 -》 同步从库

这里的操作不是原子性的, 所以一定会存在问题。

数据不一致场景: 主从库同步数据的间隙, 读线程在从库中读到了修改前的数据

解决方案:

  1. 延时双删, 写线程写完主库,延迟几百ms(主从同步时间),在删除下缓存。也可以异步线程做延迟删缓存。
  2. 缓存不存在时,直接读主库。

4、先更新DB,后删除缓存

  1. 线程1:写主库 -》 删缓存
  2. 线程2:读缓存 -》 读从库
  3. 数据库:写主库 -》 同步从库

这里的操作不是原子性的, 所以一定会存在问题。

数据不一致场景1:在线程1写主库和删缓存的中间,线程2读到了旧缓存值,和主库不一致

数据不一致场景2:缓存失败,线程2在从库读到数据;线程1更新了DB并删除了缓存; 线程1将读到的数据写入缓存,出现缓存和数据库不一致。

解决方案:

  1. 延时删除
  2. 使用消息队列,通过消息再次进行删除缓存,保证缓存一定可以删除成功。 缺点是引入了消息中间件,对代码的侵入性较大,另外要考虑消息的可靠性。
  3. 订阅 Mysql 数据库的 binlog 日志对缓存进行操作,利用工具(canal)将binlog日志采集发送到MQ中,然后通过ACK机制确认处理删除缓存。

缓存更新模式

Cache aside:先写DB后删缓存

Read through/Write through:

Cache Aside套路中,我们的应用代码需要维护两个数据存储,一个是缓存(Cache),一个是数据库(Repository),而Read/Write Through套路是把更新数据库(Repository)的操作由缓存自己代理了,所以,对于应用层,可以理解为只操作一个单一存储。

  • Read through:在查询操作中,如果缓存未查到则更新缓存。
  • Write through:在更新操作中,如果没有命中缓存,直接更新数据库,然后返回。如果命中了缓存,则更新缓存,然后再由Cache自己更新数据库。

Write behind caching(write back):在更新数据的时候,只更新缓存,不更新数据库。缓存会异步地批量更新数据库。

电商平台缓存应用

缓存应用

  1. 首页促销产品, 放到redis

  2. 促销产品,放到内存级缓存

缓存预热

首页服务如果出现重启会导致缓存中的数据丢失,导致查询请求直接访问数据库,所以针对缓存要有专门的缓存预热机制。

java 复制代码
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.CommandLineRunner;
import org.springframework.stereotype.Component;

@Component
public class PreheatCache implements CommandLineRunner {

    @Autowired
    private HomeService homeService;

    @Override
    public void run(String... args) throws Exception {
        for(String str : args) {
            log.info("系统启动命令行参数: {}",str);
        }
        homeService.preheatCache();
    }

}

数据一致性

  1. 缓存过期时间

  2. 定时任务刷新缓存

  3. Mysql数据库,利用Canal检测数据库的更新,然后删除缓存中对应部分。

缓存过期引起毛刺现象

java 复制代码
    private Cache<String, HomeContentResult> promotionCache;
    private Cache<String, HomeContentResult> promotionCacheBak;

    /*先从本地缓存中获取推荐内容*/
        HomeContentResult result = allowLocalCache ?
                promotionCache.getIfPresent(brandKey) : null;
        if(result == null){
            result = allowLocalCache ?
                    promotionCacheBak.getIfPresent(brandKey) : null;
    }

对促销数据,使用双缓存。两个缓存使用了不同的策略:

  • 正式缓存是最后一次写入后经过固定时间过期
  • 备份缓存是设置最后一次访问后经过固定时间过期。意味着备份缓存中内容不管是读写后,实际过期时间都会后延。

在本地缓存的异步刷新机制上:

  • 正式缓存只有无效才会被重新写入,保证对客户端的稳定响应。
  • 备份缓存无论是否无效都会重新写入,一则可以保证数据不至于真的永久无法过期而太旧,二则使过期时间不管用户是否访问首页都可以不断后延。

手动干预缓存

后端的一个兜底操作, 可以手动刷新缓存