待办事项提醒系统落地:数据库设计、调度器与幂等发送

发布时间:2026/10/9 23:16:18
待办事项提醒系统落地:数据库设计、调度器与幂等发送
简介这份待办事项提醒系统实现源码包面向计算机、软件工程等专业的课程设计、期末大作业与毕设场景提供一套可直接运行的完整项目参考。资源包含全部源码、项目说明文档与数据库脚本采用前后端分离结构后端以Java实现业务逻辑与接口前端使用Vue组件化开发并配有SQL建表与初始化数据便于快速理解任务管理、提醒调度等核心功能的实现思路。压缩包共602个文件以252个java、95个vue、88个svg、77个js为主另含xml配置、scss样式、yml与bat脚本等整体约1.49MB目录划分清晰方便按模块查阅与二次调试。目前已有306人学习下载适合希望借鉴成熟项目结构、对照源码梳理开发流程并在此基础上扩展功能的读者参考使用。1. 待办事项提醒系统从建表到提醒触发的完整落地路径很多人第一次做待办提醒系统都会把它想成“一个列表加一个闹钟”。真动手才发现难点根本不在界面而在三件事任务怎么存、提醒什么时候触发、重复任务怎么算下一次时间。我见过不少 Demo 把任务塞进一个 JSON 文件跑两天就乱套——改一条任务要重写整个文件多端同步直接冲突提醒时间一改就丢。所以这个标题真正要解决的问题是用一套可持久化、可查询、可扩展的结构把“待办”和“提醒”这两件事拆开又串起来。它适合两类人一类是想把课程设计或练手项目做扎实的开发者另一类是需要在内部工具里加一个轻量提醒模块的工程师。核心思路是数据库负责状态调度器负责时间通知层负责触达。下面按建表、写接口、跑调度、避坑、进阶的顺序讲每一步都能直接抄。2. 数据库设计三张表撑起任务、提醒与通知记录2.1 为什么不是一张表搞定新手最容易犯的错是把任务和提醒时间放在同一张表里字段大概长这样id, title, due_time, remind_time, status。单次任务没问题但一旦出现“每周一上午十点提醒我交周报”这张表就崩了——你没法存重复规则也没法记录每次提醒是否已经发过。常见做法是拆成三张表任务表存“要做什么”提醒表存“什么时候提醒”通知记录表存“提醒发没发出去”。这样重复任务只需要在提醒表里挂一条规则任务本身不用复制多份。2.2 建表 SQL 与字段说明-- 任务表只存任务本身的状态 CREATE TABLE todo_task ( id BIGINT PRIMARY KEY AUTO_INCREMENT, title VARCHAR(200) NOT NULL, description TEXT, status TINYINT NOT NULL DEFAULT 0, -- 0待办 1完成 2取消 priority TINYINT NOT NULL DEFAULT 1, -- 1低 2中 3高 created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP ); -- 提醒表一条任务可以有多个提醒支持重复规则 CREATE TABLE todo_reminder ( id BIGINT PRIMARY KEY AUTO_INCREMENT, task_id BIGINT NOT NULL, remind_at DATETIME NOT NULL, -- 首次提醒时间 repeat_rule VARCHAR(50) DEFAULT NULL, -- 如 DAILY / WEEKLY / NONE repeat_until DATETIME DEFAULT NULL, -- 重复截止时间 enabled TINYINT NOT NULL DEFAULT 1, INDEX idx_remind_at (remind_at, enabled), FOREIGN KEY (task_id) REFERENCES todo_task(id) ON DELETE CASCADE ); -- 通知记录表防止同一次提醒重复发送 CREATE TABLE todo_notification_log ( id BIGINT PRIMARY KEY AUTO_INCREMENT, reminder_id BIGINT NOT NULL, fire_time DATETIME NOT NULL, channel VARCHAR(20) NOT NULL, -- sms / email / push result TINYINT NOT NULL, -- 0失败 1成功 created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_reminder_fire (reminder_id, fire_time) );逻辑说明todo_reminder上的联合索引(remind_at, enabled)是给调度器扫描用的调度器每次只查“到点且启用”的提醒。todo_notification_log的唯一键是后悔药——调度器可能因为重试或并发重复触发唯一键直接挡住重复写入。参数上repeat_rule用字符串而不是枚举是为了后续加MONTHLY或EVERY_2_DAYS时不用改表结构。2.3 重复任务的下一次时间怎么算重复规则不要存“下一次时间”而是每次触发后根据repeat_rule和repeat_until算出新的remind_at。这样即使服务停了一天重启后也能从当前时间往后推不会补发一堆过期提醒。常见做法是写一个纯函数from datetime import datetime, timedelta def next_remind_time(current: datetime, rule: str) - datetime | None: if rule DAILY: return current timedelta(days1) if rule WEEKLY: return current timedelta(weeks1) if rule MONTHLY: # 简化处理按月加遇到月末用下月最后一天 month current.month 1 year current.year (month - 1) // 12 month (month - 1) % 12 1 day min(current.day, 28) # 避免2月溢出实际项目用日历库 return current.replace(yearyear, monthmonth, dayday) return None # NONE 表示不重复参数说明current是本次实际触发时间不是原计划时间这样能避免任务堆积时连续补发。repeat_until的判断放在调用方如果算出的下一次时间超过截止时间就把enabled置 0。3. 提醒调度器轮询、触发与幂等发送3.1 轮询间隔与扫描窗口怎么定调度器最朴素的做法是每秒查一次数据库SELECT * FROM todo_reminder WHERE remind_at NOW() AND enabled 1。任务量小的时候没问题但任务上万后每秒全表扫会拖垮数据库。我一般会加一个扫描窗口比如每 30 秒跑一次查remind_at在[now, now 30s]之间的记录配合索引命中率很高。窗口大小要略大于轮询间隔否则会漏掉边界任务。import time from datetime import datetime, timedelta POLL_INTERVAL 30 # 秒 WINDOW 35 # 略大于轮询间隔防止边界漏扫 def scan_reminders(db): now datetime.now() end now timedelta(secondsWINDOW) rows db.query( SELECT id, task_id, remind_at, repeat_rule, repeat_until FROM todo_reminder WHERE enabled 1 AND remind_at BETWEEN %s AND %s, (now, end) ) for row in rows: fire_reminder(db, row) def fire_reminder(db, row): # 先写通知记录唯一键冲突说明已发过直接跳过 try: db.execute( INSERT INTO todo_notification_log (reminder_id, fire_time, channel, result) VALUES (%s, %s, push, 1), (row[id], row[remind_at]) ) except DuplicateKeyError: return send_push(row[task_id], row[remind_at]) # 计算下一次时间 nxt next_remind_time(row[remind_at], row[repeat_rule]) if nxt and (row[repeat_until] is None or nxt row[repeat_until]): db.execute(UPDATE todo_reminder SET remind_at %s WHERE id %s, (nxt, row[id])) else: db.execute(UPDATE todo_reminder SET enabled 0 WHERE id %s, (row[id],))逻辑说明先写日志再发送是为了让“发送”这个动作有幂等保护。如果先发送再写日志进程在中间崩溃就会重复发。参数上POLL_INTERVAL和WINDOW要根据任务密度调任务密集时把窗口调小、轮询调快任务稀疏时反过来减少空转。3.2 多实例部署时怎么避免重复触发单机跑调度器没问题一旦部署两个实例两个进程会同时扫到同一条提醒。常见做法有两种一是用数据库行锁SELECT ... FOR UPDATE SKIP LOCKED抢到锁的实例才处理二是用一张调度锁表按时间片加唯一键。我一般用第一种简单且不引入额外组件。-- 抢锁式扫描MySQL 8.0 / PostgreSQL 支持 SKIP LOCKED SELECT id, task_id, remind_at, repeat_rule, repeat_until FROM todo_reminder WHERE enabled 1 AND remind_at BETWEEN NOW() AND DATE_ADD(NOW(), INTERVAL 35 SECOND) FOR UPDATE SKIP LOCKED;注意SKIP LOCKED在事务里才生效处理完一条要尽快提交否则锁持有太久会拖慢其他实例。如果数据库不支持退而求其次用UPDATE ... SET locked_by ? WHERE locked_by IS NULL的方式打标记。3.3 通知层怎么解耦发送短信、邮件、推送的代码不要写在调度循环里否则一个通道超时会把整个调度器卡住。我一般把通知任务丢进队列调度器只负责写队列消费者负责实际发送。没有消息队列时用数据库表当队列也行todo_notification_log里result 0的记录就是待重试的。def send_push(task_id, fire_time): # 实际项目里这里投递到队列Demo 里直接调用 payload {task_id: task_id, fire_time: fire_time.isoformat()} queue.put(notify, payload)参数说明fire_time要带上消费者用它做去重和日志关联。队列的消费者要设置最大重试次数超过后把result置为失败并告警不要无限重试。4. 避坑与排查五个真实翻车现场4.1 提醒时间存了本地时间换时区后全乱现象服务器从一台机器迁到另一台所有提醒提前或延后了 8 小时。原因remind_at存的是本地时间字符串没有时区信息。解决数据库统一存 UTC展示层再转本地时区。建表时用DATETIME存 UTC或者直接用TIMESTAMP并设置连接时区。4.2 重复任务补发了一整屏通知现象服务停了两天重启后用户收到几十条过期提醒。原因调度器扫描时把remind_at now的历史记录全查出来了。解决扫描窗口用BETWEEN now AND now window对于已经过期的重复任务直接把remind_at推到下一个未来时间点不补发。4.3 唯一键冲突导致正常提醒被吞现象某条提醒明明到点了但用户没收到。原因todo_notification_log的唯一键是(reminder_id, fire_time)而fire_time用了秒级精度同一秒内重试被当成重复。解决fire_time用本次计划触发时间而不是实际发送时间并且重试时不要改这个字段。4.4 任务删除后提醒还在跑现象用户删了任务提醒照样发。原因删任务时没有级联删除提醒或者用了软删除但调度器没过滤。解决外键加ON DELETE CASCADE软删除时在调度查询里加task.status ! 2的条件。4.5 调度器单点卡死没人发现现象提醒延迟了半小时才发出来。原因调度循环里某个通知发送阻塞了整个循环停住。解决通知发送必须异步化调度循环里只做数据库操作同时加心跳监控超过两个轮询周期没跑就告警。5. 进阶技巧用最小验证集确认提醒链路真的通了5.1 造一批边界任务来压测不要只测“一分钟后提醒我”这种顺风局。我一般会造这几类任务刚好在扫描窗口边界的、重复规则跨月的、repeat_until就在下一次触发当天的、任务被删除但提醒未清理的。跑一遍看通知记录表确认没有重复、没有遗漏、没有补发。-- 检查是否有重复发送 SELECT reminder_id, fire_time, COUNT(*) AS cnt FROM todo_notification_log GROUP BY reminder_id, fire_time HAVING cnt 1; -- 检查是否有到点未发送的提醒 SELECT r.id, r.remind_at FROM todo_reminder r LEFT JOIN todo_notification_log l ON l.reminder_id r.id AND l.fire_time r.remind_at WHERE r.enabled 1 AND r.remind_at NOW() AND l.id IS NULL;这两条 SQL 是我每次上线前必跑的第一条查重第二条查漏。参数上第二条的NOW()可以换成具体时间点用来回溯某次故障。5.2 用日志字段定位问题在todo_notification_log里加一个trace_id调度器每次扫描生成一个同一次扫描处理的所有提醒共享。出问题时按trace_id一拉就能看出是扫描没扫到、还是发送失败了。5.3 我自己的习惯我习惯在调度器启动时先打一行日志把当前时间、扫描窗口、待处理数量打出来。别小看这一行很多“提醒没发”的问题看一眼启动日志就知道是窗口设错了还是时区不对。另外重复规则我从来不用自然语言解析只支持几种固定枚举省得给自己挖坑。希望帮到你。本文还有配套的精品资源点击获取