文章目录
-
- 前言
- [一、固定长度字符串协议 + SETRANGE 部分更新](#一、固定长度字符串协议 + SETRANGE 部分更新)
-
- [1.1 背景与挑战](#1.1 背景与挑战)
- [1.2 解决方案](#1.2 解决方案)
- [1.3 核心优势](#1.3 核心优势)
- [1.4 性能数据](#1.4 性能数据)
- [二、ZSet 时间序列索引:高效的时间范围查询](#二、ZSet 时间序列索引:高效的时间范围查询)
-
- [2.1 业务场景](#2.1 业务场景)
- [2.2 实现方案](#2.2 实现方案)
- [2.3 技术亮点](#2.3 技术亮点)
- [2.4 性能对比](#2.4 性能对比)
- [三、Hash 分片策略:分散负载,提升性能](#三、Hash 分片策略:分散负载,提升性能)
-
- [3.1 问题分析](#3.1 问题分析)
- [3.2 分片方案](#3.2 分片方案)
- [3.3 优势](#3.3 优势)
- [3.4 分片数量选择](#3.4 分片数量选择)
- [四、批量查询 MGET:减少网络往返](#四、批量查询 MGET:减少网络往返)
-
- [4.1 性能瓶颈](#4.1 性能瓶颈)
- [4.2 批量优化](#4.2 批量优化)
- [4.3 性能提升](#4.3 性能提升)
- [4.4 注意事项](#4.4 注意事项)
- [五、异步批量查询 + 线程池:提升并发性能](#五、异步批量查询 + 线程池:提升并发性能)
-
- [5.1 场景](#5.1 场景)
- [5.2 异步并行方案](#5.2 异步并行方案)
- [5.3 性能数据](#5.3 性能数据)
- [5.4 最佳实践](#5.4 最佳实践)
- [六、分布式锁 SETNX:防止并发问题](#六、分布式锁 SETNX:防止并发问题)
-
- [6.1 业务场景](#6.1 业务场景)
- [6.2 实现方案](#6.2 实现方案)
- [6.3 关键点](#6.3 关键点)
- [6.4 进阶:Redisson 分布式锁](#6.4 进阶:Redisson 分布式锁)
- [七、List 分页缓存:高效的分页查询](#七、List 分页缓存:高效的分页查询)
-
- [7.1 需求](#7.1 需求)
- [7.2 方案设计](#7.2 方案设计)
- [7.3 优势](#7.3 优势)
- [7.4 缓存策略](#7.4 缓存策略)
- 八、带过期时间的临时标记:自动清理
-
- [8.1 场景](#8.1 场景)
- [8.2 实现](#8.2 实现)
- [8.3 优势](#8.3 优势)
- [8.4 使用场景](#8.4 使用场景)
- 九、综合性能对比
-
- [9.1 优化前后对比](#9.1 优化前后对比)
- [9.2 系统容量](#9.2 系统容量)
- 十、最佳实践总结
-
- [10.1 数据结构选择](#10.1 数据结构选择)
- [10.2 性能优化原则](#10.2 性能优化原则)
- [10.3 注意事项](#10.3 注意事项)
- 十一、未来优化方向
- 结语
前言
在高并发、大数据量的 IoT 设备管理系统中,缓存设计是性能优化的关键。本文基于一个真实的电单车设备管理平台,分享我们在 Redis 使用上的 8 个核心优化技巧,这些技巧帮助系统支撑了百万级设备的实时数据管理。
一、固定长度字符串协议 + SETRANGE 部分更新
1.1 背景与挑战
设备信息包含 50+ 个字段(位置、电量、状态等),每次更新如果全量替换,会导致:
- 网络传输开销大
- Redis 内存碎片增加
- 并发更新冲突风险高
1.2 解决方案
我们设计了一套固定长度字符串协议,将设备对象编码为固定长度的字符串,每个字段在字符串中有固定的偏移量和长度。
java
// DeviceProtocol.java - 协议定义
static {
addDeviceProtocolInfo(new ProtocolInfo("imei", 15));
addDeviceProtocolInfo(new ProtocolInfo("lng", 10));
addDeviceProtocolInfo(new ProtocolInfo("lat", 10));
addDeviceProtocolInfo(new ProtocolInfo("restBattery", 3));
addDeviceProtocolInfo(new ProtocolInfo("lowBatteryLostOrderCount", 10));
// ... 50+ 字段
}
// DeviceRepository.java - 部分更新实现
public void update(DeviceInfoDO deviceInfoDO) {
DeviceProtocol deviceProtocol = new DeviceProtocol();
// 只编码非空字段
List<RedisSetRange> redisSetRanges = deviceProtocol.encodeNotNull(deviceInfoDO);
String tenantId = CommandContextHolder.getCommandContext().getTenantId();
String key = DevicePassRedisKey.DEVICE_INFO.format(tenantId, deviceInfoDO.getImei());
this.setRangeList(key, redisSetRanges);
}
private void setRangeList(String key, List<RedisSetRange> redisSetRanges) {
for (RedisSetRange redisSetRange : redisSetRanges) {
// 使用 SETRANGE 只更新指定偏移量的字段
redisMapper.setRange(key,
redisSetRange.getFixedLengthValue(),
redisSetRange.getOffset());
}
}
1.3 核心优势
- 网络开销降低 90%+:只传输变更字段,而非整个对象
- 原子性更新:每个字段独立更新,互不干扰
- 内存效率高:固定长度避免内存碎片
- 支持并发:不同字段可以并发更新
1.4 性能数据
- 更新单个字段:网络传输从 2KB 降至 50 字节
- 并发更新吞吐量:提升 5-10 倍
- 内存碎片:减少 60%+
二、ZSet 时间序列索引:高效的时间范围查询
2.1 业务场景
需要快速查询某个服务区内在指定时间后上报过数据的设备列表。
2.2 实现方案
使用 ZSet 存储设备 IMEI,score 为最后上报时间戳:
java
// 更新设备上报时间
private void updateReportTime(Long serviceId, String imei) {
String tenantId = CommandContextHolder.getCommandContext().getTenantId();
Integer hash = Integer.parseInt(imei.substring(imei.length() - 1)) % 3;
String key = DevicePassRedisKey.SERVICE_GFENCE_CAR_ZSET
.format(tenantId, serviceId, hash);
// score 为时间戳
redisMapper.zAdd(key, imei, System.currentTimeMillis());
}
// 查询指定时间后的设备
public List<String> getImeiList(List<Long> serviceIdList, Long reportTime) {
List<String> imeiList = new ArrayList<>();
String tenantId = CommandContextHolder.getCommandContext().getTenantId();
for (Long serviceId : serviceIdList) {
for (int i = 0; i < DEVICE_HASH_COUNT; i++) {
String key = DevicePassRedisKey.SERVICE_GFENCE_CAR_ZSET
.format(tenantId, serviceId, i);
// 范围查询:score >= reportTime
Set<String> imeiSet = redisMapper.zRangeByScore(
key, reportTime, Long.MAX_VALUE);
imeiList.addAll(imeiSet);
}
}
return imeiList;
}
2.3 技术亮点
- O(log N) 查询复杂度:ZSet 基于跳表实现,范围查询高效
- 自动排序:按时间戳自动排序,无需额外排序操作
- 支持多种查询 :
zRangeByScore、zRange、zRevRange等
2.4 性能对比
| 方案 | 查询 10 万设备耗时 | 内存占用 |
|---|---|---|
| MySQL WHERE + ORDER BY | 2-5 秒 | 低 |
| Redis List + 全量扫描 | 1-2 秒 | 中 |
| Redis ZSet | 50-200ms | 中 |
三、Hash 分片策略:分散负载,提升性能
3.1 问题分析
单个服务区可能有 10 万+ 设备,如果全部存在一个 ZSet 中:
- 单个 key 过大,影响性能
- 写入热点集中
- 查询时需要全量扫描
3.2 分片方案
根据 IMEI 最后一位进行 hash 分片,将数据分散到 3 个 ZSet:
java
// 分片逻辑
Integer hash = Integer.parseInt(
imei.substring(imei.length() - 1)
) % 3; // 0, 1, 2 三个分片
String key = DevicePassRedisKey.SERVICE_GFENCE_CAR_ZSET
.format(tenantId, serviceId, hash);
3.3 优势
- 负载均衡:数据均匀分布到 3 个 key
- 并行查询:可以并行查询多个分片
- 扩展性强:可以动态调整分片数量
3.4 分片数量选择
- 3 个分片:适合 10 万级设备
- 10 个分片:适合百万级设备
- 权衡:分片越多,查询时需要合并的结果越多
四、批量查询 MGET:减少网络往返
4.1 性能瓶颈
查询 1000 个设备信息,如果逐个 GET:
- 网络往返:1000 次
- 总耗时:1000 × 2ms = 2 秒
4.2 批量优化
使用 MGET 一次获取多个 key:
java
public List<DeviceInfoDO> getRackDeviceByTenantServiceImei(
List<TenantServiceImeiDto> imeiDtos) {
if (CollectionUtils.isEmpty(imeiDtos)) {
return new ArrayList<>();
}
// 批量构建 key
String[] keys = imeiDtos.stream()
.map(n -> DevicePassRedisKey.DEVICE_INFO
.format(n.getTenantId(), n.getImei()))
.toArray(String[]::new);
// 一次批量获取
List<String> deviceStrList = redisMapper.mGet(keys);
// 批量解码
DeviceProtocol deviceProtocol = new DeviceProtocol();
return deviceStrList.stream()
.filter(Objects::nonNull)
.map(deviceProtocol::decode)
.collect(Collectors.toList());
}
4.3 性能提升
- 网络往返:从 N 次降至 1 次
- 查询 1000 个设备 :从 2 秒降至 50-100ms
- 吞吐量提升 :10-20 倍
4.4 注意事项
- MGET 限制:建议单次不超过 1000 个 key
- 超时控制:大批量查询需要设置合理的超时时间
- 分批处理:超过限制时进行分批查询
五、异步批量查询 + 线程池:提升并发性能
5.1 场景
需要查询 1 万个设备信息,如果串行查询,耗时过长。
5.2 异步并行方案
java
// 分片 + 线程池并行查询
public List<DeviceInfoDO> getDeviceListByImeiList(List<String> imeiList) {
// 1. 数据分片(每片 500 个)
List<List<String>> partition = Lists.partition(imeiList, 500);
List<Future<List<DeviceInfoDO>>> futures = new ArrayList<>();
String tenantId = CommandContextHolder.getCommandContext().getTenantId();
// 2. 提交到线程池并行执行
for (List<String> imeiPartition : partition) {
Future<List<DeviceInfoDO>> future = paasExecutor.submit(() -> {
String[] keys = imeiPartition.stream()
.map(n -> DevicePassRedisKey.DEVICE_INFO.format(tenantId, n))
.toArray(String[]::new);
// 每个线程执行 MGET
List<String> deviceStrList = redisMapper.mGet(keys);
DeviceProtocol deviceProtocol = new DeviceProtocol();
return deviceStrList.stream()
.filter(Objects::nonNull)
.map(deviceProtocol::decode)
.collect(Collectors.toList());
});
futures.add(future);
}
// 3. 收集结果
List<DeviceInfoDO> result = new ArrayList<>();
for (Future<List<DeviceInfoDO>> future : futures) {
result.addAll(future.get());
}
return result;
}
5.3 性能数据
| 设备数量 | 串行查询 | 并行查询(20 线程) | 提升倍数 |
|---|---|---|---|
| 1,000 | 2 秒 | 200ms | 10x |
| 10,000 | 20 秒 | 1.5 秒 | 13x |
| 100,000 | 200 秒 | 15 秒 | 13x |
5.4 最佳实践
- 线程池大小:CPU 核心数 × 2
- 分片大小:500-1000 个 key 为一批
- 超时控制:设置合理的 Future 超时时间
六、分布式锁 SETNX:防止并发问题
6.1 业务场景
生成设备地图假数据时,需要防止多个请求同时执行,造成资源浪费。
6.2 实现方案
java
public Boolean deviceMapFakeNx(Long serviceId) {
String tenantId = CommandContextHolder.getCommandContext().getTenantId();
String key = DevicePassRedisKey.DEVICE_MAP_FAKE_NX
.format(tenantId, serviceId);
// SETNX:key 不存在时设置,存在时返回 false
// 设置 3 秒过期,防止死锁
return redisMapper.setNX(key, "1", 3, TimeUnit.SECONDS);
}
// 使用示例
if (deviceInfoQuery.deviceMapFakeNx(serviceId)) {
try {
// 执行业务逻辑
generateDeviceMapFake(serviceId);
} finally {
// 释放锁(可选,因为有过期时间)
redisMapper.delete(key);
}
} else {
// 已有其他请求在执行
return "正在生成中,请稍后...";
}
6.3 关键点
- 原子性:SETNX 是原子操作,保证并发安全
- 过期时间:必须设置,防止死锁
- 释放锁:业务完成后主动删除,或依赖过期时间
6.4 进阶:Redisson 分布式锁
对于更复杂的场景,可以使用 Redisson:
java
RLock lock = redisson.getLock("device_map_fake_" + serviceId);
if (lock.tryLock(3, 10, TimeUnit.SECONDS)) {
try {
// 业务逻辑
} finally {
lock.unlock();
}
}
七、List 分页缓存:高效的分页查询
7.1 需求
设备列表查询需要支持分页,且查询条件复杂(多维度筛选)。
7.2 方案设计
将筛选后的结果缓存到 Redis List 中,使用 LRANGE 实现分页:
java
// 设置分页缓存
public void setDevicePageCache(String filterHash, List<DeviceInfoDO> collect) {
String tenantId = CommandContextHolder.getCommandContext().getTenantId();
String key = DevicePassRedisKey.DEVICE_PAGE_CACHE
.format(tenantId, filterHash);
DeviceProtocol deviceProtocol = new DeviceProtocol();
// 按上报时间排序后编码
String[] deviceStr = collect.stream()
.sorted(Comparator.comparing(DeviceInfoDO::getReportTime))
.map(deviceProtocol::initProtocolData)
.toArray(String[]::new);
// 删除旧数据,重新写入
redisMapper.delete(key);
redisMapper.lPush(key, deviceStr);
// 设置 3 分钟过期
redisMapper.expire(key, 3, TimeUnit.MINUTES);
}
// 获取分页数据
public PageDTO<DeviceInfoDO> getDevicePageByCache(
String filterHash, Integer pageNum, Integer pageSize) {
String tenantId = CommandContextHolder.getCommandContext().getTenantId();
String key = DevicePassRedisKey.DEVICE_PAGE_CACHE
.format(tenantId, filterHash);
// LRANGE 实现分页:start = (pageNum - 1) * pageSize
List<String> deviceStr = redisMapper.lRange(
key,
(pageNum - 1) * pageSize,
pageSize * pageNum - 1
);
// 获取总数
Long count = redisMapper.lLen(key);
// 解码并构建分页对象
DeviceProtocol deviceProtocol = new DeviceProtocol();
List<DeviceInfoDO> collect = deviceStr.stream()
.map(deviceProtocol::decode)
.collect(Collectors.toList());
PageDTO<DeviceInfoDO> pageDTO = new PageDTO<>();
pageDTO.setCount(count);
pageDTO.setPageNum(pageNum);
pageDTO.setPageSize(pageSize);
pageDTO.setList(collect);
return pageDTO;
}
7.3 优势
- O(1) 分页查询 :
LRANGE时间复杂度为 O(S+N),S 为起始位置,N 为元素数量 - 内存友好:使用固定长度字符串,内存占用可控
- 自动过期:设置过期时间,避免缓存堆积
7.4 缓存策略
- 第一页:实时查询并缓存
- 后续页:从缓存读取
- 过期时间:3 分钟,平衡实时性和性能
八、带过期时间的临时标记:自动清理
8.1 场景
需要标记设备在某个时间段内的临时状态(如临时解锁标记),过期后自动失效。
8.2 实现
java
// 设置临时标记,带过期时间
public void setTempUnLockTag(String imei, Long seconds) {
String tenantId = CommandContextHolder.getCommandContext().getTenantId();
String key = DevicePassRedisKey.TEMP_RIDING_TAG.format(tenantId, imei);
// 设置 key 和过期时间
redisMapper.set(key, "1", seconds, TimeUnit.SECONDS);
}
// 检查标记是否存在
public Boolean hasTempUnLockTag(String imei) {
String tenantId = CommandContextHolder.getCommandContext().getTenantId();
String key = DevicePassRedisKey.TEMP_RIDING_TAG.format(tenantId, imei);
return redisMapper.exists(key);
}
8.3 优势
- 自动清理:无需手动删除,Redis 自动过期
- 精确控制:可以设置任意过期时间
- 内存效率:过期 key 自动释放内存
8.4 使用场景
- 临时权限标记
- 限流计数器
- 防重复提交
- 会话管理
九、综合性能对比
9.1 优化前后对比
| 场景 | 优化前 | 优化后 | 提升 |
|---|---|---|---|
| 更新单个字段 | 2KB 传输,10ms | 50 字节,2ms | 5x |
| 查询 1 万设备 | 20 秒 | 1.5 秒 | 13x |
| 时间范围查询 | 2-5 秒 | 50-200ms | 10-25x |
| 分页查询 | 500ms | 20ms | 25x |
9.2 系统容量
- 设备数量:支持 100 万+ 设备
- QPS:单机 10,000+ QPS
- 延迟:P99 < 50ms
- 内存占用:单设备 < 500 字节
十、最佳实践总结
10.1 数据结构选择
- String:固定长度协议,部分更新
- ZSet:时间序列,范围查询
- List:分页缓存,有序数据
- Set:去重,集合操作
10.2 性能优化原则
- 批量操作优先:MGET、MSET、Pipeline
- 分片策略:大 key 拆分,负载均衡
- 异步并行:线程池 + Future
- 合理过期:避免内存泄漏
10.3 注意事项
- Key 命名规范 :
业务_类型_{参数1}_{参数2} - 过期时间设置:根据业务特点设置
- 监控告警:监控内存、QPS、延迟
- 降级方案:缓存失效时的降级策略
十一、未来优化方向
- Redis Cluster:水平扩展,支持更大规模
- 本地缓存:Caffeine + Redis 二级缓存
- 数据压缩:对固定长度字符串进一步压缩
- 读写分离:主从架构,提升读性能
结语
通过以上 8 个 Redis 优化技巧,我们的设备管理系统在性能、可扩展性和稳定性方面都得到了显著提升。这些技巧不仅适用于 IoT 设备管理场景,也可以应用到其他高并发、大数据量的业务系统中。
关键点总结:
- 固定长度协议 + SETRANGE:极致优化更新性能
- ZSet 时间索引:高效的范围查询
- Hash 分片:分散负载,提升并发
- 批量操作:减少网络往返
- 异步并行:充分利用多核 CPU
- 分布式锁:保证并发安全
- List 分页:O(1) 分页查询
- 过期机制:自动清理,节省内存
希望这些实践经验对大家有所帮助!
作者 :技术团队
日期 :2024
版本:v1.0