"Card Add Flush"这六个词,单独拆开都不复杂:Card 是卡片,Add 是添加,Flush 是刷新、冲刷。但把它们按顺序放在一起,就成了一个很典型的业务痛点——给卡片类系统做"新增卡片后立即刷新生效"的功能。我最早接触这个需求是在做一个内部卡片管理平台时,用户提交一张新门禁卡,系统却经常出现"提交成功了,列表里却看不到""卡号录入了,刷卡却还是无效"这类问题。排查到最后发现,根子都出在 Add 做完之后,缺少一个严谨的 Flush 流程。这篇文章就用一个模拟项目X来复盘:这个需求该怎么拆、刷新机制怎么设计、完整流程怎么落地,以及我在真实环境里踩过的那些坑。
1. 先把需求掰开:添加卡片后到底要不要主动刷新
1.1 Card Add Flush 三个词,每个都是坑
Card 这个词,在业务系统里远不止"一张塑料卡"这么简单。它通常是一组数据的集合:卡号、持卡人、卡类型(门禁/交通/会员/工牌)、使用状态、有效期、绑定设备、甚至余额和权限组。我第二次做卡片需求时,一开始只建了一张很薄的卡片表,字段只有卡号和绑定人,结果后面要做挂失、换卡、权限回收,全都缺字段,硬生生重构了一遍。所以做 Card 建模,宁可在初期多花点时间把状态和关联关系想清楚,也不要等到 Add 和 Flush 都做完再补。
Add 也不是一条 insert 就结束。从用户角度看,添加就是"把卡放进我的卡包";从系统角度看,它至少包含卡号校验、唯一性检查、记录写入、初始状态设置、绑定关系更新、操作日志记录这一串动作。这些动作天然不是一个原子事务,任何一个环节失败,都会留下半截脏数据。这也是很多"添加成功但没生效"的第一现场。
Flush 是整个标题的点睛之笔。它在计算机术语里最常见的意思是"把缓存里的数据强制写回"或者"让缓存失效重新加载",延展到业务上,就是让新添加的卡片从"数据库里存在"推进到"所有端都能正确感知"。这个感知包含三层:界面要重新拉取并渲染新卡、服务端缓存要失效以免旧数据串台、其他端(App、小程序、后台管理)要通过事件或轮询同步最新状态。只做 Add 不做 Flush,用户看到的就是"提交成功但列表里没有卡"。
1.2 三个真实场景,看清"不刷新"有多痛
我这里整理三个项目里真实出现过的场景,它们分别对应 Flush 的三个层次。
场景A:用户在小程序里添加一张门禁卡,提交后界面提示成功,但是去闸机刷卡,门不开。查到最后是卡片状态一直是"待激活"——Add 接口只负责写入记录,没有人触发状态推进。这是单机状态层的 Flush 缺失。
场景B:管理员在后台上传一批待发卡,上传成功后前端的表格区域没有自动刷新,管理员以为操作失败,连续点了三次上传,结果库里有了三批重复卡。这是列表数据层没有刷新。
场景C:用户在手机上给交通卡充值,另一块关联手表上仍旧显示旧余额,直到手动重启应用才好。这是跨端同步层的 Flush 缺失。
这三个场景如果只有一个结论,那就是:Add 只是开始,Flush 才是让业务真正闭环的那一步。而且每一层 Flush 的侧重点完全不同,不能用一套"清一下缓存"的简单逻辑去通吃。
1.3 状态机:把"添加完"和"生效"之间补齐
为了让 Flush 有章可循,我在模拟项目X里给卡片定义了一套状态机:待激活 → 激活 → 挂失 → 注销。新增卡片一律从"待激活"起步;Add 接口只负责创建记录,Flush 流程负责把"待激活"推进到"激活"。
为什么要把状态推进单独拆出来而不是在 Add 里一起做?因为这样容错率高。比如卡片写入数据库成功了,但是权限初始化的下游接口超时,如果两者放在一个事务里,这个用户连卡都建不出来;如果状态推进是独立的一步,至少卡还停留在"待激活"状态,管理员能看到它,排查问题也有依据。用生活类比就是:Add 等于在人事系统录入了员工信息,Flush 等于给他发工牌、开系统权限、通知全公司。只录入不发工牌,这个员工在组织里就"等于不存在"。
2. 刷新机制核心:三种Flush层级逐个讲透
2.1 UI层Flush:列表刷新不是简单的 push
UI 层最常见也最容易被做糙。业界两种典型做法:一是 Add 接口返回新卡片对象,前端直接 push 进列表渲染;二是 Add 成功后调用列表接口重新拉取全量。第一种快,但依赖接口返回结构完整;如果后端新增卡片后忘了返回状态码或者绑定的设备号,前端就会渲染出一张残缺卡。第二种稳,但多一次请求,数据量大时会出现白屏闪烁。
我的折中方案是"乐观更新+静默校正":提交成功后,先用服务端返回的数据在本地生成一条占位卡片,立即渲染,同时后台拉一次全量列表,回来后用服务端顺序替换本地顺序。用户感知不到等待,又保证了最终一致性。
这里还有一个很容易忽略的细节:请求乱序。用户快速添加两张卡,两个请求的网络返回顺序可能不一致,如果前端按返回顺序 push,列表顺序就是乱的。所以无论采用哪种方案,列表顺序最终都应该以服务端返回为准,而不是前端拼装。
2.2 数据层Flush:缓存失效要清干净
服务端一旦引入缓存,Flush 的重点就从"界面刷新"变成了"缓存失效"。这个环节最经典的坑是缓存 key 没清全。我做过一个卡片系统,Add 接口只删了 user:cards:{userId} 这个列表缓存,结果用户详情里依然显示旧卡数,因为相关接口走的是另一套 detail 缓存。
整理一下我的经验。缓存 key 分三类:卡片详情 card:detail:{cardId}、用户卡片列表 user:cards:{userId}、查询列表 card:list:{queryHash}。Add 成功后,至少要把用户列表缓存和查询列表缓存全部清掉。但逐个删除很麻烦,尤其查询条件一多,queryHash 就有几十种变体。更省心的做法是给缓存加业务版本号,比如 user:cards:v{version}:{userId},每发生一次卡片变更就给版本号加 1,旧 key 自动不被读取。这个思路本质上是"让旧数据自然过期",而不是"找到所有旧 key 再删"。
数据层 Flush 还有一个外部同步的场景。比如交通卡余额由外部平台维护,本地只有一份只读副本。Add 卡片后,本地缓存刷新没问题,但外部平台的数据如果没同步过去,本地刷新十次也是旧数据。这时候 Flush 的语义就变了——不只是清缓存,而是要发起一次外部同步任务并等待回调。我最终给 Flush 流程加了一张同步任务表:待同步 → 同步中 → 成功/失败,靠这张表兜底,回调丢失也能重跑。
2.3 服务端状态Flush:多端一致不能靠自觉
多端一致的实现方式,无非两种:主动轮询和事件推送。轮询简单,但效率低,每个端各自定时去拉,刷新节奏很难统一,表现出来就是"手表上余额是新的,手机上是旧的"。事件推送及时、单向、以服务端为唯一事件源,是卡片类业务的更优解。
用事件推送要注意两点。第一,事件里不要携带整张卡的数据,只带 cardId。因为事件经过消息队列到消费端,存在时序问题,携带的数据很可能已经过期,消费端拿到错误数据反而扩大了不一致。正确姿势是消费端收到 cardId 后,回源拉取最新的卡片详情。第二,同一张卡在一小段时间内反复变更时,会产生多个事件,消费端要做幂等处理:根据卡片版本号判断,版本没变就丢弃。我见过一次因为事件重复消费导致用户卡片被重复挂失的生产事故,教训很深。
3. 复现一个完整流程:从提交新建卡到列表自动刷新
3.1 数据模型先行:卡片表应该长什么样
实操部分,我以一个模拟项目X为例:某公司内部的卡片管理系统,支持门禁卡、工牌卡、访客卡三类卡片,管理员可以录入新卡,员工可以在 App 端查看自己的卡包,添加新卡后要求 App 端和后台管理端都立即刷新。
先看核心表设计(MySQL 语法,关键字段已简化):
CREATE TABLE card_records ( id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '自增主键', card_no VARCHAR(64) NOT NULL COMMENT '物理卡号,业务唯一', card_type TINYINT NOT NULL COMMENT '1-门禁卡 2-工牌卡 3-访客卡', owner_uid BIGINT NOT NULL COMMENT '绑定用户ID', status TINYINT NOT NULL DEFAULT 0 COMMENT '0-待激活 1-激活 2-挂失 3-注销', valid_from DATETIME NOT NULL COMMENT '生效时间', valid_until DATETIME DEFAULT NULL COMMENT '失效时间', version INT NOT NULL DEFAULT 1 COMMENT '业务版本号,乐观锁兼缓存版本', created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE KEY uk_card_no (card_no), KEY idx_owner (owner_uid) ) COMMENT='卡片记录表';这里几个字段都值得单独说。card_no 加唯一索引,是从数据库层面兜住"重复加卡"的最底线;status 默认 0 即待激活,保证新卡在完整 Flush 之前不可能被当成正式卡使用;version 字段平时很多人不加,但它同时承担了乐观锁和缓存版本号两个职责,后面写 Flush 逻辑时非常有用。
3.2 后端实现:addCard 接口与 flush 流程分步走
接口逻辑我用伪代码写,还原真实流程,重点是"事务提交后再发事件"这一条纪律。
public CardResponse addCard(AddCardRequest request) { // 1. 基础校验:卡号格式、绑定人是否存在 validate(request); // 2. 幂等校验:同一用户同一卡号,短时间内不允许重复提交 String idempotentKey = request.getUserId() + ":" + request.getCardNo(); if (idempotentCache.exist(idempotentKey)) { return buildDuplicateResponse(); } idempotentCache.set(idempotentKey, 1, Duration.ofMinutes(5)); // 3. 写入卡片记录,状态为待激活 CardRecord card = new CardRecord(); card.setCardNo(request.getCardNo()); card.setCardType(request.getCardType()); card.setOwnerUid(request.getUserId()); card.setStatus(0); // 待激活 cardDao.insert(card); // 4. 事务提交后执行 Flush 流程 flushCard(card.getId()); // 5. 回源查询最新卡片,返回给前端 return buildResponse(cardDao.findById(card.getId())); }flushCard 的内部动作按顺序做这几件事:
public void flushCard(Long cardId) { CardRecord card = cardDao.findById(cardId); // 1. 初始化绑定的权限组 permissionService.bindDefaultPermission(card); // 2. 推进状态到激活,带上版本号做乐观锁 cardDao.updateStatus(cardId, 1, card.getVersion()); // 3. 使相关缓存版本号+1,旧缓存自然过期 cacheService.bumpCardVersion(card.getOwnerUid()); // 4. 事务提交后发布"卡片新增"事件 TransactionSynchronizationManager.registerSynchronization( new TransactionSynchronization() { @Override public void afterCommit() { eventPublisher.publish("CARD_ADDED", cardId); } } ); }这里最有价值的一条经验是:事件发布必须放在事务提交之后。如果事务还没提交就发事件,消费端立刻拿 cardId 回源查询,此时查询可能因为事务未提交而拿不到数据,导致这次刷新直接失败。我在早期版本里犯过这个错,表现为"时好时坏",排查了很久才发现是事务可见性问题。
3.3 前端实现:添加成功之后,做一次"冷静"的刷新
前端部分以小程序为例,逻辑在 Web 端同样适用。核心目标有三个:提交期间防止重复点击、成功后重新拉取列表而不是本地拼装、刷新过程要有加载态避免用户白屏困惑。
async function addCard(formData) { if (this.submitting) return; // 防重复提交 this.submitting = true; try { const resp = await request('/api/card/add', formData); if (resp.code !== 0) { toast(resp.msg); return; } // 立即在本地做一次占位渲染,提升感知速度 const placeholder = normalizeCard(resp.data); this.cardList.unshift(placeholder); // 静默刷新:重新拉取全量列表,以服务端顺序为准 const listResp = await request('/api/card/list', { page: 1, pageSize: 100 }); this.cardList = listResp.data.list; } catch (e) { toast('添加失败,请重试'); } finally { this.submitting = false; } }有两个细节值得多说。一是"占位渲染"的卡片对象,必须用服务端返回的数据做标准化,不能用前端自己拼的 JSON,否则容易出现字段缺失导致的渲染错乱。二是刷新请求如果失败,不能静默吞掉,至少要在控制台打一条日志,最好给用户一个"列表刷新失败,下拉重试"的提示。因为添加动作本身可能已经成功了,只是列表没拉回来,这时候如果表现成"失败",用户会再点一次提交,重复卡就是这么来的。
3.4 多端同步的落地方案:事件推送加版本判断
到了多端同步环节,模拟项目X采用的方案是服务端发布事件,App、小程序、后台管理各自监听。事件体只携带 cardId 和卡片版本号,消费端拿到之后回源查询,然后与本地版本比对,版本变化才更新本地数据。
{ "event": "CARD_ADDED", "data": { "cardId": "12345", "version": 2, "ts": 1690000000000 } }消费端处理逻辑可以简化为:
function handleCardAdded(data) { const localVersion = store.get(`card_version_${data.cardId}`); if (localVersion >= data.version) { return; // 已经处理过,丢弃 } const card = await fetchCardDetail(data.cardId); updateLocalStore(card); store.set(`card_version_${data.cardId}`, data.version); }这个方案的优点是天然幂等,重复消费、事件乱序都能靠版本号挡住。缺点是如果事件真的丢了,终端没有感知。所以我在系统里还加了一个每日对账任务:凌晨扫一遍服务端卡片状态,和各端的本地版本快照做对比,发现不一致就推送一次"强制刷新"事件。对账是脏活,但在卡片场景里,它比任何实时机制都让人安心。
4. 真实环境踩坑记录:不刷新、重复卡、闪屏全排查
4.1 添加成功但列表没变,先按这个顺序查
这类问题在项目里反复出现,我的排查顺序是固定的。第一查缓存 key 是否清全:只删了 user:cards 列表缓存,没删 card:detail 或者查询列表缓存,都会导致部分入口读旧数据。第二查请求链路是否有 CDN 或本地存储缓存:小程序端如果 getStorage 缓存了上一版的列表,请求即使成功了,渲染的还是旧数据。第三查并发场景:两个添加请求几乎同时完成,后返回的旧列表覆盖了新卡片,表现为"明明加上了,一刷新又没了"。
针对第一点,缓存版本号方案能根治大部分问题;针对第二点,前端读取列表时不要直接读本地缓存,要先请求再 fallback 到本地;针对第三点,处理方式上面已经写过,前端必须统一以服务端顺序做最终呈现。
4.2 用户连点三次提交,库里有三张重复卡怎么办
重复卡的本质是缺少幂等。前端按钮置灰只能防一部分,因为用户可能在弱网环境下重复发送请求,或者多个端同时提交。真正的防线在服务端,三层缺一不可:数据库唯一索引 card_no 保证物理上不可能有相同的卡号;业务幂等键(用户ID+卡号)在短时间内拦截重复请求;最后再靠状态机兜底,一张卡已经激活,就不会再被重复激活。
幂等键的设计要选准粒度。用"用户ID+卡号"比单纯卡号更好,因为不同用户可以持有相同物理编号的卡片(比如访客卡临时绑定不同访客)。但业务上也不建议把幂等做太宽,比如加上"日期",否则同一用户同一天只会被允许添加一次,不符合真实场景,反而造成误拦截。
4.3 刷新风暴:一次批量导入,把整个查询服务打挂
批量导入几百张卡,如果每一张卡都触发一次"列表刷新",前端就会被几百个请求淹没,服务端的查询缓存也会被连续失效,最终表现为"导入越热闹,页面越卡"。这是 Flush 设计里很容易忽略的容量问题。
我的处理思路是"合并刷新"。前端层面,批量导入完成后只做一次全量列表拉取,中间过程用进度条表现,不要逐张刷新。服务端层面,批量导入只发一个"列表变更"事件,卡片明细通过后续的分页列表接口自己拉取。再激进一点,同一批导入的所有卡共用一个批次 ID,Flush 只以批次为单位推进一次,而不是以卡为单位推进几百次。批量任务对实时性的要求本来就不高,把强一致放宽到"导入完成后最终一致",系统压力会小一个数量级。
4.4 状态不一致:这边显示激活,那边显示待激活
状态不一致的根源通常是事件丢失或者消费端处理异常。我遇到比较多的情形是:消费端代码在更新本地库半路抛了异常,卡片版本号没写入,后续事件因为版本号判断被误认为"已经处理"而丢弃,造成永久不一致。
解决思路有两条。第一,消费端更新本地数据和写入版本号必须放在同一个事务里,要么都成功,要么都失败。第二,不能只依赖事件机制,要有对账兜底。我在模拟项目X里加了一个定时任务,每天用服务端的数据做一次全量对账,发现不一致就自动生成补偿事件。对账任务跑了几周之后,状态不一致率归零了。我的体会是:实时机制负责体验,对账机制负责正确,两者缺一不可。
4.5 避坑清单:一张表记住所有经验
| 问题现象 | 根因 | 正确姿势 |
|---|---|---|
| 提交成功但列表没变 | 缓存 key 清不全 | 用缓存版本号方案,变更就让版本+1 |
| 列表顺序乱 | 前端按返回顺序 push | 一律以服务端最新列表顺序为准 |
| 重复添加多张卡 | 没有幂等 | 唯一索引+幂等键+前端拦截三层兜底 |
| 批量导入后卡顿 | 每张卡都刷新一次 | 合并刷新,按批次推进 |
| 各端状态不一致 | 事件丢失/消费异常 | 事务内更新本地库+每日对账兜底 |
| 时好时坏的刷新 | 事件先于事务提交发布 | afterCommit 之后再发事件 |
这张表里的每一条都是我实际踩过坑之后总结出来的,放在代码评审或者故障排查时对照着看,能省很多时间。
我在实际项目里学会的最后一件事,是把 Flush 当作和 Add 平级的一等公民来对待,而不是"添加成功之后顺手刷一下"的附加动作。需求的命名其实已经暗示了它的定位:"Card Add Flush"里的 Flush 是主动语态,是需要被认真设计、测试、对账的一个完整环节。如果你现在正被"卡片添加后不生效、不同步、重复添加"这类问题困扰,我建议你回到三个层面逐一排查:UI 层的列表刷新,数据层的缓存失效,以及服务端的跨端同步。把这三条线走通,你会发现,很多看似奇怪的问题,其实只是 Add 之后少了一次认真的 Flush。