背单词小程序「优词记 Pro」技术复盘第五篇。前面几篇偏架构,今天讲一个具体的业务问题:学习日历上的「连续学习天数」,最后为什么用 SQL 算而不是 PHP 算。
业务背景
小程序里每答一道题都会写一条学习日志(记录答对还是答错),学习日历根据日志给有学习记录的日期打标记,同时展示「当前连续学习天数」和「历史最长连续天数」——这是打卡激励体系的核心数字。
第一版:把日期拉回来遍历
最直觉的实现:把用户所有学习日志的日期去重后查回来,在 PHP 里排序遍历,相邻日期差一天就累加,断了就重置。十几行代码,跑得也对。
问题出在增长上。重度用户一年就是三百多个日期,而这个数字显示在个人中心首屏,每次打开都要算一遍。全量日期从 MySQL 传输到应用层、在协程里逐个比较——单次不慢,但它是高频路径上的 O(n) 传输加 O(n) 遍历,数据和用户量一起涨的时候,这种"能跑就行"的实现会最先出问题。
第二版:gaps-and-islands,让数据库自己算
连续日期统计在 SQL 里是个经典问题,叫 gaps-and-islands(间隙与孤岛)。核心技巧一句话就能说明白:
日期 - ROW_NUMBER() = 同一段连续日期共享的锚点
对去重后的学习日期按时间排序编号,用每个日期减去它的行号:连续的日期减出来的结果完全相同,一旦断档,差值就变了。按这个差值 GROUP BY,每组就是一段连续区间,COUNT(*) 是段长,取最大值就是最长连续天数,包含今天(或昨天)的那段就是当前连续天数。
整个计算在数据库内完成,网络上只传回一两个数字。索引配合下,执行时间基本不随日志总量线性增长——这正是数据库擅长而应用层不擅长的事:贴着数据做集合运算。
顺手踩的一个 Hyperf 坑
迁移过程中还遇到一个值得记录的坑:用 Model 执行这类带聚合别名的查询时,first() 拿到的聚合字段总是 0。原因是查询结果会走 Model 的属性处理链路——驼峰转换、列元数据校验等——而 max_days 这种临时别名不在表的列定义里,被处理链路"矫正"丢了。解法是聚合查询统一用 toBase()->first(),跳过 Model 属性处理,直接拿原始结果。这个坑在任何重写了属性访问的 ORM 里都可能出现,遇到聚合值莫名为 0 可以往这个方向查。
沉淀下来的分层规则
这次重构还让我把一条规则写进了项目规范:数据计算逻辑必须封装在 Model 层的静态方法里,Service 层只做编排,不做内联计算。上一版的问题本质就是计算逻辑散落在 Service 里,既不好测试,也让"换 SQL 实现"这种优化牵动业务代码。收进 Model 之后,Service 调用的方法签名没变,重构对上层完全透明。
产品本体是个背单词小程序,微信搜「优词记 Pro」可以体验,日历页那个连续天数就是这条 SQL 算出来的。明天换个轻松的话题:一次 HBuilderX 生产构建的踩坑复盘——组件被静默丢弃、样式全乱,排查过程挺有意思,欢迎关注。



网友评论 0