逐日AI
第 3 周 · D20约 6 小时

定时 Job 与主动关怀:时区、quiet hours、每日上限、通知 provider 抽象

实现一套主动给用户发消息的定时 Job:处理好时区换算、安静时段、每日发送上限,并把通知渠道抽象成可替换的 provider。

今日目标 0/3

登录后可以勾选并保存进度。

今日目标

  1. 能实现按用户时区正确计算"该发送"的本地时间
  2. 能实现 quiet hours(安静时段)与每日发送上限的限制逻辑
  3. 能把通知发送抽象成一个 provider 接口,支持替换不同渠道

昨天(D19)把多 Agent 服务和 mini-koda 用用户级令牌打通了,接口通了、幂等也做了,但整套系统仍然是"用户说一句、系统做一件"。今天让它自己开口——并且在开口之前先学会闭嘴。读完回到页面顶部,把上面三条勾掉。

小白版讲解

物业为什么不在半夜敲门

小区物业和住户打交道有两种情形。第一种是你自己下楼去问:"我那个快递被退回了怎么办?"——你在等回答,物业说什么你都听得进去。第二种是物业自己决定告诉你一件事:明天停水、电梯保养、门禁卡要换。这一种没有人在等,它是闯进来的。

同一句话,第一种情形下是服务,第二种情形下可能就是打扰。差别不在内容,在谁先开的口。

回到系统里。D13 已经把"能按时发"做完了:中心调度器按 cron 表达式醒来、把任务投进消息总线、幂等键锚在计划触发的那一分钟,多副本重复触发也只会落下一条。那条链路今天一行不改,今天补的是长在它上面的一层——该不该发

用户发起的消息和系统主动发起的消息,在三件事上是两种东西:

用户发起系统主动发起
谁在等用户正盯着屏幕没有人在等
失败了怎么办必须报错给用户看见多数时候应该安静地推迟或放弃
凭什么发用户开口了你得自己说出理由

第三行是本章的立论点:主动消息的默认答案是"不发"。 每一条准备发出去的主动消息,你都要能回答三个问题——为什么是现在、为什么是这个用户、为什么这条内容值得打断他。答不上来任何一个,就不该发。

这不是价值观表态,是一笔算得清的账。用户对主动消息的容忍度极低:连着收到几条无关紧要的推送之后,他不会跟你争论内容对不对,他会直接把通知权限关掉——而关掉之后,你连那条真正重要的消息也送不出去了。花掉的不是他这一次的注意力,是一个用完就拿不回来的额度。

所以"主动关怀"的工程形态不是"怎么把消息发出去",而是"怎么让绝大多数候选在发出去之前就被拦下来"。本课把拦截做成三道闸,规矩和物业那套一模一样:按住户自己的作息算而不是按物业的上班时间(时区)、半夜不敲门(安静时段)、一天最多贴一张(每日上限)。

三道闸里最容易写错的是第一道,最容易被忽略的是"闸到底装在哪一步"。

时间只有一种存法,判断却必须换算

物业贴通知时看的是自己办公室墙上那口钟,但"扰不扰民"的判据是住户家里那口钟。

系统里对应的规矩只有两句话,本课全程照此执行:时间一律存 UTC,判断前先转成用户本地时区。 存下来的是绝对时刻,拿去判断的是墙上时间,这是两种东西,混成一个字段就一定会出事。

zones.js
// JS 没有带时区的日期类型:Date 只是一个 UTC 毫秒数。要拿到"用户那边的墙上时间",
// 只能借 Intl 把它格式化到目标时区再读回来——另外三门语言都有原生类型,不用绕这一圈。
function partsOf(zoneId, at) {
  const f = new Intl.DateTimeFormat('en-CA', {
    timeZone: zoneId, hour12: false,
    year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit',
  })
  const p = Object.fromEntries(f.formatToParts(at).map((x) => [x.type, x.value]))
  // en-CA 的 hour12:false 在午夜会给出 24,取模拉回 0
  return { date: `${p.year}-${p.month}-${p.day}`, hour: Number(p.hour) % 24, minute: Number(p.minute) }
}
 
function offsetMsAt(zoneId, at) {
  const p = partsOf(zoneId, at)
  const [y, m, d] = p.date.split('-').map(Number)
  return Date.UTC(y, m - 1, d, p.hour, p.minute) - Math.floor(at.getTime() / 60000) * 60000
}
 
// 反方向:本地墙上时间还原成唯一的 UTC 瞬时。用前后各一天的偏移量当候选,
// 切换日的两个偏移量都会被覆盖到;再把候选换算回墙上时间验一遍。
function resolveLocal(zoneId, date, hh, mi) {
  const target = Date.UTC(...date.split('-').map(Number).map((v, i) => (i === 1 ? v - 1 : v)), hh, mi)
  const cands = [...new Set([target - 86400000, target + 86400000]
    .map((probe) => target - offsetMsAt(zoneId, new Date(probe))))].sort((a, b) => a - b)
  const valid = cands.filter((c) => {
    const p = partsOf(zoneId, new Date(c))
    return p.date === date && p.hour === hh && p.minute === mi
  })
  if (valid.length === 0) return { instant: new Date(cands.at(-1)), kind: 'gap' }
  if (valid.length > 1) return { instant: new Date(valid[0]), kind: 'ambiguous' }
  return { instant: new Date(valid[0]), kind: 'exact' }
}

四份代码放在一起就说明了一件事:Python、Java、Swift 的标准库都有"带时区的日期"这个类型,JS 没有,所以只有 JS 版要自己拿偏移量反算。在 JS 项目里看到有人手写时区加减法,那不是聪明,是没得选。

四个具体的坑,逐个给判据。

第一,别存本地时间字符串。 一串 2026-09-06 08:00 不指向任何确定的时刻:同一串字符在上海和纽约差 12 小时,换台机器、改个 TZ 环境变量还会解释出别的结果。判据是只靠这一列加上用户档案里的时区,能不能唯一还原出那个绝对时刻——存本地字符串答不上来,存 UTC 时间戳答得上来。

第二,时区要存 IANA 标识,不能存偏移量。 存 +08:00 而不是 Asia/Shanghai,在没有夏令时的地区看不出毛病,一旦用户在纽约、伦敦、悉尼,偏移量一年变两次——你存下它的那一刻它就已经过期了。IANA 标识是规则,偏移量只是规则在某一天算出来的结果。

第三,夏令时切换那一天。 这是本章最值得较真的地方。那一天会出现两种反常:

  • 春季前跳:纽约 2026 年 3 月 8 日的本地 02:30 根本不存在,时钟从 02:00 直接跳到 03:00;
  • 秋季回拨:11 月 1 日的本地 01:30 出现了两次,一次在夏令时下、一次在标准时下。

安静时段的窗口结束时刻只要落进这两个坑,"推迟到明天早上八点"就解释不出唯一答案。泛泛提一句"注意夏令时"毫无用处,得给出能写成断言的裁定。本课取的是和 java.time 一致的那一套:不存在就顺延到过渡之后的对应时刻(02:30 顺延成 03:30),出现两次就取第一次。 今天的实验把这两条当自检项跑,四门语言在这两个输入上给出的 UTC 瞬时完全一致。

第四,用户跨时区旅行时,"他的时区"以哪一次为准。 判据是以用户档案里那个显式字段为准,绝不跟着设备静默漂移:设备上报的时区只用来问一句"要不要把作息也跟着改",用户点了才写。再给档案记一个时区更新时刻,当它在 24 小时内跳变超过 3 小时(这个人真的在飞),当天的安静时段按新旧两个时区的并集处理——任何一边在安静,就不发。保守的代价是晚几小时收到,激进的代价是在人家凌晨三点响一声。

安静时段:跨午夜的那个判断,几乎所有人第一次都写错

物业的规矩是晚上十点以后不敲门,一直到第二天早上八点。这个区间和"下午一点到两点午休"有一个要命的区别:它跨过了午夜。

本课固定口径:默认安静时段 22:00 到 08:00(用户本地时间),左闭右开——22:00 整算安静,08:00 整算可发。

把时刻换算成"从午夜起算的分钟数"之后,几乎每个人第一次都会写成 start <= now && now < end。对午休那种同日区间这是对的;对 22:00 到 08:00,start 是 1320、end 是 480,这个条件永远不成立,于是半夜照发不误。这个 bug 最恶劣的地方在于它只在跨午夜的配置上错——你拿 13:00 到 14:00 写单元测试,全绿。

正确的写法是:start 小于 end 时用"且",start 大于 end 时换成"或"。

quiet.js
const minutesOf = (hhmm) => hhmm.split(':').reduce((h, m) => Number(h) * 60 + Number(m))
 
// 左闭右开 [start, end)。start 大于 end 就是跨了午夜,条件必须从"且"换成"或"
export function isQuiet(quietStart, quietEnd, hour, minute) {
  const now = hour * 60 + minute
  const start = minutesOf(quietStart)
  const end = minutesOf(quietEnd)
  if (start === end) return false // 空区间:这个用户不设安静时段
  return start < end ? now >= start && now < end : now >= start || now < end
}
 
// 窗口结束落在哪一天:本地 23:30 推到次日 08:00,本地 07:00 推到当天 08:00。
// 同一个 quietEnd,落哪天取决于当前本地时间在午夜的哪一侧。
export function endDate(quietStart, quietEnd, localDate, hour, minute) {
  const crossesMidnight = minutesOf(quietStart) > minutesOf(quietEnd)
  const sameDay = !crossesMidnight || hour * 60 + minute < minutesOf(quietEnd)
  if (sameDay) return localDate
  const [y, m, d] = localDate.split('-').map(Number)
  return new Date(Date.UTC(y, m - 1, d + 1)).toISOString().slice(0, 10)
}

判断写对了,还有第二个决定要做:命中安静时段之后是推迟,不是丢弃。

判据不该是发送方当时的心情,而应该由消息自己带着:给每条候选加一个过期时刻,过期时刻早于窗口结束的就丢弃,其余一律推迟。"这一单三十分钟后自动取消"过了今晚就没意义,丢掉是对的;"本月账单已出"明早八点发同样有效,丢掉就是白少服务一次。

推迟到哪一刻也要算准:本地 23:30 那条推到次日 08:00,本地 07:00 那条推到当天 08:00;算出这个墙上时间之后再用上一节的换算还原成唯一的 UTC 瞬时,存进按到期时刻排序的延后队列。

每日上限:一天最多贴一张,而"一天"是住户的一天

本课固定口径:每日主动消息上限 3 条每用户。这一道闸只有三行代码,但有三个问题必须问清楚。

第一,"一天"是谁的一天。 计数键写成 UTC 日期是最常见的做法,也是错的:东八区用户的本地一天从 UTC 前一天 16:00 开始,他早上八点收到的那条会被算进"昨天"的额度,而他自己看到的是今天第一条。计数的"天"必须是用户本地日历日,键长这样:

TextText
notify:count:u-1042:2026-09-06
                    ^^^^^^^^^^ 用户本地日历日,不是 UTC 日

第二,先查后写还是先占坑。 先查再判断是本能反应,也是错的:并发下两条候选同时读到 2、各自判断"没超",一起发出去,用户当天收到 4 条。正确做法是先占坑再判断——原子自增,拿自增之后的返回值去比上限,超了再把名额还回去。Redis 的 INCR、Postgres 的 update returning 都能一步做到。还回去这一步不能省:渠道明确拒绝(正文非法、用户已退订)之后不还,用户当天就白丢一条额度。

第三,闸装在哪一步。 这是本章最贵的一条。

gates.js
// 三道闸,顺序固定:时区 → 安静时段 → 每日上限。
// 上限闸放最后,因为被推迟的消息不该占掉今天的名额——它明早才发,凭什么算今天的。
// 位置比顺序更要命:整个函数必须在"让模型写正文"之前调用。
export async function passGates(store, profile, candidate, at) {
  const parts = partsOf(profile.timeZone, at) // 第一道闸:转成用户本地时间
  if (isQuiet(profile.quietStart, profile.quietEnd, parts.hour, parts.minute)) {
    const dueAt = quietEndsAt(profile, at) // 第二道闸:安静时段
    if (candidate.expiresAt && new Date(candidate.expiresAt) <= dueAt) {
      return { action: 'drop', reason: '窗口结束前就过期', localDate: parts.date }
    }
    return { action: 'defer', dueAt, localDate: parts.date }
  }
  const used = await store.bumpDaily(profile.id, parts.date) // 第三道闸:先占坑再判断
  if (used > profile.dailyLimit) {
    await store.releaseDaily(profile.id, parts.date) // 没用上就还回去
    return { action: 'drop', reason: '超出每日上限', localDate: parts.date }
  }
  return { action: 'send', reason: `当天第 ${used} 条`, localDate: parts.date }
}

主动关怀的完整链路是这样一条:

TextText
调度器命中 cron  →  取出候选用户  →  三道闸  →  让模型写正文  →  交给通知渠道
   (D13 已完成)                    ^^^^^^ 装在这里
                                             ^^^^^^^^^^^^ 装在这里就晚了

顺序错了程序照样跑通,发出去的消息也一模一样,唯一的区别是每一条被拦下的消息,你都已经付过一次模型调用的钱。按 D13 那张价格表换算:一条主动消息的正文按输入 3000、输出 500 token 估,单次 0.00075 美元;1000 个用户每天各排 3 条候选就是 3000 条,按安静时段和上限一起拦掉四成算,每天有 1200 条是白生成的:

  • 每天白花:1200 乘 0.00075 等于 0.9 美元
  • 每月白花:0.9 乘 30 等于 27 美元

对照 D13 算过的那笔账——同样 1000 个用户、每天一次正常对话,一个月 22.5 美元——光是"闸装晚了"这一个顺序问题,浪费掉的钱就超过了业务本身的开销。 更麻烦的是它在监控上看不出来:调用成功了、消息也发了,台账里那一行和正常调用一模一样。只有把"候选数"和"实际发送数"并排摆出来,差额才会浮出水面——这就是今天实验第 5 项自检在做的事。

通知 provider:同一条通知,走短信还是贴公告板

同一条停水通知,物业可以群发短信,可以贴楼道公告板,也可以在业主群里说一声。选哪个渠道跟通知内容无关,是另一个维度的决定。

这个形状你见过两次了:D4 的模型 provider、支付的 PaymentProvider。今天是第三次,能被换掉的东西都该推到一个接口后面这句话已经不新鲜。值得说的是下一句:通知的接口不能照抄支付那一份,三处语义完全不同。

收下不等于送达。 支付网关返回成功,钱就划走了;通知渠道返回成功,只表示它收下了这条消息,真正有没有落到用户手机上,是过一会儿通过回执异步告诉你的。所以返回值只能叫 accepted 不能叫 delivered,而且必须带一个渠道侧的消息标识——回执回来时靠它对上号。

限流是渠道自带的。 短信通道有每秒条数上限,返回 429 的同时会告诉你等多久再来。这是渠道维度的技术约束,和刚做完的每日上限(用户维度的礼貌约束)是两件事,混成一个概念就没法分别调整:一个说的是"这条线路挤不下了",一个说的是"这个人今天已经被打扰够了"。

没有撤销。 支付有退款,通知没有。消息一旦交给渠道就撤不回来,"取消"只在交出去之前有效。所以接口里绝不能出现 cancel——留一个做不到的操作比没有这个操作危险得多,调用方会真的去用它。

notify.js
// 送达是异步回执,所以这里只表达"渠道收没收下",三种结果的处理方式完全不同
export async function deliver(provider, notification, sleep, maxAttempts = 3) {
  for (let attempt = 1; ; attempt += 1) {
    // 重试必须带同一个 idempotencyKey:数据库里多一行你能删掉,用户手机上多响一声删不掉
    const result = await provider.send(notification)
    if (result.status === 'accepted') return { ok: true, attempts: attempt }
    if (result.status === 'rejected') return { ok: false, attempts: attempt } // 参数错,重试也没用
    if (attempt >= maxAttempts) return { ok: false, attempts: attempt }
    await sleep(result.retryAfterMs) // 渠道说等多久就等多久,别自作主张
  }
}

重试语义也因此分成三类,不能一刀切:限流就按渠道给的时长等;参数错(正文为空、号码不合法、用户已退订)直接放弃并把今天的名额还回去;服务端错误或超时可以重试,但必须带同一个幂等键。这是幂等键在本课的第四次出场(D8 落库、D13 定时触发、D19 跨服务、今天重试),而通知场景的赌注最高。

源码导读

动手实验

🧪 D20 实验:proactive job + 通知抽象

代码位置:labs/agent-30days/day-20-proactive-jobs

验收标准:

  1. MOCK=1 SELFTEST=1 pnpm start 六项自检全部 ✅,退出码为 0(starter 原样跑只有第 6 项 ✅)。
  2. 第 1、2 项证明时区与跨午夜判断正确:同一个 UTC 瞬时在上海是次日 07:30(安静)、在纽约是 19:30(可发)、在伦敦是次日 00:30(安静);夏令时那两天分别被判成"不存在"与"出现两次"。
  3. 第 3 项证明安静时段内是推迟而不是丢弃:上海用户本地 23:30 那条被推到次日 08:00 并在那一刻真的发出,只有窗口结束前就过期的那条才被丢弃。
  4. 第 4 项证明每日上限按用户本地日历日:纽约用户当天连排 5 条,前 3 条发出、第 4 条起被拦;跨过纽约本地午夜后额度重置。
  5. 第 5、6 项证明成本与渠道:9 条候选只发出 5 条,模型调用恰好 5 次、白花 0 美元;短信渠道限流后重试一次成功,收件箱里仍然只有 1 条。

这个实验在 MOCK=1零外部服务:计数器与延后队列走内存实现(不是打桩——原子自增占坑、按到期时刻取出都真的实现了一遍),时钟可注入,自检一毫秒内就跨过用户本地的午夜。starter 挖了 5 个练习点,每个挖空处的默认值都会让程序"跑得起来但明显不对"——按 UTC 判断作息、跨午夜恒判可发、命中安静时段就丢弃、上限只计数不拦截、闸装在生成之后。先原样跑一次,把那 5 个 ❌ 点名的练习号看清楚再动手。装了 Docker 的话在 lab 根目录 docker compose up -d 并配上 REDIS_URLDATABASE_URL 重跑,基础设施那一行会从 memory 变成 real,六项结果一模一样。

  1. 补完按用户时区换算本地时间的函数,重跑自检看第 1 项从 ❌ 变 ✅:三个时区在同一瞬时给出三个不同的本地时间,夏令时那两天被判成 gap 与 ambiguous。
  2. 把安静时段判断改成能处理跨午夜的写法,第 2 项那一行的七个时刻会从"全部可发"变成 22:00 到 07:59 判静、21:59 与 08:00 判可发。
  3. 算出安静时段结束的那个 UTC 瞬时,第 3 项会从"半夜就发了出去"变成"推迟到次日 08:00 才发",而带过期时刻的那条仍然被丢弃。
  4. 给第三道闸补上每日上限判断,观察纽约用户第 4 条起被拦下,再跨过他本地的午夜,看额度重置。
  5. 把三道闸挪到生成正文之前,第 5 项会打印出模型调用次数正好等于发送条数、白花 0 美元,同时告诉你闸装晚了要白花多少。

面试题

今天 4 道题在下方题库区,侧重时区坑、限流与打扰控制。展开后先看"分析过程"再看要点——第 2 题的追问(这类 bug 为什么在测试里永远是绿的)和第 3 题的追问(三道闸内部谁先谁后)是最容易被追着问的两处,别跳过。标注"国内高频 / 海外高频"方便按目标市场取舍。

检查清单与明日预告

  • 能实现按用户时区正确计算"该发送"的本地时间
  • 能实现 quiet hours(安静时段)与每日发送上限的限制逻辑
  • 能把通知发送抽象成一个 provider 接口,支持替换不同渠道
  • 能不看讲义写出跨午夜安静时段的正确判断,并说清朴素写法为什么恒为 false
  • 能说清三道闸为什么必须装在生成正文之前,以及装晚了每月白花多少钱
  • 实验的 6 项自检标准全部 ✅
  • 4 道面试题不看要点也能答出至少 3 道

明天(D21)是 W3 的收口。到今天为止,这套系统会分诊、会拆任务并行做、会自我评审、会主动关怀而且知道克制——但它到底做得好不好,你其实说不上来,手上只有"我试了几条感觉还行"。明天补上最后一块:用一批固定的标准样本集去量它,用模型给模型打分并搞清楚这种打分靠不靠谱,再把调用链路、失败率与成本做成能看的面板。先把系统做出来再谈怎么评估它,顺序不能反——没有评估的系统,是你永远不敢改的那一种。

面试题库

  • 系统主动发给用户的消息,和用户自己触发的消息,在系统设计上有什么不同?How does a system-initiated message differ from a user-triggered one, from a system design point of view?
    国内高频海外高频基础#proactive-messaging#system-design#product-engineering

    分析过程 · 先想清楚再作答

    1. 这题看着像概念题,其实是筛人题。答「都是发消息,只是触发方不同」就落进了最浅的一层——面试官想听的是这个差别会逼你多写哪些代码。
    2. 先给三条结构化的差别:谁在等(用户触发时他正盯着屏幕,主动消息没有人在等);失败怎么处理(用户触发的失败必须报错给他看,主动消息的失败多数时候应该安静地推迟或放弃);凭什么发(用户触发是他开了口,主动消息你得自己说出理由)。
    3. 第三条是题眼,要说透:主动消息的默认答案是不发。每一条都要能回答为什么是现在、为什么是这个用户、为什么这条内容值得打断他,三个问题答不上任何一个就不该发。
    4. 然后给出这个差别在系统里的落点:主动消息这一侧必须多出一层准入判断,本课叫三道闸——按用户时区算本地时间、安静时段命中就推迟、每日上限满了就拦下。用户触发那一侧完全不需要这层。
    5. 代价也要算清楚,这是区分「读过文章」和「做过系统」的地方:用户对主动消息的容忍度极低,连着几条无关紧要的推送之后他不会争论内容对不对,直接关掉通知权限——而权限一关,你连真正重要的那条也送不出去了。你消耗的是一个用完就拿不回来的额度。
    6. 可以预期的追问:那定时任务和主动消息是不是一回事?答不是。定时任务解决的是「能按时触发」(中心调度、幂等键锚在计划触发的那一分钟),主动消息解决的是「该不该发」,前者是机制、后者是准入,两层要分开做。

    How to reason about it · think before answering

    1. This looks like a definition question but it is really a filter. Answering both send a message, only the trigger differs stays at the shallowest layer — the interviewer wants to know what extra code the difference forces you to write.
    2. Give three structured differences: who is waiting (a user-triggered reply has someone staring at the screen, a proactive message has nobody waiting); how failure is handled (user-triggered failures must surface as errors, proactive failures should usually be silently deferred or dropped); and what justifies sending (the user asked, versus you having to justify it yourself).
    3. The third is the hinge, so make it explicit: the default answer for a proactive message is do not send. Every one must answer why now, why this user, and why this content is worth interrupting them. Fail any of the three and it should not go out.
    4. Then land the difference in the system: the proactive path needs an admission layer the reactive path does not — compute the user's local time from their timezone, defer if it falls inside quiet hours, drop if the daily cap is used up.
    5. Quantify the cost, which is what separates having read about this from having shipped it: tolerance for proactive messages is very low. After a few irrelevant pushes the user will not argue about the content, they will revoke the notification permission — and once revoked, the genuinely important message cannot reach them either. You are spending a budget that never refills.
    6. Expect: so is a cron job the same thing as a proactive message? No. The scheduler solves firing on time (central scheduling, an idempotency key anchored to the scheduled minute); proactive care solves whether to send at all. One is mechanism, the other is admission, and they belong in separate layers.

    答题要点

    • 三条差别:谁在等、失败怎么处理、凭什么发;第三条是关键
    • 主动消息的默认答案是不发,每条要能回答为什么是现在、为什么是这个用户、为什么值得打断他
    • 落到系统上就是多一层准入判断:时区换算、安静时段、每日上限,用户触发那一侧不需要
    • 代价是一个不可再生的额度:推送惹烦了用户,他关掉权限之后重要消息也送不出去
    • 定时机制(能按时触发)和主动关怀(该不该发)是两层,不要混在一起做

    Key points

    • Three differences: who is waiting, how failure is handled, and what justifies sending — the third is the crux
    • The default answer for a proactive message is no; each one must justify why now, why this user, why worth interrupting
    • In the system this becomes an admission layer — timezone, quiet hours, daily cap — that the reactive path does not need
    • The cost is a non-renewable budget: annoy the user and they revoke notifications, taking the important messages down with them
    • Scheduling (fire on time) and proactive care (should we send) are two separate layers
  • 做一个面向全球用户的定时推送服务,时区上你会踩到哪些坑?夏令时切换那天怎么处理?Building a scheduled push service for users worldwide, what timezone pitfalls would you hit, and how do you handle the DST switchover day?
    国内高频海外高频深入#timezone#scheduling#correctness

    分析过程 · 先想清楚再作答

    1. 这题的区分度全在夏令时那半句。只答「存 UTC、展示转本地」是及格线,能不能给出夏令时那天的**可验证裁定**决定了你是「知道有坑」还是「填过坑」。
    2. 先把基础两条说死:时间一律存 UTC(存的是绝对时刻),判断前先转成用户本地时区(判断的是墙上时间)。判据是一句可自查的话——只靠这一列加上用户档案里的时区,能不能唯一还原出那个绝对时刻。存本地时间字符串答不上来。
    3. 第二个坑是时区字段本身:必须存 IANA 标识(Asia/Shanghai)而不是 UTC 偏移量。偏移量一年会随夏令时变两次,标识是规则、偏移量只是规则在某一天算出来的结果,存结果的那一刻它就过期了。
    4. 第三个坑是夏令时那天的两种反常:春季前跳会让某个本地时间**根本不存在**(纽约 2026-03-08 的 02:30),秋季回拨会让某个本地时间**出现两次**(2026-11-01 的 01:30)。只要你的调度点落在这两个窗口里,「每天早上 8 点发」就解释不出唯一答案。裁定要显式给出而不是交给库随便选:不存在就顺延到过渡之后(02:30 变 03:30),出现两次就取第一次——这也正是 java.time 的 ZonedDateTime.of 的默认行为,可以直接写成断言测试。
    5. 第四个坑最容易被漏:用户跨时区旅行时,他的时区以哪一次为准。答案是以用户档案里那个显式字段为准、绝不跟着设备静默漂移;设备上报只用来询问是否切换。更细一层可以补:时区在 24 小时内跳变超过 3 小时时,当天的安静时段按新旧两个时区的并集处理,任何一边在安静就不发——保守的代价是晚几小时收到,激进的代价是在人家凌晨三点响一声。
    6. 可以预期的追问:这种 bug 为什么很难被测出来?因为本机、CI、生产常常都在同一个时区甚至都在 UTC,「忘了转时区」在所有测试里都是绿的。给出判据:把测试用户的时区故意设成三个互不相同、且都不等于服务器时区的值,任何依赖服务器时区的判断当场变红。

    How to reason about it · think before answering

    1. All the signal in this question lives in the DST half. Store UTC, render local is the passing grade; giving a verifiable ruling for the switchover day is what separates knowing the pitfall from having fixed it.
    2. Nail the two basics first: always store UTC (an absolute instant) and convert to the user's zone before any judgement (a wall-clock time). The self-check is one sentence — can this column plus the user's stored timezone uniquely reconstruct the absolute instant? A local time string cannot.
    3. The second pitfall is the timezone field itself: store the IANA identifier (Asia/Shanghai), never a UTC offset. Offsets shift twice a year under DST; the identifier is the rule and the offset is only what that rule evaluated to on one particular day, so it is stale the moment you persist it.
    4. The third is the two anomalies on switchover day: spring-forward makes some local time simply not exist (02:30 on 2026-03-08 in New York), and fall-back makes some local time occur twice (01:30 on 2026-11-01). If your schedule point lands in either window, send at 8am local has no unique answer. State the ruling explicitly rather than leaving it to whatever the library picks: shift a nonexistent time forward past the transition (02:30 becomes 03:30), and take the first occurrence when it happens twice — which is exactly what java.time's ZonedDateTime.of does, so you can assert on it in tests.
    5. The fourth is the one people miss: when a user travels across zones, which timezone counts. Answer: the explicit field on the user profile, never silent drift from device reports; a device report should only prompt the user to confirm a change. Go one level deeper if you can — when the zone jumps more than three hours within 24 hours, treat that day's quiet hours as the union of the old and new zones and stay silent if either is quiet. Being conservative costs a few hours of delay; being aggressive costs a 3am buzz.
    6. Expect: why are these bugs so hard to catch? Because your laptop, CI and production are often all in one zone, frequently UTC, so forgot to convert stays green everywhere. Give the fix: pin the test users to three distinct zones, none equal to the server's, and every server-timezone dependency turns red immediately.

    答题要点

    • 存储一律 UTC,判断前转用户本地时区;自查判据是这一列加时区能否唯一还原绝对时刻
    • 时区存 IANA 标识而不是 UTC 偏移量——偏移量随夏令时一年变两次,存下来就过期
    • 夏令时两种反常:本地时间不存在(春季前跳)、本地时间出现两次(秋季回拨)
    • 裁定要显式且可断言:不存在就顺延到过渡之后,出现两次取第一次(与 java.time 默认一致)
    • 跨时区旅行以用户档案里的显式字段为准,不跟设备漂;跳变超过 3 小时时按新旧时区的并集判安静
    • 测试里把用户时区设成三个不同于服务器的值,否则「忘了转时区」在所有测试里都是绿的

    Key points

    • Store UTC everywhere, convert to the user's zone before judging; the check is whether column plus zone reconstructs the instant
    • Persist IANA identifiers, not UTC offsets — offsets change twice a year and are stale on write
    • Two DST anomalies: a local time that does not exist (spring forward) and one that occurs twice (fall back)
    • Make the ruling explicit and assertable: shift nonexistent times past the transition, take the first of a duplicated pair (matching java.time)
    • For travellers, trust the explicit profile field, not device drift; on jumps over three hours, treat quiet hours as the union of both zones
    • Pin test users to three zones different from the server's, or the missing conversion stays green in every test
  • quiet hours 和每日发送上限这两条规则你会怎么实现?它们应该放在链路的哪一步判断?How would you implement quiet hours and a per-user daily cap, and where in the pipeline should they be evaluated?
    国内高频海外高频进阶#rate-limiting#quiet-hours#cost-control

    分析过程 · 先想清楚再作答

    1. 这题有两个题眼,很多人只答了前一个。第一个是「怎么判断」(细节题),第二个是「放在哪一步」(架构题),后者才是拿分点。
    2. 先讲 quiet hours 的判断。把时刻折成从午夜起算的分钟数之后,绝大多数人第一次都会写成 start 小于等于 now 且 now 小于 end。这对午休那种同日区间是对的,对 22:00 到 08:00 恒为 false——start 是 1320、end 是 480,条件永远不成立,于是半夜照发。正确写法是 start 小于 end 时用「且」,start 大于 end(跨午夜)时换成「或」。这个 bug 恶劣在只在跨午夜的配置上错,用 13:00 到 14:00 写的单元测试全绿。
    3. 接着是命中之后怎么办:推迟,不是丢弃。判据不该由发送方临时决定,而应该由消息自己带一个过期时刻——过期时刻早于窗口结束的丢弃,其余一律推迟到窗口结束。限时取消提醒过了今晚就没意义,账单提醒明早发一样有效。另外要提一句惊群:所有推迟的消息会算出同一个到期时刻,要加一个按用户标识哈希得出的抖动(不能用随机数,否则线上复现不了)。
    4. 再讲每日上限的两个细节。一是「一天」必须是**用户本地日历日**,写成 UTC 日的话东八区用户早上八点前发的会算进昨天的额度。二是必须先占坑再判断——原子自增拿返回值比上限,超了再把名额还回去;先查后写在并发下两条候选会同时读到同一个值然后一起发出去。渠道明确拒绝时也要把名额还回去。
    5. 最后是架构题那一半,也是最值钱的一段:三道闸必须在**生成内容之前**判断,不是在发送那一步。顺序错了程序照样跑通、发出的消息也一样,唯一区别是每条被拦下的消息你都已经付过一次模型调用的钱。按 1000 用户每天各 3 条候选、拦掉四成、单条约 0.00075 美元算,一个月白花约 27 美元,比这批用户的正常对话开销还高,而且监控上完全看不出来——只有把候选数和实际发送数并排摆出来才看得见差额。
    6. 可以预期的追问:三道闸内部谁先谁后?答时区、安静时段、每日上限,上限必须最后。因为被安静时段推迟的消息明早才发,不该占掉今天的名额;顺序反了用户会发现自己明明没收到几条却被限流了。

    How to reason about it · think before answering

    1. There are two hinges here and most candidates only answer the first. One is how to evaluate the rules (a details question), the other is where in the pipeline (an architecture question) — the second is where the points are.
    2. Start with quiet hours. Once you fold times into minutes-from-midnight, almost everyone first writes start less-or-equal now and now less-than end. That is correct for a same-day window like a lunch break, but for 22:00 to 08:00 it is always false: start is 1320, end is 480, the condition never holds, and you push at 3am. The fix is to use and when start is before end, and or when start is after end. What makes this bug nasty is that it only misfires on the cross-midnight config, so a unit test written around 13:00 to 14:00 passes.
    3. Then what to do on a hit: defer, do not drop. The decision should not be the sender's mood — attach an expiry to each candidate and drop only when it expires before the window ends, deferring everything else to the window's end. A thirty-minute cancellation warning is worthless tomorrow; a billing summary is just as valid at 8am. Mention the thundering herd too: every deferred message resolves to the same due instant, so add jitter derived from a hash of the user id, never a random number, or you cannot reproduce incidents.
    4. Now two details on the daily cap. First, the day must be the user's local calendar day; keying on the UTC date charges an East-Asian user's 8am message to yesterday's budget. Second, increment first and check the returned value, then give the slot back if it exceeded — a read-then-write races, letting two candidates read the same count and both go out. Return the slot on a hard rejection from the channel as well.
    5. Finish with the architecture half, which is the valuable part: all three gates must run before the content is generated, not at the send step. Get the order wrong and the program still works and sends the same messages; the only difference is that you paid for a model call on every message you then threw away. At 1000 users, three candidates each per day, forty percent blocked and roughly $0.00075 per message, that is about $27 a month wasted — more than the normal conversational spend for the same cohort — and it is invisible in monitoring. Only putting candidate count next to sent count reveals the gap.
    6. Expect: what order do the three gates run in? Timezone, quiet hours, daily cap, with the cap last. A message deferred to tomorrow morning must not consume today's quota; reverse the order and users get rate-limited despite having received almost nothing.

    答题要点

    • 跨午夜的安静时段:start 小于 end 用「且」,start 大于 end 换成「或」,朴素写法对 22:00-08:00 恒为 false
    • 命中安静时段是推迟到窗口结束而不是丢弃;只有自带的过期时刻早于窗口结束才丢
    • 推迟会造成惊群,要加按用户标识哈希得出的抖动,不能用随机数
    • 每日上限的「天」必须是用户本地日历日,不是 UTC 日
    • 计数要先占坑再判断(原子自增后比上限,超了还回去),先查后写在并发下会超发
    • 三道闸必须在生成内容之前判断,装晚了每条被拦的消息都已经付过模型调用的钱(示例量级约 27 美元每月);闸内顺序是时区、安静时段、每日上限,上限最后

    Key points

    • Cross-midnight quiet hours need or when start is after end; the naive and version is always false for 22:00-08:00
    • On a hit, defer to the end of the window rather than drop; only drop when the message's own expiry precedes that
    • Deferral causes a thundering herd, so add jitter hashed from the user id, never a random value
    • The day in a daily cap must be the user's local calendar day, not the UTC date
    • Increment atomically then compare and release on overflow; read-then-write over-sends under concurrency
    • Run all three gates before generating content — otherwise every blocked message has already been paid for (about $27/month at the example scale); order them timezone, quiet hours, daily cap, with the cap last
  • 模型调用、支付、通知渠道你都做过 provider 抽象。通知这一份接口和另外两份有什么不同?You have abstracted model calls, payments and notification channels behind providers. How does the notification interface differ from the other two?
    国内高频海外高频进阶#provider-abstraction#api-design#retry-semantics

    分析过程 · 先想清楚再作答

    1. 这题在考你是「会套模式」还是「懂模式」。把三者说成一回事——都是接口加多个实现、换厂商不改业务——只答到了共性那一层,面试官真正想看的是你有没有识别出差异并把它写进接口。
    2. 先给共性,一句话带过:都是把「会被替换的东西」推到接口后面,业务代码只认接口,选择点集中在一处。这一层是对的,但不构成区分度。
    3. 然后给三条差异,这是拿分点。第一,收下不等于送达:支付网关返回成功钱就划走了,通知渠道返回成功只表示它收下了,真正送达是过一会儿通过回执异步告诉你的。所以返回值只能叫 accepted 不能叫 delivered,而且必须带渠道侧的消息标识,回执回来时靠它对上号。
    4. 第二,限流的层次不同:渠道自带每秒条数上限并会用 429 加重试间隔告诉你稍后再来,这是**渠道维度的技术约束**;而每日发送上限是**用户维度的礼貌约束**。两者混成一个概念就没法分别调整——一个说的是这条线路挤不下了,一个说的是这个人今天已经被打扰够了。
    5. 第三,没有撤销:支付有退款,通知发出去就撤不回来,取消只在交给渠道之前有效。所以接口里不能出现 cancel——在接口上留一个做不到的操作比根本没有这个操作更危险,调用方会真的去用它。
    6. 可以预期的追问:那失败重试怎么设计?答分三类:限流按渠道给的时长退避重试;参数错(正文非法、用户已退订)不可重试,直接放弃并把当天的名额还回去;服务端错误或超时可重试但必须带同一个幂等键——数据库里多一行你能删掉,用户手机上多响一声删不掉。再补一条判断抽象好坏的判据:新接一个渠道时如果它只需要实现「把这条消息发出去」,抽象就对了;如果它还得知道现在是不是安静时段、这是今天第几条,说明业务规则泄进了渠道层。

    How to reason about it · think before answering

    1. This question separates applying a pattern from understanding one. Saying all three are the same — an interface with several implementations so you can swap vendors without touching business code — only covers the shared part; the interviewer wants to see whether you spotted the differences and encoded them in the interface.
    2. Acknowledge the commonality in one line: each pushes a replaceable dependency behind an interface, business code depends only on the interface, and the selection point lives in exactly one place. Correct, but not differentiating.
    3. Then give three differences, which is where the points are. First, accepted is not delivered: when a payment gateway returns success the money has moved, but when a notification channel returns success it has merely taken the message, and actual delivery arrives later as an asynchronous receipt. So the result is accepted, never delivered, and it must carry the provider-side message id so the receipt can be correlated.
    4. Second, throttling lives at a different layer: the channel has its own per-second ceiling and tells you to come back later with a 429 plus a retry interval — a channel-level technical constraint — while the daily cap is a user-level courtesy constraint. Collapsing them into one concept makes them impossible to tune separately: one says this line is congested, the other says this person has been interrupted enough today.
    5. Third, there is no undo: payments have refunds, notifications do not. Once handed to the channel the message is gone, and cancel only means anything before that handoff. So the interface must not expose a cancel method — leaving an operation that cannot work is worse than not having it, because callers will actually use it.
    6. Expect: how do you design retries then? Three classes. Throttling backs off for the interval the channel gave you. Parameter errors (invalid body, unsubscribed user) are not retryable, so give up and return the daily slot. Server errors and timeouts are retryable but must carry the same idempotency key — you can delete a duplicate row, you cannot un-buzz a phone. Add a test for the abstraction itself: if a new channel only has to implement send the message, the boundary is right; if it also needs to know whether it is quiet hours or which message of the day this is, business rules have leaked into the channel layer.

    答题要点

    • 共性是把可替换依赖推到接口后面、选择点集中一处,但这只是及格线
    • 收下不等于送达:返回值叫 accepted 不叫 delivered,必须带渠道侧消息标识以便异步回执对号
    • 限流分两层:渠道的每秒上限是技术约束,每日发送上限是用户维度的礼貌约束,不能合并
    • 通知没有撤销,接口里不能有 cancel;留一个做不到的操作比没有更危险
    • 重试分三类:限流按渠道给的时长退避、参数错不可重试并归还名额、服务端错误可重试但必须带同一个幂等键
    • 判断抽象切没切对:新渠道只需实现发送就对了,还要知道安静时段和当天条数就说明业务泄进了渠道层

    Key points

    • The shared part is pushing a replaceable dependency behind an interface with a single selection point — that is only the baseline
    • Accepted is not delivered: name the result accepted and carry a provider message id so async receipts can be correlated
    • Throttling has two layers: the channel's per-second ceiling is technical, the daily cap is a user-level courtesy rule, and they must stay separate
    • Notifications have no undo, so the interface must not expose cancel — an unimplementable operation is worse than none
    • Three retry classes: back off for the channel's interval on throttling, give up and release the slot on parameter errors, retry server errors with the same idempotency key
    • Test the boundary: a new channel should only implement send; needing to know quiet hours or today's count means business rules leaked into the channel

评论

登录后即可参与讨论

还没有评论,来说第一句。