news 2026/10/13 1:07:32

Redis高并发实战:缓存、分布式锁、排行榜与消息队列全解析

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Redis高并发实战:缓存、分布式锁、排行榜与消息队列全解析

1. 别把Redis当缓存库,它是你的业务加速器

先聊点实在的。很多Java程序员一提到Redis,脑子里蹦出来的第一个词就是“缓存”,然后就是“用起来简单,set一个key,get一个key”。这话没错,但格局小了。

我在实际项目里摸爬滚打这么多年,Redis能干的活远超“缓存”这两个字的范畴。它更像是一个内存里的数据结构服务器,你给它的不是一个短暂的临时容器,而是一个可以支撑核心业务逻辑的高性能中间件。从用户登录的Session托管、热点数据的秒级响应、高并发下的流量削峰,到复杂的排行榜计算、分布式环境下的锁机制、甚至轻量级的消息推送,Redis都能胜任。

这篇文章我不想讲那些枯燥的源码分析或者面试八股文,而是以一个Java开发者的视角,结合我自己的实战经历,把Redis在日常业务里最常用、最实用的几大招式拆开揉碎讲清楚。无论是刚入行的新人,还是工作几年的老手,看完这篇文章,你至少会对自己项目里的Redis有一个全新的认识——原来它能做这么多事,原来有些问题用Redis解决可以这么优雅。

先给一张整体图景,看看Redis在Java世界里能承担的角色:

角色定位核心价值典型业务场景
缓存加速层将热点数据驻留内存,把毫秒级响应变成常态商品详情、用户信息、配置数据
分布式锁协调器解决多实例并发互斥问题秒杀扣减、订单防重、定时任务抢占
计数器与限流器原子性自增自减、时间窗口统计点赞数、访问量、接口限流
有序集合计算引擎基于分数的实时排序排行榜、优先级队列、延时任务
轻量消息通道发布订阅模式、生产者消费者站内通知、异步解耦、实时推送

接下来,我从最简单的场景开始,逐步深入,把每一招的原理、代码、踩坑点都给你剖析到位。

2. 缓存不只是“放进去读出来”,三个经典问题必须先搞懂

缓存是Redis最基础的应用,但基础不等于简单。我在面试别人或者帮朋友排查问题的时候发现,很多人用Redis做缓存,就只会“写进去-读出来”,一旦遇到缓存穿透、缓存击穿、缓存雪崩这三个经典问题,就抓瞎了。这三个问题处理不好,生产环境分分钟给你颜色看。

2.1 缓存穿透:查询一个不存在的数据,Redis形同虚设

问题本质:客户端疯狂请求一个缓存和数据库中都不存在的数据。比如一个商品的ID是负数,或者一个被删掉的用户ID。因为缓存没有这个数据(也没法缓存,总不能缓存一个“不存在”吧?),所有请求直接打到数据库上。如果被恶意攻击,数据库瞬间压力爆表。

我之前接手过一个项目,就被人用不存在的ID刷过接口,数据库连接池一度被打满,整个服务雪崩。

解决方案:

  • 缓存空结果:如果数据库查询结果为空,仍然把这个key缓存起来,只不过value是null或者一个特殊标记,过期时间设置短一些,比如60秒。这样同样的请求就不会穿透到数据库了。
  • 布隆过滤器拦截:在缓存前面加一道拦截层,把所有合法ID先放到布隆过滤器里。请求过来先判断ID是否可能存在,如果布隆过滤器说“不存在”,那肯定不存在,直接返回404,连Redis都不用查。这里要注意,布隆过滤器存在误判率,但它是“宁可错杀一千,不可放过一个”的策略,用于拦截大部分非法请求足够了。

2.2 缓存击穿:热点key瞬间过期,流量全怼到数据库

问题本质:某个key的并发访问量极高(比如某个秒杀商品的库存、某个明星的八卦新闻),但这个key在某一瞬间过期了。就在这一瞬间,大量请求发现缓存没有数据,同时去数据库查询,数据库不堪重负。

这个跟穿透的区别在于,穿透是缓存没有这个key,击穿是缓存本来有,但是刚好在那一瞬间过期了。

解决方案:

  • 互斥锁(Mutex Key):当缓存过期时,不是所有线程都去数据库查,而是先获取一个分布式锁。只有拿到锁的线程才能去查数据库并更新缓存,其他线程等待片刻后重新读取缓存。这样数据库的压力就大大降低了。
  • 逻辑过期:在value里设置一个逻辑过期时间(注意不是Redis的TTL),比如30分钟。当线程读取到数据时,发现逻辑时间快过期了,就尝试获取锁,自己去更新缓存。其他线程依然返回旧数据,但对于用户来说数据是正常的。这种方式的好处是没有真正的过期时间,不会出现缓存为空的情况,适合实现“数据最终一致”但要求极高的场景。

2.3 缓存雪崩:大量key同时过期,Redis直接“宕机”

问题本质:雪崩有两种情况,一种是Redis宕机,另一个更常见的是大量key设置了相同的过期时间,比如统一在凌晨12点过期,或者统一在某个业务高峰点过期。这时候大量请求同时发现缓存失效,一起涌向数据库,数据库直接被打垮。

解决方案:

  • 过期时间加随机值:这是最简单有效的办法。不要在设置key时都用set(key, value, 3600)这种固定时间,而是应该在过期时间上加上一个随机数,比如3600 + random(300)。这样key不会在同一时刻过期,分散请求压力。
  • 多级缓存:在Redis前面再加一层本地缓存(比如Caffeine),或者CDN。Redis挂了或者key过期了,本地缓存还能顶一阵。即使本地缓存也没了,数据库压力也会比直接面对全量并发小很多。
  • 高可用保障:Redis本身配置主从复制+哨兵集群,避免单点故障。这是运维层面的保障,但Java程序员也应该了解。

这三兄弟搞懂了,缓存这块才能在业务里站得住脚。光会用@Cacheable注解调第三方接口可不行,出了问题还得知道怎么定位和解决。

3. 分布式锁:多个Java实例抢同一个资源,怎么保证不打架?

我先问一个问题:你写的程序部署了几台机器?如果只有一台,那synchronized和Lock完全够用。但现在的互联网应用,随便一个服务都是多实例部署的,甚至做容灾还要跨机房。这种情况下,JVM级别的锁就失效了,因为A机器的锁管不到B机器。

这时候,Redis的分布式锁就派上用场了。这是Java程序员用Redis的第二个高频场景。但这里面的坑,比缓存多得多。

3.1 最基础的加锁姿势:SETNX + 过期时间

Redis 2.6.12版本之后,SET命令支持NX和EX参数,一个命令搞定加锁和设置过期时间,保证了原子性。

// 加锁 String lockKey = "order:pay:" + orderId; String requestId = UUID.randomUUID().toString(); Boolean locked = redisTemplate.opsForValue().setIfAbsent(lockKey, requestId, 30, TimeUnit.SECONDS); if (locked) { try { // 执行业务逻辑,比如扣减库存、发红包、支付回调处理 } finally { // 释放锁 String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end"; redisTemplate.execute(new DefaultRedisScript<>(script, Long.class), Arrays.asList(lockKey), requestId); } }

有两个细节必须注意:第一,锁的value必须是唯一的请求标识(requestId)。为什么?因为你释放锁的时候,得确认这把锁是自己的,而不是别的线程的。如果不判断,可能发生这种情况:A线程加锁后业务执行时间太长,锁过期了;B线程抢到锁开始执行业务;A线程业务执行完,直接把锁删除。结果就是B线程的锁被A删了,C线程又进来加锁,并发问题就出现了。用requestId+Lua脚本判断,就是为了解决“不能删别人的锁”。

第二,释放锁必须用Lua脚本保证原子性。先判断再删除,这两步如果分开做,中间可能被其他线程插入操作,也会出问题。写成一段Lua脚本让Redis原子执行,就避免了这种竞争。

3.2 锁过期时间怎么设定?这是一个trade-off

锁的过期时间设短了,业务没执行完锁就过期了,其他线程趁虚而入;设长了,如果持有锁的线程挂了,其他线程要等很久才能拿到锁。怎么取舍?

我常用的思路是:

  • 估算业务的最大执行时间,然后在这个基础上乘以2倍作为过期时间。
  • 对于不确定的业务,我会在获取锁之后启动一个看门狗线程(类似于Redisson的原理),定时续期。比如每10秒检查一次,如果锁还在,就延长过期时间。这个线程在finally块里一定要被中断,否则会一直续期。

如果你不想自己实现看门狗,直接用Redisson就完事了。Redisson的RLock已经封装好了一套成熟机制,而且还支持可重入锁、公平锁、读写锁,比自己写SETNX要省心得多。但在核心业务上用Redisson要注意,它默认加的锁是默认过期时间30秒,看门狗每10秒续期一次,这套机制是合理的。

3.3 什么场景才需要分布式锁?

不是所有并发场景都需要锁。我给一个判断标准:如果业务允许最终一致,优先用其他的并发控制手段,而不是锁。

比如,扣减库存,你完全可以用Redis的DECR命令原子扣减,扣到负数就返回失败,根本不需要加锁。再比如,防止用户重复提交订单,你可以用一个短的过期时间去重标记,用setIfAbsent做幂等控制,效果跟锁一样,但性能更好。

锁的使用原则:锁是用来保护共享资源的,不是用来串行化所有操作的。能用原子操作解决的,不用锁;能用乐观锁解决的,悲观锁要谨慎用;要真的用锁,也尽量缩小锁的粒度。

4. 高性能计数器与限流:Redis的原子性有多好用?

Redis是单线程模型,命令执行是串行的,因此它在处理简单数据类型的操作时天然具有原子性。这种特性做计数器、做限流器,简直是量身定做。

4.1 计数器:不只是点赞数这么简单

最常见的就是点赞、收藏、阅读数。拿帖子详情来说,你不能每访问一次就更新一次MySQL,那样扛不住。通常方案是先用Redis的INCR命令累加,然后定时(比如每5分钟)异步同步到MySQL。

// 用户访问帖子,阅读数+1 String viewCountKey = "post:viewcount:" + postId; Long viewCount = redisTemplate.opsForValue().increment(viewCountKey);

INCR和INCRBY都是原子操作,即便有10万个并发请求同时过来,Redis也能保证每个请求都准确累加,不会出现加少、加错的问题。

还有一个使用技巧:当计数器需要归零或重置时,用EXPIRE设置key的过期时间。比如统计某一天的用户访问量:

String dailyVisitKey = "visit:count:" + localDate; redisTemplate.opsForValue().increment(dailyVisitKey); redisTemplate.expire(dailyVisitKey, 1, TimeUnit.DAYS); // 第二天这个key自动过期,新的一天从0开始计数

4.2 限流:用Redis做一个简单的滑动窗口

限流是保护系统不被突发流量打垮的常用手段。我常用的一个方式是滑动窗口限流,用Redis的ZSet实现。

逻辑是这样的:每个用户(或每个IP)用一个ZSet,score存入当前时间戳,member存入唯一标识(可以是一个自增序列或UUID)。每次请求进来时:

  1. 移除窗口之外的时间点数据(比如1秒窗口,就移除60秒之前的所有数据)
  2. 计算当前窗口内的请求数
  3. 如果超过阈值则拒绝,否则加入ZSet放行
public boolean isAllowed(String key, int maxCount, long windowSeconds) { long now = System.currentTimeMillis(); long windowStart = now - windowSeconds * 1000; // 1. 清除窗口外的记录 redisTemplate.opsForZSet().removeRangeByScore(key, 0, windowStart); // 2. 统计当前窗口内请求数 Long count = redisTemplate.opsForZSet().zCard(key); // 3. 判断是否超过阈值 if (count != null && count >= maxCount) { return false; } // 4. 记录当前请求 redisTemplate.opsForZSet().add(key, UUID.randomUUID().toString(), now); redisTemplate.expire(key, windowSeconds, TimeUnit.SECONDS); return true; }

这段代码比INCR+EXPIRE的固定窗口限流要好,因为它考虑到了窗口边界问题。比如固定窗口每秒钟允许100个请求,你在第999毫秒发了100个请求,第1000毫秒又能发100个,这个瞬间就200个了。滑动窗口则平滑地统计最近1秒内的请求数,更精确。

4.3 限流的进阶玩法:令牌桶算法的Redis实现

滑动窗口适合控制QPS,但无法应对突发流量。如果业务希望支持一定的突发流量(比如允许瞬间冲一下,但长期均值可控),令牌桶算法更合适。

令牌桶的原理是:系统按固定速率向桶里放令牌,桶的容量有限;请求每次需要拿到一个令牌才能通过;桶满的时候令牌丢弃,桶空的时候请求被拒绝。

用Redis实现,可以这样设计:

  • key存储当前令牌数
  • 每次请求时,计算从上次请求到现在应该产生的令牌数,更新当前令牌数
  • 如果能拿到令牌,DECR,否则拒绝

这个实现稍微复杂,但好处是流量控制更平滑。不过在实际项目中,更推荐直接用现成的分布式限流组件,比如Sentinel配合Redis,或者Gateway层面的限流,它们内部已经是成熟的令牌桶算法了,没必要重复造轮子。

5. 排行榜与最新列表:有序集合的用武之地

排行榜是Redis的招牌功能之一。以前用MySQL做排行榜,每次查询都要ORDER BY score DESC LIMIT 10,数据量大了之后索引和查询压力都很大。Redis的ZSet(有序集合)解决得干净利落。

5.1 积分排行榜:一个ZSet搞定

场景:游戏里玩家的战力排行,或者电商里的销量排行。

// 玩家战力更新 String rankKey = "game:rank:power"; redisTemplate.opsForZSet().add(rankKey, playerId, powerValue); // 查询前10名 Set<String> top10 = redisTemplate.opsForZSet().reverseRange(rankKey, 0, 9); // 查询某个玩家排名 Long rank = redisTemplate.opsForZSet().reverseRank(rankKey, playerId);

ZSet内部是跳表(Skip List)结构,插入和查询的时间复杂度都是O(log n),性能非常棒。而且它天生就是按score排序的,完全不用额外排序,拿到的就是正确顺序。

很多人在用ZSet做排行榜时忽略的一个问题是:并列排名处理。reverseRange只返回一个位置序列,如果两个玩家积分一样,根据插入顺序就会有先后排名,但实际上应该并列。这时候可以用ZSCAN结合分数统计来处理,或者在前端展示时做“并列排名修正”。

5.2 最新列表:时间戳当score,完美实现“最近N条”

ZSet不只是能做积分排名。把score设置为时间戳,就能实现“最新列表”。

比如,一个用户发布了一篇文章,你希望首页展示他最近发布的10篇文章。可以用ZSet存储该用户的文章ID,score是发布时间:

String userArticlesKey = "user:articles:" + userId; redisTemplate.opsForZSet().add(userArticlesKey, articleId, System.currentTimeMillis()); // 查询最近发布的10篇 Set<String> latestArticles = redisTemplate.opsForZSet().reverseRange(userArticlesKey, 0, 9);

这样做的好处是,文章列表的排序完全由Redis承担,而MySQL只需要存储完整数据,无需承担高频的列表查询压力。更新、删除也很方便,ZSet的remove命令按member删除。

5.3 用ZSet做延时队列

这是个隐藏用法。把任务放在ZSet里,score设置为任务的执行时间戳。一个后台线程定时用rangeByScore查一下:

long now = System.currentTimeMillis(); Set<String> todoTasks = redisTemplate.opsForZSet() .rangeByScore("delay:queue", 0, now);

查出来的就是到了执行时间的任务。处理完之后用remove移除,未到时间的任务自动留在ZSet里等待下次扫描。这种方式适合做订单超时自动取消、定时通知等逻辑。相比MQ的延迟队列,它胜在轻量,不需要额外的消息中间件;缺点是没有重试、死信队列这些功能。所以看场景,轻量级任务用它很合适,复杂任务还是老老实实上MQ。

6. 轻量消息队列与发布订阅:不同Instance之间的通信方式

有人会说:“消息队列我有RabbitMQ、Kafka,为什么还要用Redis?”确实,Redis不适合做重型消息中间件,它没有消息持久化的隔离、没有复杂的路由、没有消息确认机制。但在某些轻量场景下,Redis的PUB/SUB和Stream能解决很多实际问题,而且部署成本为零。

6.1 PUB/SUB:一对多实时通知

场景:用户下单后,系统需要通知库存服务扣减库存、通知积分服务增加积分、通知短信服务发短信。用Redis的发布订阅,订单服务作为发布者,其他服务作为订阅者。

// 发布消息 redisTemplate.convertAndSend("order:created", orderDTO); // 订阅消息(配置一个监听器容器) @Bean RedisMessageListenerContainer container(RedisConnectionFactory connectionFactory, MessageListenerAdapter listenerAdapter) { RedisMessageListenerContainer container = new RedisMessageListenerContainer(); container.setConnectionFactory(connectionFactory); container.addMessageListener(listenerAdapter, new ChannelTopic("order:created")); return container; } // 监听器处理逻辑 public void onMessage(Message message, byte[] pattern) { String orderJson = new String(message.getBody(), StandardCharsets.UTF_8); // 反序列化并处理业务逻辑 }

但这个方案有个致命问题:Redis的PUB/SUB模式不持久化消息。如果订阅者当时不在线(重启、宕机、网络分区),消息直接丢失。所以它只适合做“实时通知”这种丢失了也无所谓的场景,不适合做“必须送达”的场景。

6.2 Stream:带持久化的轻量消息模式

从Redis 5.0开始引入了Stream,这是一个支持持久化的消息队列模型,用起来更像传统的MQ了。

// 生产者:发布消息 redisTemplate.opsForStream().add("stream:order", Map.of("orderId", "12345", "userId", "8888")); // 消费者:读取消息 List<MapRecord<String, Object, Object>> messages = redisTemplate.opsForStream() .read(Consumer.from("group-a", "consumer-1"), StreamOffset.create("stream:order", ReadOffset.lastConsumed()));

Stream里有消费组的概念,支持消息确认、消费者组内的消息分发,比PUB/SUB靠谱得多。如果你的项目里消息量不大,不想额外引入Kafka或者RabbitMQ,用Stream做轻量级异步解耦是一个不错的选择。

6.3 Java程序员的实际选择建议

我在项目里做技术选型时,遵循这样一个原则:

消息需求特点推荐方案
实时推送、可丢失、不重要的通知Redis PUB/SUB
异步处理、需要持久化、但量不大Redis Stream
高吞吐量、持久化、路由复杂、重要业务RabbitMQ / Kafka
延迟消息、任务调度ZSet延时队列 / 专业MQ插件

不要一上来就上重量级组件。能用Redis解决的轻量问题,用了Redis,系统架构就简单一分;反之,如果业务要求“消息不丢”这种强一致,就别硬用Redis,老老实实上MQ。

7. 实战梳理:一个典型业务场景下的Redis应用全貌

前面说了那么多Redis的用法,可能还是有人觉得零散。我用一个典型的电商下单场景,把Redis的各种能力串起来,让你直观感受一下一个真实业务里怎么组合运用这些技术。

模拟一个用户下订单的完整链路:

第一步:用户访问商品详情页

  • 商品基本信息、库存数量、价格这些热点数据放在Redis缓存里(用Hash存储商品维度,用String存储库存),查询走缓存不压数据库。
  • 商品详情页阅读数用INCRBY累加,定期同步到MySQL。

第二步:点击“立即购买”

  • 后端收到下单请求,先做防重处理:用SETNX设置一个订单防重key,过期时间5秒。如果设置成功才继续处理,否则返回“请勿重复提交”。
  • 执行库存扣减:用DECR命令扣减Redis中的库存,扣减后如果是负数,说明库存不足,返回失败并INCR恢复。
  • 扣减成功生成订单号(可以用Redis的INCR生成自增序列),然后异步往Stream里发一条“订单创建成功”的消息。

第三步:多个服务响应订单事件

  • 库存服务订阅Stream里的下单事件,减MySQL的库存。
  • 积分服务订阅Stream,给用户增加积分。
  • 通知服务消费消息,发送短信给用户。

第四步:订单支付倒计时

  • 订单创建成功后,往ZSet延时队列放一条消息,score是“30分钟后”的时间戳。
  • 一个定时任务每10秒扫描一次延时队列,如果发现已到时间的订单还没支付,就发一条消息提醒用户,或者自动取消订单。

第五步:首页排行榜更新

  • 用户支付成功,商家销量加1,通过INCRBY更新销量。
  • 销量排行ZSet里更新商家的销售额。

第六步:接口限流

  • 在整个下单接口前面加了一层滑动窗口限流,每个用户每秒最多下单3次,防止被脚本秒杀接口或恶意刷单。

看下来你会发现,这一个业务里,Redis承担了缓存、分布式锁、原子计数器、延时队列、消息通信、限流六个角色,每一个角色都不可或缺,而且用Redis解决这些问题是成本最低、代码最简单的方式。

8. 从“会用”到“用好”:我的一些实操心得和避坑建议

我踩过不少坑,也见过不少同行踩坑。这里分享几个实在的经验,希望能帮你少走弯路。

8.1 数据序列化问题

Java客户端(比如Spring Data Redis)操作Redis时,数据的序列化方式非常关键。我见过有人因为默认序列化方式不对,导致在Redis Desktop Manager里看到的value全是乱码,排查半天才发现是序列化配置问题。

我的建议是:

  • key使用String序列化,value使用JSON序列化,这样可读性最好,也方便其他系统接入。
  • 如果追求极致性能,可以用protobuf等二进制序列化,但牺牲的可读性很大,付出代价不一定值得。

8.2 用管道(Pipeline)批量操作

一个Redis命令一次网络往返。如果你在循环里执行1000次SET命令,就要1000次网络往返,耗时可能是秒级。如果用Pipeline:

redisTemplate.executePipelined((RedisCallback<Object>) connection -> { for (int i = 0; i < 1000; i++) { connection.stringCommands().set( ("key:" + i).getBytes(), ("value:" + i).getBytes()); } return null; });

一次网络往返发一批命令,性能提升极其明显,特别是批量初始化缓存、批量写排行榜数据的时候。

但要注意,Pipeline不保证原子性,如果你需要原子性,得用Lua脚本或者MULTI/EXEC事务。

8.3 监控key的数量和内存,别不做“瘦身”

很多Java程序员只管往Redis里塞数据,从不清理。时间一长,Redis内存飙升,key数量爆炸,查询性能下降,最终影响业务。我处理过几次这种事,真的头疼。

常规做法:

  • 给所有key设置合理的过期时间,不要设置永不过期(除非它是长期有效的配置数据)。
  • 定期用SCAN扫描大key,清理无用数据。不要用KEYS *,生产环境直接卡死Redis。
  • 配置Redis最大内存和淘汰策略(比如allkeys-lru),防止内存被打满。

8.4 连接管理:不要每次操作都新建连接

Java项目里用连接池是标配,但连接池的参数要调好。maxTotal设太大,Redis服务端连接数会顶不住;设太小,高并发时线程会阻塞在获取连接上。我给的保守建议:maxTotal可以设为CPU核心数 * 2个左右,maxIdle可以设为CPU核心数个左右,minIdle设为0或者1。具体数值结合压测结果微调,别原封不动套别人的配置。

8.5 线上问题先看慢日志

Redis日志里有个“慢查询”记录功能,默认超过10毫秒的命令会被记录下来。当线上出问题时,先查一下SLOWLOG GET,看看是哪些命令耗时高。一个常见的坑是使用KEYS命令或者一次HGETALL一个大hash,这种命令会在Redis单线程模型里卡住后续所有命令,造成整个Redis服务“假死”。线上排查的时候,这个方向一定要先看。

9. 写在最后的个人感受

Redis对Java程序员来说,绝对不只是“拿来缓存一下”的工具。它是一个内存数据结构的宝库,用好了,你就是团队里最能解决高并发问题的那个;用不好,充其量是个会用注解调接口的调用方。

我自己平时写代码的习惯是:碰到一个性能或者并发问题,先想一想Redis能不能解决,再去想其他重型组件。Redis解决不了的,再上其他方案。这种思路帮我砍掉了很多不必要的技术架构,让系统的复杂度一直在可控范围内。

最后再分享一个我特别喜欢的小技巧:用Redis的Hash存储一张表的一行数据。比如用户表,每个user一个hash,field对应列名,value对应列值。这样查询单个用户所有字段,一次HGETALL就能搞定,而且内存占用远低于存JSON字符串。在需要高频读取单条记录的场景下,这个方案极其高效。

希望这篇文章能让你对Redis有一个更立体的认识。如果以后在项目里遇到类似的问题,可以多想一想:“这个需求,能不能用Redis优雅解决?”答案可能比你想象的更丰富。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/13 1:07:13

数字痕迹解剖学:从JPEG文件结构逆向提取隐藏信息

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/13 1:06:59

Hexo写作实操:从文章创建到草稿发布与模板配置

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/13 1:05:31

ArcGIS Engine地图控件底层原理与C#实战优化

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/13 1:05:09

SpringBoot+Jenkins+Docker自动化部署闭环实践

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/13 1:04:35

STM32嵌入式开发AI协同实战:从代码生成到调试避坑

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华