引言
现代大规模 Web 应用对延迟有着极为严格的要求。更高的延迟会带来糟糕的用户体验。许多网站会在特定的延迟指标上投入大量时间和金钱进行优化,例如“登录时间小于 2 秒”“首页加载小于 2 秒”“其他页面加载小于 5 秒”等。
为了实现这些低延迟,最常用的设计模式就是数据缓存。
在上一章里,我们从高层次探讨了数据缓存的用例。被缓存的数据通常放在用于提供服务的计算节点附近。把数据放在更近的位置成本更高,因此不是所有数据都会被缓存。本章将深入讨论如何识别需要缓存的“热点数据”,以满足特定用例。
在不同位置保留数据副本会引入一致性问题。数据变更在多个副本之间传播的延迟会导致陈旧数据。本章将介绍用于避免返回陈旧数据的设计模式。
近几年,移动设备深度普及,大多数网络服务都会提供移动 App 作为与用户保持互动的重要入口。由于用户随身携带手机,通过移动端使用服务已经融入日常。然而,手机网络通常更慢且更不稳定,这会导致更高的延迟。为解决此问题,数据工程师可以选择在移动设备本地缓存数据,从而在执行低延迟任务时尽量绕过慢且不稳定的网络连接。
本章将从先前的旅行聚合器案例切换到一个新的用例:在线电影数据库。
结构
本章涵盖以下主题:
- 在线电影数据库
- 填充缓存
- 使用本地 Memcached 进行缓存
- 缓存质量与淘汰策略
- 缓存陈旧、失效与过期
- 预处理数据的缓存
- 预取(Prefetch)
- 在笔记本与移动设备上缓存
目标
读完本章,你将理解多种数据缓存用例。你将了解服务器端缓存如何比从持久化云存储(如 AWS S3)读取更快地提供数据;同时掌握识别陈旧数据及其失效方法的解决方案。
你还将理解数据预处理的用例,以及如何通过缓存预处理结果来降低延迟。接着,你会学习数据预取的用例与实现思路。
最后,你还会了解在用户设备(如笔记本与手机)上缓存数据的用例,以显著降低数据访问延迟。
在线电影数据库
我们来讨论一个假想的在线电影数据库系统。总体上,它提供如下功能与流程,其中一些将用于展开缓存用例与对应的数据工程模式:
-
维护电影、电视剧、短片及其他媒资的数据库。
-
允许用户在网站上搜索电影、电视剧等。
-
允许用户注册并使用如下功能:
- 创建观影清单
- 发表影评
- 电影打分
- 获取影片推荐
-
为用户维护个性化首页。
-
存储用户偏好并推荐合适的影片。
-
根据用户喜好展示新片预告。
为更好理解缓存用例,我们先从用户登录开始,再讨论加载用户首页相关的用例。
用户登录是进入任何网站的第一步。登录缓慢会降低用户兴趣,甚至直接流失。因此必须提供非常迅速的登录体验。登录过程中有一步是用户密码认证。
用户认证服务
许多大规模 Web 应用会实现一个用户认证服务,负责基于密码的用户认证。为校验密码,服务需要将用户请求中携带的加密密码与认证数据库中存储的加密密码比对。
一个现代互联网级应用通常采用可自动扩缩容的无状态服务来提供数据。为便于讨论,假设该服务既负责用户登录,也负责加载首页。加载首页时,服务需要拉取首页相关数据。对在线电影数据库而言,典型首页包含三块:
- “新片速递” :展示新上映电影的海报(及链接)。
- “个性化推荐” :展示为登录用户推荐的电影海报(及链接)。
- “再看一次” :展示用户已看过但可能想重温的电影海报(及链接)。
每个区域仅展示固定数量的海报,例如每区 10 张。也就是说,用户登录后,页面需加载共 30 张电影海报。
加载大量海报(图片内容)在时间与数据访问/传输费用方面都很昂贵。为减少加载时长与频繁访问数据的成本,可以使用数据缓存设计模式。在本例中我们将缓存:
- 用户认证信息;
- 用户首页数据,即首页上要展示的电影海报。
无状态服务的实现有助于采用只读缓存:如果服务不直接修改缓存内容,就可以维持只读缓存。若同时运行多个无状态服务实例,它们可以各自维护各自的只读缓存实例,而无需担心实例间的一致性问题。
既然已确认需要缓存哪些数据,接下来我们将探讨如何填充缓存。
填充缓存
在了解如何填充缓存之前,先看一下在线电影数据库应用的架构。
图 14.1 展示了典型的在线电影数据库应用架构:
图 14.1:电影数据库应用架构
从上图可以看出:
- 用户界面运行在用户笔记本上的网页浏览器中。
- 浏览器连接到数据服务节点,该节点负责处理各种用户请求。
- 数据服务节点与云端的用户认证数据库通信。为执行并校验用户认证,数据服务节点必须从认证数据库取数,这会带来高延迟和访问成本。
- 电影海报存放在 AWS S3 上。从 S3 获取海报同样存在高延迟与数据访问成本。
- 访问认证信息与电影海报的成本会随着访问频率增长。为降低这类成本,可以在数据服务节点本地做缓存,把认证信息和电影海报缓存在节点上。
使用本地 Memcached 进行缓存
一种常见的缓存软件是 Memcached,它免费开源。顾名思义,Memcached 将缓存数据存放在内存中,从而实现快速访问。你可以在数据服务节点本地启动一个 Memcached 实例用于缓存。Memcached 提供类似键值存储的接口,用户可基于唯一键来存/取/删二进制、结构化或非结构化数据。
下面的代码是一个 PySpark 程序示例,用于从 MySQL 服务器加载用户认证信息到 Memcached 缓存中:
from pyspark.sql import SparkSession
from pymemcache.client.base import Client
# Initialize Spark session
spark = SparkSession.builder \
.appName("MySQLToMemcached") \
.config("spark.jars", "/path/to/mysql-connector-java.jar") \
.getOrCreate()
# MySQL Database connection properties
jdbc_url = "jdbc:mysql://your-mysql-host:3306/your_database"
db_table = "your_table"
db_properties = {
"user": "your_username",
"password": "your_password",
"driver": "com.mysql.cj.jdbc.Driver"
}
# Read data from MySQL
df = spark.read.format("jdbc").options(
url=jdbc_url,
dbtable=db_table,
**db_properties
).load()
# Initialize Memcached client
memcached_client = Client(('localhost', <memcached_port>))
def cache_to_memcached(row):
key = f"user:{row.id}"
value = row.password
memcached_client.set(key, value)
# Apply caching function to each row
df.foreach(cache_to_memcached)
从缓存读取
一旦数据被缓存在本地 Memcached,对该数据的频繁访问成本就会降到极低。不过,即便某对象曾被加载进缓存,也不能保证它一定仍在缓存中——因为像 Memcached 这样的缓存会实施淘汰机制,为新数据腾出空间(后文会详细介绍淘汰策略)。
因此,读取数据的程序需要:
- 先检查对象是否存在于缓存(本地 Memcached)中;
- 若在缓存中,直接返回缓存结果;
- 若不在缓存中,则从 AWS S3 获取并返回。
下面的 Python 代码演示了上述逻辑:
import boto3
from pymemcache.client.base import Client
memcached_client = Client(('localhost', <memcached_port>))
S3_BUCKET = "your-bucket-name"
def get_object(key):
# Check if object exists in Memcached
cached_data = memcached_client.get(key)
if cached_data:
return cached_data
# Fetch object from S3
s3_client = boto3.client('s3')
s3_response = s3_client.get_object(Bucket=S3_BUCKET, Key=key)
data = s3_response['Body'].read()
return data
图 14.2:从缓存读取数据的流程
对象缓存通常有两种实现方式:内存缓存或基于磁盘的持久化缓存。这类缓存都对可缓存的数据量有上限。Memcached 属于内存缓存,其可存数据量受机器 RAM 容量限制。当缓存内存已满而仍需写入新数据时,必须将部分旧数据移除(淘汰) ,而用于确定应淘汰哪些数据的方法称为缓存淘汰策略。
数据缓存质量与缓存淘汰策略
缓存淘汰策略在定义数据缓存质量方面起着关键作用。实现数据缓存的目的,是在读取数据时尽可能从缓存中命中数据。读取时如果在缓存中找到了数据,称为缓存命中(cache hit) ;若未找到而必须从 AWS S3 读取,则称为缓存未命中(cache miss) 。缓存命中率是缓存命中次数与总缓存读取次数之比。命中率越高,缓存质量越好。
要确保更好的缓存质量,必须保证正确的数据对象被存入缓存,同时将不需要的数据对象移除以为新数据腾出空间。因此,缓存淘汰策略对缓存质量至关重要。
常见的缓存淘汰策略如下:
- 先进先出(FIFO) :当缓存已满时,按进入缓存的时间,最早进入的对象最先被淘汰。
- 最近最少使用(LRU) :为每个缓存对象维护“最近访问时间戳”。每次访问都会更新该时间戳;淘汰时优先移除“最近访问时间戳”最早的对象。
- 最不经常使用(LFU) :为每个缓存对象维护访问计数器。每次访问递增计数;淘汰时优先移除计数值最小的对象。
- 基于过期时间的淘汰:为每个对象设置过期时间;淘汰时只移除已过期的对象。
由于数据缓存能显著改善访问延迟,同时也会带来不小的成本。要最大化缓存收益,用户需要理解应用的数据访问模式,并选择与之匹配的淘汰策略。
Memcached 实现的是 LRU 淘汰策略,确保最近最少使用的对象优先被淘汰。该策略基于“被访问过的对象在不久将来更可能再次被访问”的经验法则。例如,一部新片爆火后,更多用户会访问其相关数据,因此与该电影相关的数据更可能保留在缓存中。
缓存陈旧、失效与过期
随着时间推移,缓存中的数据对象可能变得陈旧(stale) 。以用户首页的 “本周新片(What’s New)” 为例:每周都会有新片上映,上周的内容在一周后就不再相关。对应的电影海报存入缓存后,一周后也不再需要。
有时,AWS S3 中的数据会被删除或修改;如果其副本仍在缓存中,这些副本就是陈旧数据。陈旧数据需要**失效(invalidation)**并从缓存中移除。下面介绍失效与清理的设计模式。
缓存失效或清理
延续 “What’s New” 的例子,缓存数据可能每周变陈旧。因此可以实现一个每周任务来使相关缓存失效。在我们的示例中,我们缓存的是电影海报。上周海报已无用,周任务即可显式删除这些键对应的缓存对象。
Memcached 是键值存储:键通常是字符串,值可以是字节数组。用户可将复杂对象序列化为字节数组后再存入 Memcached。在本例中,我们假设电影海报本身就是字节数组,因此无需额外序列化。
与 AWS S3 不同,Memcached 不支持列出键的 List API(如按前缀列出)。因此,由用户负责记录(或另行存储)已缓存数据的键,便于后续访问与清理。
下面的 Python 代码片段演示了如何根据给定键列表删除缓存对象:
from pymemcache.client.base import Client
memcached_client = Client(('localhost', <memcached_port>))
def cleanup_cached_objects(cleanup_keys):
for key in cleanup_keys:
result = memcached_client.delete(key)
缓存过期(Expiry)
手写清理代码并定期执行会比较繁琐。为减少这类开销,Memcached 允许在写入时指定对象过期时间。
在我们的示例中,可将 “What’s New” 海报的过期时间设置为 7 天,到期后 Memcached 会自动删除这些对象。示例如下:
from pymemcache.client.base import Client
memcached_client = Client(('localhost', <memcached_port>))
memcached_client.set(key, value, expire=7*24*60*60)
注意:虽然设置过期时间能减轻清理负担,但在某些场景下,仍然有必要保留手动清理能力。
例如,若因用户误操作导致缓存数据被污染或损坏,依据应用的访问模式,等待自动过期清理可能需要较长时间。在此情况下,提供“后门”式的手动清理手段会非常有用。
预处理数据的缓存
为每位用户生成个性化推荐是一项复杂且资源密集的工作。因此,数据工程工作流会先进行预处理,为每个用户识别个性化推荐。此类推荐一经生成,便会存入 AWS S3 以备后续访问。在典型实践中,个性化推荐的重算频率为每周或每月一次。
每当用户加载首页时,都需要从 AWS S3 读取这些个性化推荐。在本例中,这些预先生成的个性化推荐就属于被缓存的数据。
此外,这类预计算的个性化数据还可以通过一种称为**预取(prefetching)**的数据工程模式,提前加载到运行在数据服务节点上的数据缓存中。下面来理解预取。
数据预取(Prefetching)
我们之前已经看过将用户密码信息加载到数据缓存中的用例。由于密码信息体量很小,可以在数据服务应用启动时直接加载进缓存,往往也能轻松放得下。然而,并非所有必需的数据都适合在应用启动阶段一次性装入缓存。比如,每个用户的个性化推荐都不相同:对于一千万用户、每人 10 部电影的推荐清单,总计可能有 1 亿条不同的电影海报。通常,在线电影数据库不会在启动时把这 1 亿张海报全部塞进缓存,因为:
- 把如此海量的数据加载进缓存会非常耗时,拖慢启动过程;
- 这些海报的总体积可能超出缓存容量。
因此,数据服务节点可以只缓存与当前连接到该节点的用户相关的个性化推荐海报。在这种情况下,仅有一个数据子集被缓存,而且是在按需加载的:当发生用户登录等操作时触发加载。这种由其他操作(如登录)触发、为了更好性能而进行的按需缓存加载,就叫预取。
注:在预取中,数据加载并非由显式的“取数请求”发起,而是由其它动作(如用户登录)触发,以获取更好的性能。
换句话说,预取是一种提前把“很快就会被访问”的特定数据装入缓存的技术。在我们的示例里,首页有两块与用户强相关的区域:
- 个性化推荐(Personalized Recommendations)
- 再看一次(Visit Again)
这两块区域的电影列表都对用户高度定制。因此,当用户登录时,即便浏览器尚未发起取数请求,也可以从 AWS S3 预取这些电影海报到服务端缓存中。
图 14.3 展示了预取发生时的事件顺序及其带来的好处:
图 14.3:预取的收益
从图中可见,事件流程如下:
-
用户向数据服务节点发送登录请求;
-
节点完成用户校验;
-
校验成功后,同时触发两件事:
- 触发用户相关数据的预取;
- 返回登录成功响应;
-
之后两件事并行进行:
- 用户数据开始被缓存;
- 用户浏览器收到登录响应并发起首页海报请求;由于缓存已开始预热,数据服务节点很可能直接在本地缓存中命中;
-
被缓存命中的数据直接从缓存返回,从而降低首页加载时延。
直到现在,我们主要讨论了在数据服务节点上缓存数据的好处。不过,数据也可以缓存到其他位置。数据越靠近最终消费者,服务就越快。许多网站会利用浏览器缓存来进一步加速。
在笔记本与移动设备上进行缓存
继续我们的首页用例:登录后,用户会继续浏览电影、影评及其他内容。在此期间,用户可能多次回到首页。每次回到首页,浏览器都需要加载对应的海报,并默认向服务端的数据服务节点发起请求。
为了避免在每次返回首页时都去服务端取海报,可以实现浏览器端缓存。
图 14.4:浏览器缓存的收益
从图中可见:
- 若数据已在浏览器缓存中,直接从本地缓存服务,避免因网络往返带来的延迟;
- 若浏览器缓存命不中,再把请求转发到服务端数据服务节点。
IndexedDB 是一种流行的、开源的浏览器端 NoSQL 数据库,适合在 Web 应用中缓存图片、文件等。
从概念上说,服务端本地缓存与浏览器缓存并无本质差异:两者在缓存填充、预取、陈旧性处理等理念与方法上是相通的;缓存质量同样以命中率来衡量。类似地,把缓存进一步前移到移动设备上,也能显著加快移动 App 的加载速度。
结论
本章以在线电影数据库为例,围绕个性化推荐、“本周新片”等现代功能,识别了需要低时延数据服务的多种场景。我们介绍了如何利用数据缓存来加速用户登录与个性化首页的加载;以 Memcached 为代表的键值缓存工具,通过 LRU 淘汰与按对象设置过期时间,让本地缓存实现更简单。结合 Python 代码,我们演示了如何填充与读取缓存。
随后,我们讨论了缓存淘汰的必要性与多种策略,以及在 Memcached 下进行失效与清理的方法;通过“登录即预取”的场景,介绍了预取的思想,并以命中率衡量缓存质量。
最后,我们探讨了将数据进一步前移、靠近消费者——比如在浏览器端利用 IndexedDB 缓存,从而进一步降低数据加载的时延;同样的理念也适用于移动端。下一章我们将转向非结构化数据(文本、图片、视频等)的搜索模式,介绍全文检索工具、向量化表示与向量检索的基础,以及 RAG 模式。