sunku-cj 介绍
API 可以一模一样,架构位置不能
sunku-cj 是仓颉生态里的「Redis 兼容本地 KV 数据库」,架在 badger-cj 之上。
本文不吹它「兼容 Redis」,而是把「同构到什么程度、在哪一层不能同构」讲清楚。
一、它是什么
一句话:用 Redis 的命令写进程内 KV。
import sunku.*
// 纯内存
let cache = Sunku.memory()
// 持久化到磁盘
// let cache = Sunku.open("/data/db")
cache.set("user:1", "张三")
cache.setex("session:abc", "token",
Duration.hour)
cache.incr("counter", by: 1)
cache.zadd("rank", 100.0, "alice")
cache.lpush("queue", "task-1")
cache.sadd("tags", "cangjie")
方法名就是 Redis 命令名。五种数据结构都覆盖,合计约 120 个公开方法:
| 结构 | 方法数 | 覆盖的命令(部分) |
|---|---|---|
| String | 29 | set setex psetex get del incr incrby incrbyfloat append strlen getrange setrange getbit setbit bitcount |
| Hash | 17 | hset hget hgetall hdel hexists hincrby hkeys hvals hlen hmset hmget hsetnx |
| List | 23 | lpush rpush lpop rpop lrange lindex lset ltrim lrem linsert blpop brpop brpoplpush lpos |
| Set | 23 | sadd srem smembers sismember scard sinter sunion sdiff smove spop srandmember sscan |
| ZSet | 28 | zadd zrem zscore zrank zrange zrangebyscore zincrby zcount zremrangeby* zunionstore zinterstore zpopmin bzpopmin |
| Key | 13 | exists expire expireat pexpire ttl persist key |
这不是「像 Redis」,是「照着 Redis 的签名抄的」——连 setex / psetex(秒和毫秒的区别)、incrbyfloat、zrangebyscore 这些细节都对上了。
二、为什么会有这个东西
在仓颉里用 Redis,通常要起一个服务、走网络、序列化。如果你的程序本身就是单进程的,这套开销是白付的:
用 Redis:
你的程序 ──TCP──> Redis 进程 ──> 内存
↑
序列化、上下文切换、
连接池管理
用 sunku:
你的程序 ──函数调用──> 本地 LSM / 内存
差别不只是「快一点」:
| Redis | sunku | |
|---|---|---|
| 部署 | 起一个服务(Docker / systemd) | 一个依赖,零额外进程 |
| 运维 | 要监控、要备份、要考虑认证 | 库怎么部署它就怎么部署 |
| 故障面 | 网络断了就没了 | 没有网络这一层 |
| 数据边界 | 多个客户端共享 | 只有本进程能访问 |
所以它的合适位置是:你的程序自己就是那个「唯一使用者」的时候。
三、能做什么(核对过源码)
3.1 五种数据结构,覆盖完整
我逐个文件核对过实现行数,没有「声明了但没实现」的占位:
| 结构 | 实现 | 测试 |
|---|---|---|
| String | 513 行 | 323 行 |
| ZSet | 686 行 | 481 行 |
| Set | 363 行 | 275 行 |
| List | 494 行 | 387 行 |
| Hash | 233 行 | 171 行 |
测试 2159 行 vs 实现 2853 行——测试比想象中厚,这对一个存储库是好事。
3.2 两种存储模式
// 纯内存,进程退出即丢
Sunku.memory()
// 指定 memtable 大小
Sunku.memory(memTableSize: ...)
// 持久化(LSM 落盘)
Sunku.open("/data/db")
持久化模式直接复用 badger-cj,所以继承了它的能力:WAL、SSTable、compaction、优雅退出落盘。
// SIGTERM → flush 落盘 → 退出
cache.registerSignalShutdown()
对一个要跑在容器里的程序,这条很实用:docker stop 时数据不会丢。
3.3 TTL
cache.setex("k", "v", Duration.hour)
cache.pexpire("k", 5000) // 毫秒
cache.ttl("k") // 剩余时间
cache.persist("k") // 去掉过期
实现方式是写时把 expiresAt 存进 LSM(Entry.withExpiresAt),读到过期就视作不存在。
四、不能做什么(这几条比上面更重要)
4.1 它不是「Redis 的替代品」,是「Redis 的一个位置」
这是最容易误解的一点。sunku 是进程内库,不是网络服务。
Redis 在架构里的位置:
┌────────┐ ┌────────┐ ┌────────┐
│ 进程 A │ │ 进程 B │ │ 进程 C │
└───┬────┘ └───┬────┘ └───┬────┘
└──────────┼──────────┘
▼
┌─────────┐
│ Redis │
└─────────┘
唯一真相,所有进程共享
sunku 在架构里的位置:
┌─────────────────┐ ┌─────────────────┐
│ 进程 A │ │ 进程 B │
│ ┌─────────────┐ │ │ ┌─────────────┐ │
│ │ sunku │ │ │ │ sunku │ │
│ └─────────────┘ │ │ └─────────────┘ │
└─────────────────┘ └─────────────────┘
各自一份,互不可见
所以「Redis 当主缓存、sunku 当二级缓存」这个想法方向是反的。
真正的两级缓存通常长这样:
| 层 | 位置 | 延迟量级 | 用什么 |
|---|---|---|---|
| L1 | 进程内 | 纳秒 | sunku 在这个位置 |
| L2 | 进程间共享 | 毫秒 | Redis |
| L3 | 持久化 | 毫秒~秒 | 数据库 |
sunku 天生就是 L1 的材料:零网络、零序列化(对仓颉对象)、单进程独占。
4.2 blpop 是轮询,不是条件变量唤醒
Redis 的 BLPOP 是阻塞的:没有元素就挂在条件变量上,LPUSH 一到立刻唤醒。
sunku 的实现在做这个:
public func blpop(keys: Array<String>,
timeout: Duration)
: ?(String, String) {
let deadline =
DateTime.now() + timeout
let checkInterval =
Duration.millisecond * 100
while (DateTime.now() < deadline) {
for (key in keys) {
match (lpop(key)) {
case Some(v) =>
return Some((key, v))
case None => continue
}
}
// ← 每 100ms 轮询一次
sleep(checkInterval)
}
...
}
语义对上,延迟多最多 100ms。 做任务队列时这个差别是能感觉到的;做普通缓存则无所谓。
4.3 TTL 只有惰性过期
Redis 有两套机制:惰性删除(读到才判)+ 定期删除(后台扫一批)。sunku 只有前者。
后果:已过期的 key 会一直占着磁盘,直到有人读到它。
对一个长期运行的缓存进程,这意味两件事:
- 内存/磁盘曲线只涨不落,除非访问模式恰好覆盖到那些 key
- 想主动清理,只能自己扫
key列表逐个ttl判断
4.4 并发:这里的语义和 Redis 不一样
这条我核对源码后确认,值得单独说。
Redis 是单线程执行命令的,所以 INCR 天然原子。sunku 跑在你的多线程程序里,而它的事务使用方式是:
// src/sunku_string.cj 的 incr(节选)
let txn = db.newTransaction(update: true)
// ↓ 事务外读
let currentValue =
match (db.get(keyBytes)) {
case Some(vs) => ...
}
let newValue = currentValue + by
txn.setEntry(entry)
txn.commit()
读用的是 db.get(事务外),写用的是 txn.setEntry(事务内)。
底层 badger-cj 有 SSI 冲突检测,但它依赖事务记录读集(txn.get 时把 key 记进 reads)。事务外读不入账,冲突检测就看不见这次竞争。
实测统计:整个库 65 处 db.get,0 处 txn.get。
所以:
| 场景 | 是否安全 |
|---|---|
| 单线程 | ✅ 没问题 |
| 多线程各自操作不同 key | ✅ 没问题(写入本身是事务的) |
多线程对同一个 key 做 incr/zadd 这类「读-改-写」 |
⚠️ 可能丢更新 |
多线程各自 set 同一个 key |
✅ 后写覆盖,语义明确 |
如果你要并发用,避开「多线程对同一 key 做读-改-写」这个模式,或者在应用层自己加锁。
4.5 其他「Redis 有、它没有」的
| Redis 有 | sunku |
|---|---|
| 网络协议(RESP)、多客户端 | 无 |
| pub/sub | 无 |
事务 MULTI/EXEC、Lua 脚本 |
无(但底层是 LSM 事务) |
| RDB / AOF 持久化 | 直接架在 LSM 上,没有独立的持久化格式 |
| 主从复制、哨兵、集群 | 无 |
SCAN 游标(完整实现) |
只有 zscan / sscan 等局部扫描 |
五、什么场景该用它
按上面的边界,能用的场景其实很清楚:
✅ 合适
- 单进程程序的本地缓存:要 TTL、要五种结构、不想起 Redis
- CLI 工具 / 脚本的状态存储:跑完就走,不需要服务
- 测试替身:写测试时用
Sunku.memory()替代 Redis,不用起容器 - 离线数据处理的临时工作区:五种结构在手,比裸 KV 好用
- 单机的边缘/嵌入式程序:本来就没有「多进程共享」的需求
⚠️ 不合适
- 多进程共享状态——这是最核心的一条
- 需要和别的语言/服务共享缓存(Redis 的价值就在协议标准化)
- 多线程高频更新同一个计数器(见 4.4)
- 需要 pub/sub 或 Lua 做原子复合操作
- 需要主从/集群做高可用
六、怎么用:一个实际例子
假设你在写一个本地 CLI,要缓存远程 API 的响应,避免重复请求:
import sunku.*
class ApiCache {
let store: Sunku
init(dir: String) {
store = Sunku.open(dir)
// 退出时落盘
store.registerSignalShutdown()
}
func getOrFetch(url: String,
fetch: () -> String): String {
// 先查缓存(1 小时有效)
match (store.get(url)) {
case Some(v) => return v
case None => ()
}
// 未命中就请求并写入
let body = fetch()
store.setex(url, body,
Duration.hour)
body
}
// 记每个域名的调用次数(ZSet 排名)
func recordHost(host: String): Unit {
store.zincrby("hosts", 1.0, host)
}
// 看调用最多的域名
func topHosts(n: Int64):
ArrayList<(String, Float64)> {
store.zrevrange("hosts", 0, n - 1,
withScores: true)
}
}
这段代码在 Redis 下几乎一模一样——除了 Sunku.open() 换成了连接池,这正说明API 同构是有价值的:迁移成本主要在初始化和生命周期,不在业务逻辑。
七、小结
- API 同构是真的:120 个方法,命令名、参数顺序、语义都对着 Redis 抄
- 架构位置不能同构:它是进程内库,只能当 L1,不能当 Redis 那种进程间共享的 L2
- 三条已知边界:
blpop轮询(+100ms)、TTL 只有惰性过期、并发读-改-写需要自己注意 - 合适的场景:单进程、单机、测试替身、CLI 工具——只要你不需要「多进程共享」,它就是 Redis 的轻量替代
下一篇:另一个仓颉数据库 holt-cj ——图数据库,
关注的是「顶点和边怎么存、怎么遍历」。