最近在做一个内部数据网关的小工具,需要暴露几个HTTP接口,让前端页面和自动化脚本直接读写Redis缓存。一开始想上Spring Boot,但算了一下,就三五个接口,还要拖一套Tomcat和一堆自动装配,启动都得好几秒,实在不划算。正好手头一直有在用一个轻量级HTTP服务器PicoServer,核心源码加依赖不到几百KB,几行代码就能把服务拉起来。再配合Jedis连接池,一个轻量服务快速操作缓存数据的方案很快就落地了。
这篇文章就是这次集成的完整记录,从PicoServer的选型理由,到Redis环境准备、核心代码实现、接口联调测试,再到我在过程中踩过的几个坑,全部摊开来讲。如果你也在找一个“写几个API操作Redis”的最简方案,不想为了几个接口背起整个Spring全家桶,这篇内容应该能帮你省不少时间。
1. 为什么选择PicoServer:轻量HTTP服务的选型思考
1.1 PicoServer到底解决了什么问题
先说说PicoServer。它是一个基于Java的极简HTTP服务器实现,核心思想就是“够用就好”,不追求把Servlet规范全实现一遍,也不搞复杂的容器模型。它内部自己维护了监听线程和连接处理,你只需要定义路由和处理逻辑,就能对外提供HTTP服务。
举一个最简单的例子,启动一个返回JSON的接口:
java复制Server server = new Server(8080);
server.get("/hello", (req, res) -> res.send("{\"msg\":\"hello\"}"));
server.start();
到这个程度,一个HTTP服务已经能跑了。对比Spring Boot,你至少得等容器扫描、依赖注入、内嵌Tomcat初始化,再快也得几秒。PicoServer基本是毫秒级启动。这种特性决定了它的定位:适合嵌入式场景、边缘设备、内部工具、CLI辅助服务,以及像这次一样“给缓存操作包一层HTTP接口”的轻量集成。
1.2 它和Spring Boot的取舍边界
很多人一听到“Java写HTTP服务”,默认就打开Spring Initializr。但我个人建议先想清楚一件事:这个服务的生命周期有多长,接口规模有多大。
我整理过一张对比表,直接在这里分享:
| 维度 | PicoServer | Spring Boot |
|---|---|---|
| 启动速度 | 毫秒级 | 秒级 |
| 打包体积 | 几百KB起步 | 几十MB起步 |
| 依赖复杂度 | 低,可单文件运行 | 高,依赖管理复杂 |
| 路由能力 | 适中,适合RESTful基础操作 | 完善,支持过滤器、拦截器、切面 |
| 生态能力 | 需自己集成 | 全家桶,各类Starter |
| 适合场景 | 内部工具、嵌入式、边缘节点 | 正式业务系统、大型API服务 |
所以我的结论是:如果是短期工具、内部接口、边缘节点服务,PicoServer这种轻量库非常合适。但如果要做完整的权限体系、多环境配置、服务治理,那还是老老实实用Spring Boot,硬拿PicoServer去凑大型项目,反而会把自己坑了。
1.3 为什么这次选择“PicoServer + Jedis”而不是其他方案
确定接口层用PicoServer之后,Redis客户端的选择其实也有一番考虑。市面上主流的Java Redis客户端有Jedis、Lettuce、Redisson。Lettuce是Spring Boot默认集成,基于Netty,异步能力强;Redisson提供了丰富的分布式数据结构;而Jedis则是老牌同步客户端,简单直接,API贴近Redis原生命令。
这次我选Jedis,理由很实在:内部缓存读写操作是同步请求,不需要异步,也没有复杂分布式锁需求;Jedis的API最朴素,代码可读性最高,出了问题排查也快。对于“轻量服务”这个定位,不需要把复杂度拉高。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 集成前环境准备:安装Redis与引入Maven依赖
2.1 Redis环境的三分钟快速启动
想操作Redis,首先得有一个能连的Redis服务。这里给三种方式,按推荐程度排序:
方式一:Docker启动
bash复制docker run -d --name redis-cache -p 6379:6379 redis:7-alpine
这是最省事的方式,有Docker的环境基本一条命令搞定。加上--restart=always可以让容器随Docker自启:
bash复制docker run -d --name redis-cache --restart=always -p 6379:6379 redis:7-alpine
方式二:Linux直接安装
bash复制wget https://download.redis.io/releases/redis-7.2.4.tar.gz
tar xzf redis-7.2.4.tar.gz
cd redis-7.2.4
make -j4
make install
redis-server --daemonize yes
方式三:Windows测试环境
Windows并非Redis官方支持的一等公民,但本地测试可以下载Redis的Windows移植版,或者用WSL跑Linux命令。日常调接口用Windows版本足够,但生产环境不要这么玩。
Redis启动后,一定要用redis-cli确认一下状态:
bash复制redis-cli ping
如果返回PONG,说明服务正常。
2.2 Maven依赖的引入与坐标选择
这次项目使用Maven管理依赖。核心依赖就三个:PicoServer、Jedis、JSON序列化库。
提示:PicoServer因为比较轻量,不同版本的Maven坐标略有差异。我这边项目使用的是社区维护的一个2.x版本,坐标建议直接从Maven中央仓库搜索
picoserver获取。如果仓库中找不到,也可以直接把它的源代码文件拷到工程里,它本身就是为了单文件布署而设计的。
依赖清单如下:
xml复制<!-- 轻量级HTTP服务器 -->
<dependency>
<groupId>com.github.axet</groupId>
<artifactId>picoserver</artifactId>
<version>1.0.1</version>
</dependency>
<!-- Redis客户端 -->
<dependency>
<groupId>redis.clients</groupId>
<artifactId>jedis</artifactId>
<version>4.4.6</version>
</dependency>
<!-- JSON序列化/反序列化 -->
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
<version>2.15.3</version>
</dependency>
<!-- 日志门面,强烈建议加上 -->
<dependency>
<groupId>org.slf4j</groupId>
<artifactId>slf4j-simple</artifactId>
<version>1.7.36</version>
</dependency>
这里特别说一下日志组件。很多人做小工具不打印日志,出了问题只能瞎猜。我这次就吃了亏,所以强烈建议至少加一个SLF4J,PicoServer和Jedis内部日志都能输出,排查问题会轻松很多。
2.3 初始化Jedis连接池的参数设计
Jedis实例本身不是线程安全的,绝对不能在多线程环境下直接共享同一个实例。正确的做法是使用连接池。
连接池参数设计我单独拎出来讲,因为这里面的坑很隐蔽。我初始化的配置如下:
java复制JedisPoolConfig poolConfig = new JedisPoolConfig();
poolConfig.setMaxTotal(32);
poolConfig.setMaxIdle(8);
poolConfig.setMinIdle(2);
poolConfig.setTestOnBorrow(true);
poolConfig.setTestOnReturn(true);
poolConfig.setMaxWaitMillis(3000);
JedisPool jedisPool = new JedisPool(poolConfig, "127.0.0.1", 6379, 2000, null);
参数含义:
maxTotal:最大连接数,32对于内部工具已经足够maxIdle:最大空闲连接数,控制回收后保留的连接minIdle:最小空闲连接数,保证低峰期也有可用连接testOnBorrow:获取连接时做一次PING校验,确保拿到的连接是可用的maxWaitMillis:连接池耗尽时的最大等待时间,超过即抛异常- 构造器倒数第二个参数是连接超时时间,最后一个参数是密码,没密码就传
null
说实话,刚开始我没开testOnBorrow,结果有一次Redis服务重启,连接池里全是失效连接,接口批量报错。后来把这个开关打开,恢复快多了。代价是每次获取连接多一次PING,对性能影响微乎其微,但可靠性提升是肉眼可见的。
3. 核心代码实现:用PicoServer路由操作Redis缓存
3.1 设计缓存操作的REST接口
在设计接口之前,先明确缓存操作的最小集。一般场景下,无非就是查缓存、写缓存、删缓存这三件事。如果还要附带一点实用功能,可以加上“设置过期时间”和“查看命中统计”。
我设计的接口如下:
| 方法 | 路径 | 功能 | 请求体 | 查询参数 |
|---|---|---|---|---|
| GET | /cache/ | 读取缓存 | 无 | 无 |
| PUT | /cache/ | 写入缓存 | 任意字符串 | ttl:过期秒数 |
| DELETE | /cache/ | 删除缓存 | 无 | 无 |
| GET | /cache/stats | 查看命中率 | 无 | 无 |
{key}为缓存键,比如user:1001、order:998。这里有个工程经验:Redis Key统一用业务名:ID的格式,冒号在Redis Desktop Manager等可视化工具里会自动分组,看着清晰,管理也方便。
3.2 连接池单例与Redis操作工具类
先把连接池封装成单例,避免每次请求都重新初始化连接池。这一步很重要,连接池创建一次,整个服务共享一个池实例。
java复制public class RedisPool {
private static final JedisPool POOL;
static {
JedisPoolConfig config = new JedisPoolConfig();
config.setMaxTotal(32);
config.setMaxIdle(8);
config.setMinIdle(2);
config.setTestOnBorrow(true);
config.setTestOnReturn(true);
config.setMaxWaitMillis(3000);
POOL = new JedisPool(config, "127.0.0.1", 6379, 2000, null);
}
private RedisPool() {}
public static Jedis getJedis() {
return POOL.getResource();
}
public static void close() {
POOL.close();
}
}
然后写一个简单的缓存操作工具类:
java复制public class CacheService {
public String get(String key) {
try (Jedis jedis = RedisPool.getJedis()) {
return jedis.get(key);
}
}
public void set(String key, String value, long ttlSeconds) {
try (Jedis jedis = RedisPool.getJedis()) {
if (ttlSeconds > 0) {
jedis.setex(key, ttlSeconds, value);
} else {
jedis.set(key, value);
}
}
}
public long delete(String key) {
try (Jedis jedis = RedisPool.getJedis()) {
return jedis.del(key);
}
}
}
注意代码里的try-with-resources。Jedis对象实现了AutoCloseable,从连接池取出后,无论操作成功还是异常,都会自动归还到连接池。这是Jedis使用中最不能省的一个习惯。一旦忘记归还,连接池很快会被耗尽。
3.3 定义PicoServer路由并启动服务
接下来是核心部分:把PicoServer的四个路由写出来。
java复制import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ObjectNode;
import java.io.IOException;
public class CacheHttpServer {
private static final ObjectMapper MAPPER = new ObjectMapper();
public static void main(String[] args) throws IOException {
CacheService cacheService = new CacheService();
Server server = new Server(8080);
// 读取缓存
server.get("/cache/{key}", (req, res) -> {
String key = req.getParam("key");
String value = cacheService.get(key);
ObjectNode result = MAPPER.createObjectNode();
if (value == null) {
result.put("code", 404);
result.put("message", "cache miss");
} else {
result.put("code", 0);
result.put("data", value);
}
res.setContentType("application/json; charset=utf-8");
res.send(result.toString());
});
// 写入缓存
server.put("/cache/{key}", (req, res) -> {
String key = req.getParam("key");
String body = req.getBody();
String ttlParam = req.getQueryParam("ttl");
long ttl = ttlParam != null ? Long.parseLong(ttlParam) : 0;
cacheService.set(key, body, ttl);
ObjectNode result = MAPPER.createObjectNode();
result.put("code", 0);
result.put("message", "ok");
res.setContentType("application/json; charset=utf-8");
res.send(result.toString());
});
// 删除缓存
server.delete("/cache/{key}", (req, res) -> {
String key = req.getParam("key");
long count = cacheService.delete(key);
ObjectNode result = MAPPER.createObjectNode();
result.put("code", 0);
result.put("deleted", count);
res.setContentType("application/json; charset=utf-8");
res.send(result.toString());
});
// 命中率统计
server.get("/cache/stats", (req, res) -> {
ObjectNode result = MAPPER.createObjectNode();
result.put("hits", CacheStats.getHits());
result.put("misses", CacheStats.getMisses());
result.put("hitRate", CacheStats.getHitRate());
res.setContentType("application/json; charset=utf-8");
res.send(result.toString());
});
server.start();
System.out.println("Http server started at http://127.0.0.1:8080");
}
}
这里有一个细节需要注意:/cache/stats这个路由不能和/cache/{key}冲突。有些路由框架会按注册顺序匹配,如果{key}的通配路由先注册,/cache/stats会被当成key值“stats”去查缓存。我在实现时把stats放在前面注册,或者你也可以在{key}路由里先判断key是否等于stats,然后分流处理。
req.getBody()是读取原始请求体的方法,具体取决于PicoServer版本。如果版本不支持直接读取,可以通过req.getInputStream()把请求体读出来。在内部工具中,请求体就是一段普通字符串,可以是JSON、纯文本,也可以是任意业务数据,缓存服务本身不应该关心内容格式。
3.4 关于命中率统计的额外实现
既然要做缓存服务,命中率统计是个很好的加分项。我把统计逻辑单独拆出来,用原子变量保证线程安全。
java复制public class CacheStats {
private static final AtomicLong HITS = new AtomicLong();
private static final AtomicLong MISSES = new AtomicLong();
public static void recordHit() {
HITS.incrementAndGet();
}
public static void recordMiss() {
MISSES.incrementAndGet();
}
public static double getHitRate() {
long total = HITS.get() + MISSES.get();
if (total == 0) {
return 0d;
}
return HITS.get() * 1.0d / total;
}
public static long getHits() {
return HITS.get();
}
public static long getMisses() {
return MISSES.get();
}
}
然后在查询缓存时,判断命中与否并调用统计方法:
java复制String value = cacheService.get(key);
if (value == null) {
CacheStats.recordMiss();
// 返回404
} else {
CacheStats.recordHit();
// 返回数据
}
这个东西看起来不起眼,但排查缓存问题时非常有用。如果命中率长期低于50%,说明缓存设计可能有问题或者数据一直在过期。
4. 联调测试与缓存效果验证
4.1 用curl快速验证接口
服务启动后,先用最简单的方式验证接口可用性。
查看一个不存在的key
bash复制curl http://127.0.0.1:8080/cache/user:1001
预期返回:
json复制{"code":404,"message":"cache miss"}
写入一个key并设置过期时间
bash复制curl -X PUT "http://127.0.0.1:8080/cache/user:1001?ttl=3600" \
-d '{"name":"Tom","level":3}'
预期返回:
json复制{"code":0,"message":"ok"}
再次读取该key
bash复制curl http://127.0.0.1:8080/cache/user:1001
预期返回:
json复制{"code":0,"data":"{\"name\":\"Tom\",\"level\":3}"}
删除该key
bash复制curl -X DELETE http://127.0.0.1:8080/cache/user:1001
预期返回:
json复制{"code":0,"deleted":1}
4.2 用Redis Desktop Manager反向验证数据
如果装了几款Redis可视化工具,比如Another Redis Desktop Manager或者Redis Insight,可以直接看缓存里的数据。这里提醒一下,我在用Jedis写入时直接存的是JSON字符串,因此在可视化工具里看到的是字符串类型(string),不是Hash,也不是Set。
这么做的好处是通用性最强,任何语言都能解析,劣处是没法在Redis层面做字段级操作。但在这个场景下,HTTP接口的职责就是整体存取一段数据,所以选择String类型是对的。
4.3 一次简单的压测观察
接口能通只是第一步,还得看看并发能力。我用自带的压测工具模拟了2000个并发请求读取同一个key。
测试方式:
bash复制ab -n 2000 -c 50 http://127.0.0.1:8080/cache/user:1001
实测结果大概是:
- 每秒请求数:约2200
- 平均响应时间:约45ms
- 错误率:0%
这个数据受机器性能和Redis服务本身影响,只作参考。但即便机器性能一般,PicoServer加Redis的组合,扛住内部工具的几千QPS是完全没问题的。因为Redis本身响应在微秒级别,瓶颈只会出现在HTTP解析和线程调度上,而在这种体量下远没到瓶颈。
5. 踩坑记录:PicoServer遇上Redis的常见问题
5.1 Jedis连接池耗尽导致接口假死
第一次测试压测后,我发现一个现象:请求量稍大时,接口会突然全部卡住,随后抛Could not get a resource from the pool异常。
排查过程:
- 检查连接池状态,发现活跃连接数一直顶到
maxTotal - 查看代码,发现有一处循环操作Redis时,是手动
new Jedis(...),没有归还连接 - 更重要的是,那个操作没有使用
try-with-resources,异常时连接直接丢失
最终修复方式:所有Redis操作统一走try-with-resources,确保连接必定归还。另外给连接池开启testOnBorrow,即使有坏连接也能及时发现。
注意:连接池参数不能拍脑袋,要结合Redis部署方式和业务并发度。核心原则是:
maxTotal略高于预估峰值并发数,maxIdle保持中等即可,过大反而造成资源浪费。
5.2 Redis重启后连接池中的旧连接全部失效
这是一个很典型的运维场景。Redis服务被重启后,连接池里还留着老的TCP连接。这些连接在客户端看来是正常的,但实际上服务端已经断了。如果testOnBorrow没有开启,获取到的连接第一次使用就会抛异常。
两种解法:
- 开启
testOnBorrow=true,获取连接时自动检测 - 在业务侧加一层重试机制,第一次失败后重新获取连接再试一次
我建议两个都做。虽然testOnBorrow每次多一次PING,但对于内部工具来说,这点开销完全可接受。
5.3 路径参数与静态路由冲突
我在最开始的实现里,把/cache/stats注册在/cache/{key}之后,结果请求/cache/stats时,key参数变成了stats,直接去Redis里查stats这个键了。
这是一个非常隐蔽的路由问题。PicoServer这类轻量库,路由匹配规则一般比较直接,不会像Spring MVC那样自动优化精确匹配优先级。所以我在代码里提前注册了stats路由。如果你后续要增加更多固定路径,比如/cache/clear、/cache/keys,一定要留意这个冲突。
还有一种更稳妥的思路:在{key}路由内部做一层判断,遇到保留字就单独处理。这样即使路由匹配顺序有变化,也不会出错。
5.4 缓存穿透与过期策略的简单应对
虽然这次内部工具没有遭遇大规模缓存穿透,但既然做缓存服务,我还是把策略提前想好了:
- 对同一个key的并发请求,可以用Redis的
SETNX实现简单的防击穿锁 - 对大量key同时过期的场景,写入过期时间时加上随机扰动,比如
ttl + random(0, 300) - 对于恶意构造的空key查询,可以直接在写入时把空值缓存起来,设置短过期时间,例如30秒
下面是一个结合防击穿逻辑的读取示例:
java复制public String getWithLock(String key, Callable<String> loader, int ttl) {
String value = get(key);
if (value != null) {
return value;
}
// 尝试获取锁,防止大量请求同时回源
try (Jedis jedis = RedisPool.getJedis()) {
String lockKey = "lock:" + key;
long result = jedis.setnx(lockKey, "1");
if (result == 1) {
jedis.expire(lockKey, 5);
try {
value = loader.call();
set(key, value, ttl);
return value;
} finally {
jedis.del(lockKey);
}
} else {
// 没抢到锁,短暂等待后重读缓存
Thread.sleep(50);
return get(key);
}
} catch (Exception e) {
// 降级处理
}
}
这部分内容稍微往前迈了一步,但对于做缓存服务的项目来说,提前想到总是好的。
5.5 缓存key规范与可视化工具的最佳实践
最后一个坑不算坑,但属于经验之谈。我强烈建议在项目初期就定好缓存key规范。我目前的规范是:
- 统一使用
业务模块名:实体名:ID,例如gateway:user:1001 - 有变量或多条件查询时,使用
业务名:实体名:MD5(参数)来拼key
规范的好处,不仅是代码里好维护,在可视化工具里也一目了然。Redis Desktop Manager和Redis Insight都是按冒号层级展示key的,命名规范一旦建立,排查问题效率会高很多。
写在最后的小技巧
这次集成做完,我自己最大的体会是:不是所有服务都需要套一个重框架。PicoServer这样的轻量库,配合Jedis连接池,完全能承载内部缓存操作这类场景。整个项目包体才几MB,部署就是一条java -jar命令,简单粗暴,但十分可靠。
最后再分享一个调试小技巧:当你怀疑某个key的过期时间不对时,用Redis命令直接查看它的TTL和编码类型:
bash复制redis-cli ttl gateway:user:1001
redis-cli type gateway:user:1001
redis-cli object encoding gateway:user:1001
如果type不是string,说明写入时可能被其他客户端改过结构;如果ttl是-1,说明这个key被写入时没有设置过期时间,你得回头检查代码路径了。这几个命令,是我排查缓存问题出现频率最高的三把刀,推荐你记住。
