用 Redis 合并写 + DelayQueue 延迟检测,把视频播放进度写库频率降到 1/60

上一篇博客写完学习进度统计功能,留了个尾巴:前端每 15 秒提交一次播放进度直接写数据库,高并发下扛不住。这篇记录我是怎么用"合并写"思路把数据库写频率降下来的:Redis Hash 缓存播放进度 + JDK 的 DelayQueue 做延迟检测 + 只在用户停止播放时才落库。核心思想是------95% 的提交都是"覆盖式"的中间进度,只有最后一次提交才有必要写数据库。

一、高并发优化的三个方向和写优化的三种方案

先建立宏观认知。碰到高并发问题,从系统层面看只有三条路:

水平扩展:加服务器、分片、负载均衡。运维的事。

服务保护:限流、熔断、降级。也是运维和架构层面的手段。

提高单机并发能力:缩短单个请求的响应时间(RT),让一台机器能处理更多请求。这才是程序员写代码要解决的问题。

要提高单机并发,就要看瓶颈在哪。对绝大部分业务来说,瓶颈都在数据库。数据库操作分读和写两类,读优化大家都很熟------缓存、SQL 优化、索引。写优化很多人不熟悉,这篇就是这个。

高并发写有三种方案:

方案 核心思路 优点 缺点 适用场景
代码/SQL 优化 减少单次操作的耗时 简单 效果有限 通用
变同步为异步 MQ 缓冲,先返回再处理 削峰、缩短 RT 没减少写次数,只是降低频率 业务链长、多次写库
合并写请求 Redis 缓存 + 批量落库 既降频率又降次数 实现复杂、依赖 Redis 高频覆盖式写入

异步写之前领券、点赞那几篇都用过了。这篇的重点是合并写------一个用得少但威力大的方案。

二、异步写还是合并写:判断依据是"数据可不可覆盖"

回到播放进度业务:用户看一个 20 分钟的视频,每 15 秒提交一次,总共提交 80 次。但下一次续播时,只有最后一次提交的进度有意义------中间那 79 次全是"过时的、被覆盖的"数据。

这就是决策依据:

如果业务是"每次都要独立处理"(比如领券、下单),用异步写。MQ 只是让请求快点返回,每个消息还是要消费落库。

如果业务是"后续覆盖前面的"(比如播放进度、位置上报、计数器),用合并写。Redis 缓存最新值,中间过程完全不需要落库,数据库写次数直接减少一到两个数量级。

播放进度属于后者,选合并写。
#mermaid-svg-LkM8fKrsXFhbpf55{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-LkM8fKrsXFhbpf55 .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-LkM8fKrsXFhbpf55 .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-LkM8fKrsXFhbpf55 .error-icon{fill:#552222;}#mermaid-svg-LkM8fKrsXFhbpf55 .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-LkM8fKrsXFhbpf55 .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-LkM8fKrsXFhbpf55 .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-LkM8fKrsXFhbpf55 .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-LkM8fKrsXFhbpf55 .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-LkM8fKrsXFhbpf55 .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-LkM8fKrsXFhbpf55 .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-LkM8fKrsXFhbpf55 .marker{fill:#333333;stroke:#333333;}#mermaid-svg-LkM8fKrsXFhbpf55 .marker.cross{stroke:#333333;}#mermaid-svg-LkM8fKrsXFhbpf55 svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-LkM8fKrsXFhbpf55 p{margin:0;}#mermaid-svg-LkM8fKrsXFhbpf55 .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-LkM8fKrsXFhbpf55 .cluster-label text{fill:#333;}#mermaid-svg-LkM8fKrsXFhbpf55 .cluster-label span{color:#333;}#mermaid-svg-LkM8fKrsXFhbpf55 .cluster-label span p{background-color:transparent;}#mermaid-svg-LkM8fKrsXFhbpf55 .label text,#mermaid-svg-LkM8fKrsXFhbpf55 span{fill:#333;color:#333;}#mermaid-svg-LkM8fKrsXFhbpf55 .node rect,#mermaid-svg-LkM8fKrsXFhbpf55 .node circle,#mermaid-svg-LkM8fKrsXFhbpf55 .node ellipse,#mermaid-svg-LkM8fKrsXFhbpf55 .node polygon,#mermaid-svg-LkM8fKrsXFhbpf55 .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-LkM8fKrsXFhbpf55 .rough-node .label text,#mermaid-svg-LkM8fKrsXFhbpf55 .node .label text,#mermaid-svg-LkM8fKrsXFhbpf55 .image-shape .label,#mermaid-svg-LkM8fKrsXFhbpf55 .icon-shape .label{text-anchor:middle;}#mermaid-svg-LkM8fKrsXFhbpf55 .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-LkM8fKrsXFhbpf55 .rough-node .label,#mermaid-svg-LkM8fKrsXFhbpf55 .node .label,#mermaid-svg-LkM8fKrsXFhbpf55 .image-shape .label,#mermaid-svg-LkM8fKrsXFhbpf55 .icon-shape .label{text-align:center;}#mermaid-svg-LkM8fKrsXFhbpf55 .node.clickable{cursor:pointer;}#mermaid-svg-LkM8fKrsXFhbpf55 .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-LkM8fKrsXFhbpf55 .arrowheadPath{fill:#333333;}#mermaid-svg-LkM8fKrsXFhbpf55 .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-LkM8fKrsXFhbpf55 .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-LkM8fKrsXFhbpf55 .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-LkM8fKrsXFhbpf55 .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-LkM8fKrsXFhbpf55 .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-LkM8fKrsXFhbpf55 .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-LkM8fKrsXFhbpf55 .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-LkM8fKrsXFhbpf55 .cluster text{fill:#333;}#mermaid-svg-LkM8fKrsXFhbpf55 .cluster span{color:#333;}#mermaid-svg-LkM8fKrsXFhbpf55 div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-LkM8fKrsXFhbpf55 .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-LkM8fKrsXFhbpf55 rect.text{fill:none;stroke-width:0;}#mermaid-svg-LkM8fKrsXFhbpf55 .icon-shape,#mermaid-svg-LkM8fKrsXFhbpf55 .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-LkM8fKrsXFhbpf55 .icon-shape p,#mermaid-svg-LkM8fKrsXFhbpf55 .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-LkM8fKrsXFhbpf55 .icon-shape .label rect,#mermaid-svg-LkM8fKrsXFhbpf55 .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-LkM8fKrsXFhbpf55 .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-LkM8fKrsXFhbpf55 .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-LkM8fKrsXFhbpf55 :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 不一致:还在播放
一致:停止播放
📱 前端心跳

每15秒一次
💾 写Redis

Hash 缓存进度
⏱️ 提交延迟任务

20秒后检测
🔍 20秒后

缓存值 = 任务值?
忽略,等下次心跳
💾 落库

UPDATE 学习记录+课表

这张图说的是整个合并写的核心流程:前端提交先写 Redis 缓存(不碰数据库),同时提交一个 20 秒的延迟任务。任务到期后对比缓存值和任务记录值------如果变了说明用户还在看,忽略;如果没变说明用户停止播放了,这时才把最新进度写到数据库。原本 80 次心跳 80 次写库,变成了 80 次写 Redis + 1 次写数据库。

三、Redis 数据结构设计:为什么按课程分组

要缓存的信息有三个字段:记录 id(用于反查数据库)、播放进度 moment、是否学完 finished。三个字段一个对象,Redis 里怎么存?

最直觉的方案是一个小节一个 Hash Key:

复制代码
KEY: learning:record:{sectionId}:{userId}
FIELD: id / moment / finished

但这样有两个问题。第一,一个课程几十个章节,用户看几个视频就创建几个 Key,Redis 的 Key 本身有内存开销。第二,用户在几个视频之间来回切换,每个 Key 单独设置过期时间,会造成"缓存抖动"------刚建好就过期,过期又建。

最终采用按课程分组的方案:

复制代码
KEY: learning:record:{lessonId}
FIELD: {sectionId}
VALUE: {"id":xxx, "moment":242, "finished":false}

一个课表(lessonId)一个 Hash Key,所有小节作为 Hash 的 Field 存进去。
#mermaid-svg-0e599B6BCvg6k6pS{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-0e599B6BCvg6k6pS .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-0e599B6BCvg6k6pS .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-0e599B6BCvg6k6pS .error-icon{fill:#552222;}#mermaid-svg-0e599B6BCvg6k6pS .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-0e599B6BCvg6k6pS .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-0e599B6BCvg6k6pS .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-0e599B6BCvg6k6pS .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-0e599B6BCvg6k6pS .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-0e599B6BCvg6k6pS .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-0e599B6BCvg6k6pS .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-0e599B6BCvg6k6pS .marker{fill:#333333;stroke:#333333;}#mermaid-svg-0e599B6BCvg6k6pS .marker.cross{stroke:#333333;}#mermaid-svg-0e599B6BCvg6k6pS svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-0e599B6BCvg6k6pS p{margin:0;}#mermaid-svg-0e599B6BCvg6k6pS .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-0e599B6BCvg6k6pS .cluster-label text{fill:#333;}#mermaid-svg-0e599B6BCvg6k6pS .cluster-label span{color:#333;}#mermaid-svg-0e599B6BCvg6k6pS .cluster-label span p{background-color:transparent;}#mermaid-svg-0e599B6BCvg6k6pS .label text,#mermaid-svg-0e599B6BCvg6k6pS span{fill:#333;color:#333;}#mermaid-svg-0e599B6BCvg6k6pS .node rect,#mermaid-svg-0e599B6BCvg6k6pS .node circle,#mermaid-svg-0e599B6BCvg6k6pS .node ellipse,#mermaid-svg-0e599B6BCvg6k6pS .node polygon,#mermaid-svg-0e599B6BCvg6k6pS .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-0e599B6BCvg6k6pS .rough-node .label text,#mermaid-svg-0e599B6BCvg6k6pS .node .label text,#mermaid-svg-0e599B6BCvg6k6pS .image-shape .label,#mermaid-svg-0e599B6BCvg6k6pS .icon-shape .label{text-anchor:middle;}#mermaid-svg-0e599B6BCvg6k6pS .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-0e599B6BCvg6k6pS .rough-node .label,#mermaid-svg-0e599B6BCvg6k6pS .node .label,#mermaid-svg-0e599B6BCvg6k6pS .image-shape .label,#mermaid-svg-0e599B6BCvg6k6pS .icon-shape .label{text-align:center;}#mermaid-svg-0e599B6BCvg6k6pS .node.clickable{cursor:pointer;}#mermaid-svg-0e599B6BCvg6k6pS .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-0e599B6BCvg6k6pS .arrowheadPath{fill:#333333;}#mermaid-svg-0e599B6BCvg6k6pS .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-0e599B6BCvg6k6pS .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-0e599B6BCvg6k6pS .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-0e599B6BCvg6k6pS .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-0e599B6BCvg6k6pS .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-0e599B6BCvg6k6pS .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-0e599B6BCvg6k6pS .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-0e599B6BCvg6k6pS .cluster text{fill:#333;}#mermaid-svg-0e599B6BCvg6k6pS .cluster span{color:#333;}#mermaid-svg-0e599B6BCvg6k6pS div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-0e599B6BCvg6k6pS .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-0e599B6BCvg6k6pS rect.text{fill:none;stroke-width:0;}#mermaid-svg-0e599B6BCvg6k6pS .icon-shape,#mermaid-svg-0e599B6BCvg6k6pS .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-0e599B6BCvg6k6pS .icon-shape p,#mermaid-svg-0e599B6BCvg6k6pS .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-0e599B6BCvg6k6pS .icon-shape .label rect,#mermaid-svg-0e599B6BCvg6k6pS .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-0e599B6BCvg6k6pS .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-0e599B6BCvg6k6pS .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-0e599B6BCvg6k6pS :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 📚 Key: learning:record:1001

(一门课程)
🎬 Field: 5001

{moment:242, finished:true}
🎬 Field: 5002

{moment:20, finished:false}
🎬 Field: 5003

{moment:121, finished:false}
🎬 Field: 5004

{moment:80, finished:false}

这样设计带来两个好处:Key 数量少 (从"小节数"降到"课程数"),TTL 统一续期(用户在同一课程的不同视频间跳转,整个 Key 的过期时间都会被续上,避免频繁创建销毁)。

写入代码:

java 复制代码
public void writeRecordCache(LearningRecord record) {
    String key = StringUtils.format(RECORD_KEY_TEMPLATE, record.getLessonId());
    String json = JsonUtils.toJsonStr(new RecordCacheData(record));
    redisTemplate.opsForHash().put(key, record.getSectionId().toString(), json);
    redisTemplate.expire(key, Duration.ofMinutes(1));
}

只缓存三个字段:id、moment、finished。字段少意味着网络传输少,Redis 内存占用少。缓存结构应该按业务需求裁剪,不要把整个 PO 一股脑塞进去。

四、DelayQueue 原理:PriorityQueue + 阻塞队列

合并写的核心难点是什么时候落库。最直觉的方案是定时任务:每 N 秒扫一遍 Redis,把该落库的数据写到数据库。

但定时任务有个致命问题------时效性和压力不可兼得。

产品要求续播误差控制在 30 秒内。定时任务如果设 20 秒一次,压力大(数据库每秒都在被批量刷);设 2 分钟一次,压力大是解决了,但误差可能到 2 分钟,产品不接受。

换个角度想这个问题:用户每 15 秒提交一次进度,如果 Redis 中的进度值 15 秒内没变,说明用户已经停止播放了------这时候把最新进度落库就行了。不需要"定期刷",只需要"检测到用户不再提交时刷一次"。

这就是延迟任务的思路。项目用的是 JDK 自带的 DelayQueue

看它的源码:

java 复制代码
public class DelayQueue<E extends Delayed> 
    extends AbstractQueue<E> implements BlockingQueue<E> {
    
    private final transient ReentrantLock lock = new ReentrantLock();
    private final PriorityQueue<E> q = new PriorityQueue<E>();
    // ...
}

拆开来看,DelayQueue 内部是三个组件的组合:

组件 作用
PriorityQueue 存储任务,按到期时间排序------最早到期的排最前
ReentrantLock 保证队列线程安全
BlockingQueue 语义 take() 阻塞等待,直到有到期任务

存入的元素必须实现 Delayed 接口,两个方法:

java 复制代码
public interface Delayed extends Comparable<Delayed> {
    long getDelay(TimeUnit unit);  // 剩余延迟时间
    int compareTo(Delayed o);      // 任务之间比大小
}

PriorityQueue 靠 compareTo 排序,越靠近到期的越靠队首。take() 方法内部会检查队首元素的 getDelay,如果大于 0 就继续等待,等于 0 就弹出。

项目里定义了一个通用延迟任务类:

java 复制代码
@Data
public class DelayTask<D> implements Delayed {
    private D data;                    // 携带业务数据
    private long deadlineNanos;        // 到期时间(纳秒)

    public DelayTask(D data, Duration delayTime) {
        this.data = data;
        this.deadlineNanos = System.nanoTime() + delayTime.toNanos();
    }

    @Override
    public long getDelay(TimeUnit unit) {
        return unit.convert(
            Math.max(0, deadlineNanos - System.nanoTime()), 
            TimeUnit.NANOSECONDS);
    }

    @Override
    public int compareTo(Delayed o) {
        long diff = getDelay(TimeUnit.NANOSECONDS) 
                  - o.getDelay(TimeUnit.NANOSECONDS);
        return Long.compare(diff, 0);
    }
}

用泛型 D data 携带业务数据 是这里的一个小技巧。这样 DelayTask 就成了一个通用容器,任何"延迟执行的东西"都能装进去------本项目装的是 {lessonId, sectionId, moment}

延迟任务方案有四种,横向对比一下:

方案 原理 优点 缺点
DelayQueue JDK 阻塞队列 + 优先级队列 无第三方依赖、单机可用 占 JVM 内存
Redisson Redis SortedSet + 发布订阅 分布式、不占 JVM 依赖 Redis
MQ 死信队列 TTL 到期转入死信队列 分布式、不占 JVM 依赖 MQ
时间轮 Kafka/Netty 的时间轮算法 高精度、大量任务 实现复杂

本项目任务存储时间只有 20 秒,任务量可控,DelayQueue 简单直接,够用。如果生产环境集群化严重、任务量大,换成 Redisson 或 MQ 死信也很容易------把工具类里的 DelayQueue 替换掉,其他逻辑不用改。

五、延迟检测持久化:一次巧妙的"值对比"

DelayQueue 用起来了,但还有一个业务细节要想清楚:延迟任务到期后,怎么判断用户是不是停止播放了?

方案是这样的:每次心跳提交进度时,同时提交一个 20 秒的延迟任务,任务里记录"这次提交的 moment 值"。20 秒后任务到期,取出 Redis 中当前的 moment 值和任务里记的值对比:

  • 不相等:说明这 20 秒内又有新的提交(用户还在看),任务直接放弃
  • 相等:说明这 20 秒没有任何新提交(用户已经关了视频/切走了),才落库

代码里核心逻辑:

java 复制代码
public void handleDelayTask() {
    while (begin) {
        try {
            // 1.获取到期的延迟任务(queue.take 会阻塞等待)
            DelayTask<RecordTaskData> task = queue.take();
            RecordTaskData data = task.getData();
            
            // 2.查询 Redis 缓存
            LearningRecord record = readRecordCache(
                data.getLessonId(), data.getSectionId());
            if (record == null) continue;
            
            // 3.比较 moment 值
            if (!Objects.equals(data.getMoment(), record.getMoment())) {
                // 不一致:还在持续提交,忽略这个任务
                continue;
            }
            
            // 4.一致:说明是最后一次,落库
            record.setFinished(null);
            recordMapper.updateById(record);  // 更新学习记录
            
            LearningLesson lesson = new LearningLesson();
            lesson.setId(data.getLessonId());
            lesson.setLatestSectionId(data.getSectionId());
            lesson.setLatestLearnTime(LocalDateTime.now());
            lessonService.updateById(lesson); // 更新课表
        } catch (Exception e) {
            log.error("处理延迟任务异常", e);
        }
    }
}

"设置 finished = null 后再 updateById" 这个小技巧值得说一下。MyBatis Plus 的 updateById 会忽略 null 字段------这里手动把 finished 设为 null,避免在延迟任务里意外把已经 true 的 finished 又写回 true 触发额外的更新。

延迟任务处理器本身是一个 Spring 组件,用 @PostConstruct 在启动时开一个线程持续消费队列:

java 复制代码
@PostConstruct
public void init() {
    CompletableFuture.runAsync(this::handleDelayTask);
}

@PreDestroy
public void destroy() {
    begin = false;  // 优雅关闭,让 while 循环退出
}

这里练习部分还留了一道题 :目前是单线程消费队列,生产环境要改成线程池模式。把 CompletableFuture.runAsync 换成一个大小固定的线程池,每个线程跑一份 handleDelayTask 循环即可,因为 queue.take() 本身是阻塞的,多线程并发 take 是安全的。

六、改造后的完整业务流程

把缓存、延迟任务、原有逻辑串起来,改造后的提交流程长这样:
🗄️ 数据库 ⏱️ DelayQueue 💾 Redis 💼 服务端 📱 前端 🗄️ 数据库 ⏱️ DelayQueue 💾 Redis 💼 服务端 📱 前端 #mermaid-svg-6rbsBJT5qJPaXldL{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-6rbsBJT5qJPaXldL .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-6rbsBJT5qJPaXldL .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-6rbsBJT5qJPaXldL .error-icon{fill:#552222;}#mermaid-svg-6rbsBJT5qJPaXldL .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-6rbsBJT5qJPaXldL .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-6rbsBJT5qJPaXldL .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-6rbsBJT5qJPaXldL .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-6rbsBJT5qJPaXldL .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-6rbsBJT5qJPaXldL .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-6rbsBJT5qJPaXldL .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-6rbsBJT5qJPaXldL .marker{fill:#333333;stroke:#333333;}#mermaid-svg-6rbsBJT5qJPaXldL .marker.cross{stroke:#333333;}#mermaid-svg-6rbsBJT5qJPaXldL svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-6rbsBJT5qJPaXldL p{margin:0;}#mermaid-svg-6rbsBJT5qJPaXldL .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-6rbsBJT5qJPaXldL text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-6rbsBJT5qJPaXldL .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-6rbsBJT5qJPaXldL .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-6rbsBJT5qJPaXldL .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-6rbsBJT5qJPaXldL .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-6rbsBJT5qJPaXldL #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-6rbsBJT5qJPaXldL .sequenceNumber{fill:white;}#mermaid-svg-6rbsBJT5qJPaXldL #sequencenumber{fill:#333;}#mermaid-svg-6rbsBJT5qJPaXldL #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-6rbsBJT5qJPaXldL .messageText{fill:#333;stroke:none;}#mermaid-svg-6rbsBJT5qJPaXldL .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-6rbsBJT5qJPaXldL .labelText,#mermaid-svg-6rbsBJT5qJPaXldL .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-6rbsBJT5qJPaXldL .loopText,#mermaid-svg-6rbsBJT5qJPaXldL .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-6rbsBJT5qJPaXldL .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-6rbsBJT5qJPaXldL .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-6rbsBJT5qJPaXldL .noteText,#mermaid-svg-6rbsBJT5qJPaXldL .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-6rbsBJT5qJPaXldL .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-6rbsBJT5qJPaXldL .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-6rbsBJT5qJPaXldL .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-6rbsBJT5qJPaXldL .actorPopupMenu{position:absolute;}#mermaid-svg-6rbsBJT5qJPaXldL .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-6rbsBJT5qJPaXldL .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-6rbsBJT5qJPaXldL .actor-man circle,#mermaid-svg-6rbsBJT5qJPaXldL line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-6rbsBJT5qJPaXldL :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 20 秒后... 提交进度 moment=100查缓存命中(旧值=85)判断是否首次学完进度未达50%,不是更新缓存 moment=100提交延迟任务(moment=100, 20s)立即返回 ✅任务到期查缓存 moment100缓存值 = 任务值说明用户已停止播放UPDATE 学习记录UPDATE 课表最近学习信息

整条链路里,用户请求处理只涉及 Redis 读写和延迟任务提交,完全不碰数据库。数据库的写入被"用户停止播放"这个事件触发,从"每次心跳一次"变成"每次播放会话一次"。

首次学完是特殊路径 ------不能只写缓存,因为课表的 learned_sections +1、状态从"学习中"到"已学完"这些是不可覆盖 的状态变更,必须同步落库。改造后的代码在检测到 finished == true 时会同步写数据库并清缓存:

java 复制代码
if (!finished) {
    // 只更新进度:走缓存 + 延迟任务
    taskHandler.addLearningRecordTask(record);
    return false;
}

// 首次学完:同步落库
lambdaUpdate()
    .set(LearningRecord::getMoment, recordDTO.getMoment())
    .set(LearningRecord::getFinished, true)
    .set(LearningRecord::getFinishTime, recordDTO.getCommitTime())
    .eq(LearningRecord::getId, old.getId())
    .update();

// 清缓存:因为状态已经从 false 变 true,缓存里的是脏数据
taskHandler.cleanRecordCache(recordDTO.getLessonId(), recordDTO.getSectionId());
return true;

清缓存的动作很重要。因为延迟任务里读缓存时会拿到 finished=false(缓存里存的是旧状态),跟数据库里的 finished=true 不一致,会导致后续逻辑判断错误。所以首次学完落库后要立刻清缓存,让下次读的时候重新回源。

七、整体感受

这篇的东西我觉得是这几天学到的最有工程味的一段。合并写这个思路一旦掌握,能马上联想到很多应用场景:位置上报(外卖、打车、共享单车)、订单计数器、在线人数统计、股票价格刷新------所有"高频覆盖式写入"的业务都适合。

最大的收获是对**"数据的可覆盖性"** 这个判断标准的理解。以前碰到高并发写就是"上 MQ 异步",没想过还可以更进一步做合并。异步写只是"延迟了写",写次数没变;合并写是"消除中间态的写",写次数直接砍掉一到两个数量级。

另一个收获是 DelayQueue 的用法。之前只在面试八股里见过"阻塞队列 + 优先级队列"这种描述,这次真的用起来才发现它的泛型设计(Delayed 接口)有多巧妙------你只需要提供"剩余时间"和"排序规则",队列帮你搞定排序和阻塞等待。业务代码里一个 take() 就完事。

面试时这个功能我可以聊很久:视频续播需求 → 15 秒心跳 → 高频写库压力 → 为什么不用异步写 → 合并写方案 → Redis 结构选型 → DelayQueue 原理 → 值对比判定最后提交 → 首次学完特殊处理 → 缓存清理。一条链子串下来能覆盖缓存、并发、数据结构、JUC 四个知识领域。

下一个模块打算看看互动问答(评论)相关的功能,那里应该有树形结构、热点数据缓存这些技术点可以挖。

相关推荐
代码中介商2 小时前
C++ 预约系统实战(一):项目总览与 C/S 架构设计
c语言·开发语言·c++
youyou-06062 小时前
AUTOSAR‑COM 常见问题 QA (1)
java·linux·服务器·单片机·汽车
谢亮_vipxieliang2 小时前
ValidX时间段验证详解:ISO 8601标准与简化格式
java·spring boot·后端·spring·spring cloud·hibernate
Lsetea2 小时前
Java 请求 HTTPS 报 PKIX path building failed:从证书链到 truststore 的完整排查
java·https·ssl证书·keytool·truststore
我叫汪枫2 小时前
RAG 进阶:切分、检索、重排序,以及它和 Agent、微调、MCP 都是什么关系
开发语言·python
承渊政道2 小时前
PostgreSQL 鸿蒙 PC 适配全记录:从原生交叉编译到 HNP 数据库服务闭环
数据库·postgresql·harmonyos·鸿蒙系统·pc端
弘毅 失败的 mian2 小时前
数组和指针基础
c语言·开发语言·经验分享·笔记
风哥2号2 小时前
数据库教程FGMT26‑2-GoldenGate数据库容灾迁移02(OGG同构异构、数据库迁移、数据同步、容灾复制)
数据库·goldengate
牛油果子哥q2 小时前
向量数据库原理与工程选型:FAISS深度剖析、Milvus基础、检索优化、分片与持久化落地
数据库·milvus·faiss