概述
本文是笔者的系列博文 《Bun技术评估》 中的第六篇。
本文的来由,是笔者在编写系列文章的过程中,突然发现,bun提供了对redis的原生支持。这也是一个令人比较感兴趣的特性。
Web应用的开发和部署,经过一个比较长时间的发展,都已经有了一个比较成熟的模式。简单而言,就是前后端分离+静态内容服务+API负载均衡+数据库+缓存+消息队列这些相对固定的组成模块。在这个框架中,通常选择Redis来支撑缓存这部分的应用需求,它也是这方面应用模式的事实标准。
因此,bun在内置实现对redis的支持,也是比较符合逻辑和其“All In One”开发体系的技术设定的。
本文讨论的相关技术细节,参考内容主要来自bun官方技术文档的相关章节和valkey的技术文档,相关链接如下:
基本应用
和SQL的实现方式类似,bun引入的redis支持的应用,也非常简单直接,例如下面的示例代码:
import { redis } from "bun";
// Set a key
await redis.set("greeting", "Hello from Bun!");
// Get a key
const greeting = await redis.get("greeting");
console.log(greeting); // "Hello from Bun!"
// Increment a counter
await redis.set("counter", 0);
await redis.incr("counter");
// Check if a key exists
const exists = await redis.exists("greeting");
// Delete a key
await redis.del("greeting");
// redis 环境配置信息 .env
REDIS_URL=redis://:password@192.168.9.192:6380
bun redis也可以直接使用.evn环境配置文件。配置好之后,使用时系统会自动连接redis服务器并进行操作,非常方便。当然,bun redis也支持客户端实例化,连接后使用的方式:
const client = new RedisClient("redis://username:password@localhost:6379");
// Called when successfully connected to Redis server
client.onconnect = () => {
console.log("Connected to Redis server");
};
// Called when disconnected from Redis server
client.onclose = error => {
console.error("Disconnected from Redis server:", error);
};
// Manually connect/disconnect
await client.connect();
client.close();
常见指令
这里正好借机复习和熟悉一下Redis的常见操作和指令:
- 基本键值操作
包括通过键,来设置内容、获取内容、删除内容和检查存在性。
// 设置内容
await redis.set("user:1:name", "Alice");
// 查询内容
const name = await redis.get("user:1:name");
// 删除内容
await redis.del("user:1:name");
// 检查存在性
const exists = await redis.exists("user:1:name");
- 设置超时
可以给redis对象设置过期时间,实现内容的自动管理,通常用于缓存和临时性的内容操作。
// 设置过期时间,单位是秒
await redis.set("session:123", "active");
await redis.expire("session:123", 3600); // expires in 1 hour
// 获取过期时间
const ttl = await redis.ttl("session:123");
- 数值操作
通常用于计数器,可以设置当前值,增加或者减少值。
// 数值操作(计数器)
await redis.set("counter", "0");
await redis.incr("counter");
await redis.decr("counter");
- Hash
redis其实没有对象结构,而是使用一种hash结构来模拟对象的操作和行为。
// 设置hash对象
await redis.hmset("user:123", [
"name",
"Alice",
"email",
"alice@example.com",
"active",
"true",
]);
// 获取对象内容 键+hash键
const userFields = await redis.hmget("user:123", ["name", "email"]);
console.log(userFields); // ["Alice", "alice@example.com"]
// hash对象中的值
await redis.hincrby("user:123", "visits", 1);
// 支持浮点型
await redis.hincrbyfloat("user:123", "score", 1.5);
- set集合
包括了常见的set集合操作,典型的应用场景就是标签管理。
// Add member to set
await redis.sadd("tags", "javascript");
// Remove member from set
await redis.srem("tags", "javascript");
// Check if member exists in set
const isMember = await redis.sismember("tags", "javascript");
// Get all members of a set
const allTags = await redis.smembers("tags");
// Get a random member
const randomTag = await redis.srandmember("tags");
// Pop (remove and return) a random member
const poppedTag = await redis.spop("tags");
原始指令
细心的读者会发现,前面例举的这些redis的操作,都是使用对象方法来实现的。就是说,这些redis的功能,必须要在bun redis中,有对应的方法实现。显然这是不够灵活的。其实,bun redis支持原始的redis指令,只要redis服务支持这个指令,就可以进行操作。相关的指令集合,可以在对应版本的redis操作手册上查询。
下面是一些简单的示例:
// Run any Redis command
const info = await redis.send("INFO", ["server"]);
// LPUSH to a list
await redis.send("LPUSH", ["mylist", "value1", "value2"]);
// Get list range
const list = await redis.send("LRANGE", ["mylist", "0", "-1"]);
可以看到,就是使用一个通用的send方法,第一个参数就是redis指令,后面是一个对象参数,就是这个指令所需要的参数(可能是多个,所以需要一个数组来承载)。
错误处理
错误处理的实现非常简单,就是try..catch方式,但是需要注意,如果要匹配错误类型的话,需要注意查阅技术手册,获得可能的错误编码的名称。
try {
await redis.get("non-existent-key");
} catch (error) {
if (error.code === "ERR_REDIS_CONNECTION_CLOSED") {
console.error("Connection to Redis server was closed");
} else if (error.code === "ERR_REDIS_AUTHENTICATION_FAILED") {
console.error("Authentication failed");
} else {
console.error("Unexpected error:", error);
}
}
高级特性
bun redis的实现,支持一些高级特性,在实际的业务应用开发中,是比较实用的:
- reconnect 自动重新连接
bun redis可以在连接中断的时候,自动重新连接。还有一些重连参数,可以让开发者配置控制重新连接的策略。也有参数配置在连接中断时,操作的行为,是等待重新连接,还是直接返回错误信息。
- auto type convert 自动类型转换
bun redis提供了一些redis响应内容的自动化的数据类型转换(因为redis响应的格式一般都是string)。
- connection monitor 连接状态和监控
bun redis提供了一些客户端连接状态的监控机制,开发者可以监控连接状态和信息。
- pipeline 执行流水线
bun redis提供了多个redis命令执行的流水线操作机制,可以提高执行效率(当然这个特性是控制可选的)。如下面的示例代码:
// Commands are automatically pipelined by default
const [infoResult, listResult] = await Promise.all([
redis.get("user:1:name"),
redis.get("user:2:email"),
]);
- raw command 原始指令
send方法,可以用于执行任何redis服务器支持的指令和操作。前面已经有简单的示例。
应用场景
很多使用Redis的开发者,并没有认真的想过,redis可以做什么。笔者刚好在bun的redis文档中,看到了相关的内容,觉得不错,有些地方的考虑和操作方式都是比较好的,可以分享和学习一下。
- Caching 缓存
缓存是redis最常见的一个使用场景。因为在很多情况下,从数据库里面查询结果是一个代价比较高的操作,如果能够确定查询的结果不会频繁变化,可以在查询后,将结果存储在redis中。下次如果查询相同的数据,就可以直接从redis中获得结果,而且是格式化好的,不需要数据库操作和转换,这样就能够提供更好的查询性能。
下面的代码,就展示了一个带有缓存的数据库查询操作:
async function getUserWithCache(userId) {
const cacheKey = `user:${userId}`;
// Try to get from cache first
const cachedUser = await redis.get(cacheKey);
if (cachedUser) {
return JSON.parse(cachedUser);
}
// Not in cache, fetch from database
const user = await database.getUser(userId);
// Store in cache for 1 hour
await redis.set(cacheKey, JSON.stringify(user));
await redis.expire(cacheKey, 3600);
return user;
}
其实,在这里面,如果要有一个更完整的设计的话,应该有一个缓存项目消除的操作,就是在数据修改(比如当前这个用户信息)后,应当执行一个相关缓存的清除。这样可以保证,下次查询,获得的是最新的数据,而且缓存也会更新到新版本。现在设计的机制,就不是很严谨,使用一个时间来控制缓存的过期和更新。
- Rate Limit 流量控制
下面的示例,是应用redis来实现的一个IP限流函数。可以用于监控和检查在一段时间窗口内某些IP的请求数量。其他的限流算法还可以选择滑动窗口、令牌桶和漏桶等等,它们都可以利用到redis提供的高性能计数器增加方法和超时机制。
async function rateLimit(ip, limit = 100, windowSecs = 3600) {
const key = `ratelimit:${ip}`;
// Increment counter
const count = await redis.incr(key);
// Set expiry if this is the first request in window
if (count === 1) {
await redis.expire(key, windowSecs);
}
// Check if limit exceeded
return {
limited: count > limit,
remaining: Math.max(0, limit - count),
};
}
- Session Store 会话存储
它给的示例代码是这样的:
async function createSession(userId, data) {
const sessionId = crypto.randomUUID();
const key = `session:${sessionId}`;
// Store session with expiration
await redis.hmset(key, [
"userId", userId.toString(),
"created", Date.now().toString(),
"data", JSON.stringify(data),
]);
await redis.expire(key, 86400); // 24 hours
return sessionId;
}
async function getSession(sessionId) {
const key = `session:${sessionId}`;
// Get session data
const exists = await redis.exists(key);
if (!exists) return null;
const [userId, created, data] = await redis.hmget(key, [
"userId",
"created",
"data",
]);
return {
userId: Number(userId),
created: Number(created),
data: JSON.parse(data),
};
}
从代码中,我们可以理解到session管理和使用方式。分成两个阶段。用户成功登录后,使用createSession方法,将用户信息放入redis中,同时将生成的sessionid作为会话ID在客户端和服务端共享; 需要的时候,可以快速的基于sessionid,从redis中找到这个id所对应的用户信息。存储实现的方式是hash表,可以存放多个(有序的)集合信息。redis可以为这个hash对象设置过期时间,过期后自动销毁关联的信息。
- Sharing Data 共享数据
redis很适合应用在一个复杂系统当中,作为某种需要共享的数据存储的机制,来在不同的子系统之间共享数据和状态。因为它天生的支持网络访问和提供高性能的小型数据操作。前面的几种应用场景,都可以扩展到网络应用中,为不同的子系统所使用。
实现规格和限制
同样的,由于技术发展时间的限制,bun redis也有一些需要完善的地方和限制。这里简单例举几条:
-
RESP3, Bun Redis是使用Zig实现的比较新的RESP3协议。
-
不支持Redis Sentinel和 Redis Cluster
-
没有专用的pub/sub方法(可以用原始命令api)
-
现在只能通过原始指令,实现事务
-
没有专用方法支持stream(原始指令api支持)
Valkey
虽然redis是缓存数据库系统的事实标准。但后来它的许可证模式发生了一些变化,已经不再被认为是传统意义上的“开源”软件。所以市场上出现了一些开源的替代方案。
在本文的实验操作中,笔者实际使用的系统,就已经不是官方的redis服务系统,而是它的一个开源替代系统: Valkey。从技术而言,Valkey可以看成是redis的一个分支版本(7版本之后)。从笔者的实际使用经验来看,对于客户端系统来说,其实没有什么区别,因为它们的功能指令集和数据类型,都是一样的。
192.168.9.192:6380> INFO
# Server
redis_version:7.2.4
server_name:valkey
valkey_version:7.2.8
redis_git_sha1:00000000
redis_git_dirty:0
redis_build_id:a4da9b33b1c6872a
redis_mode:standalone
os:Linux 4.4.167 aarch64
arch_bits:64
monotonic_clock:POSIX clock_gettime
multiplexing_api:epoll
atomicvar_api:c11-builtin
gcc_version:9.4.0
process_id:1230944
process_supervised:no
run_id:8856614b73492bb28629a3eec6bd6f6688fd861a
tcp_port:6380
server_time_usec:1749541463089474
uptime_in_seconds:226
uptime_in_days:0
hz:10
configured_hz:10
lru_clock:4710999
executable:/opt/valkey-7.2.8-focal-arm64/bin/valkey-server
config_file:
io_threads_active:0
listener0:name=tcp,bind=*,bind=-::*,port=6380
...
关于valkey本身的安装、配置和使用,其实和redis是非常相似的,这里就不再扩展讨论。如果确有必要,笔者可能在另外的专题中专门探讨。
性能
笔者编写了一个简单的测试代码,来测试了一下bun redis的性能,主要是简单对比nodejs的实现。结论是两者没有太大的区别,也可能这个测试场景过于简单,仅作参考:
// bun redis 版本
import { redis } from "bun";
const start = async()=>{
// hot load
redis.set("hello","world");
let i = 10000;
console.log('✅ 成功连接到 Redis', i);
console.time("REDIS");
while(i--) {
await redis.set("ID:"+i , Math.random.toString(36).slice(2,8));
await redis.get("ID:"+i);
}
console.timeEnd("REDIS");
}; start();
bun r1.ts
✅ 成功连接到 Redis 10000
[13.57s] REDIS
// node redis版本
npm i redis
// 创建 Redis 客户端
const client = require('redis').createClient({
url: 'redis://xxx' // 本地 Redis,如果你是 Valkey,只要地址对即可
});
// 连接 Redis
const start = async()=> {
try {
await client.connect(); // 连接 Redis
let i = 10000;
console.log('✅ 成功连接到 Redis', i);
console.time("REDIS");
while(i--) {
await client.set("ID:"+i , Math.random.toString(36).slice(2,8));
await client.get("ID:"+i);
}
console.timeEnd("REDIS");
// 断开连接
await client.quit();
} catch (err) {
console.error('Redis 错误:', err);
}
}; start();
node r2.js
✅ 成功连接到 Redis 10000
REDIS: 14.589s
在笔者的场景中,每秒完成1400个读写操作,其实是比较一般的。
小结
本文探讨了另一个bun原生提供的,常用的Web应用支持的特性: redis。 包括了基本应用、常见的指令和数据形式、扩展和高级特性,然后讨论了常见的redis应用场景,以及redis的兼容替代技术如valkey等等。