Redis 优化实践:高性能设备缓存系统设计

文章目录

    • 前言
    • [一、固定长度字符串协议 + 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 基于跳表实现,范围查询高效
  • 自动排序:按时间戳自动排序,无需额外排序操作
  • 支持多种查询zRangeByScorezRangezRevRange

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 性能优化原则

  1. 批量操作优先:MGET、MSET、Pipeline
  2. 分片策略:大 key 拆分,负载均衡
  3. 异步并行:线程池 + Future
  4. 合理过期:避免内存泄漏

10.3 注意事项

  • Key 命名规范业务_类型_{参数1}_{参数2}
  • 过期时间设置:根据业务特点设置
  • 监控告警:监控内存、QPS、延迟
  • 降级方案:缓存失效时的降级策略

十一、未来优化方向

  1. Redis Cluster:水平扩展,支持更大规模
  2. 本地缓存:Caffeine + Redis 二级缓存
  3. 数据压缩:对固定长度字符串进一步压缩
  4. 读写分离:主从架构,提升读性能

结语

通过以上 8 个 Redis 优化技巧,我们的设备管理系统在性能、可扩展性和稳定性方面都得到了显著提升。这些技巧不仅适用于 IoT 设备管理场景,也可以应用到其他高并发、大数据量的业务系统中。

关键点总结

  • 固定长度协议 + SETRANGE:极致优化更新性能
  • ZSet 时间索引:高效的范围查询
  • Hash 分片:分散负载,提升并发
  • 批量操作:减少网络往返
  • 异步并行:充分利用多核 CPU
  • 分布式锁:保证并发安全
  • List 分页:O(1) 分页查询
  • 过期机制:自动清理,节省内存

希望这些实践经验对大家有所帮助!


作者 :技术团队
日期 :2024
版本:v1.0

相关推荐
fen_fen2 小时前
SqlServer新增schema和用户的命令
数据库·sqlserver
小蜗的房子2 小时前
Oracle 19c RAC重建AWR步骤详解
linux·运维·数据库·sql·oracle·操作系统·oracle rac
小宇的天下2 小时前
Calibre 3Dstack --每日一个命令day12【density】(3-12)
服务器·数据库·windows
難釋懷2 小时前
Redis数据结构介绍
数据结构·数据库·redis
松涛和鸣2 小时前
54、DS18B20单线数字温度采集
linux·服务器·c语言·开发语言·数据库
indexsunny2 小时前
互联网大厂Java面试实战:核心技术与微服务架构解析
java·数据库·spring boot·缓存·微服务·面试·消息队列
合方圆~小文2 小时前
三目智能监控新标杆
数据库·人工智能·模块测试
神秘的猪头2 小时前
AI全栈项目 Day 3:不仅是数据库,更是你的“数据堡垒” —— PostgreSQL 硬核入门
数据库·sql·postgresql
天人合一peng2 小时前
kingbase数据库的
服务器·数据库·oracle