上一篇博客写完学习进度统计功能,留了个尾巴:前端每 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 四个知识领域。
下一个模块打算看看互动问答(评论)相关的功能,那里应该有树形结构、热点数据缓存这些技术点可以挖。