点个赞,红心变红,请求超时。前端重试逻辑再发一次,这次成功回复到了,红心却变灰了。我明明点了赞,代码却另有打算——因为它不具备容错能力。

打开网易新闻 查看精彩图片

容错指的是系统某部分失败时,应用仍能正确工作。重试能帮我们从临时连接故障中恢复,但API必须安全地处理第二次尝试。数据库写入这里有个坑:保存变更和返回HTTP响应是两个独立步骤,连接可能在数据库提交之后断开,客户端根本不知道写入到底成没成功。

顺着这次点击,看看它在MongoDB和Neon上的PostgreSQL里分别发生了什么,再把重试变安全。

一次点击,两个请求

端点是这样的:POST /posts/42/toggle-like。它反转当前状态——我的赞存在就删掉,不存在就创建。一次点击如何同时做到两件事:

  • API保存了我的赞。
  • 连接在成功回复到达浏览器之前断开。
  • 浏览器重试。
  • API找到这个赞,把它删掉。这次回复成功送达。

第一次写入成功了,只是它的回复丢了。重试确实从连接故障中恢复了,但它同时反转了用户的操作。要让这个端点容错,两部分都得处理对。

这个赞可以存在MongoDB文档里,也可以存在Neon的PostgreSQL行里。无论哪种数据库,都会老老实实执行一个新请求去删掉它。API让它toggle,它就照做。

包进事务行不行?

可以。事务把数据库变更分组,要么全部提交,要么全部回滚,这就是原子性。MongoDB在副本集和分片集群上支持跨文档事务,Neon支持PostgreSQL事务。

但在我们的例子里,第一个事务在回复消失之前就已经提交了。重试开启一个新事务,把赞反转掉。两个事务都成功,我以极好的数据完整性取消了对你的点赞。行锁同样会随事务结束而释放,后来的重试照样能再改一次。

需要改变的是:重复请求会产生什么效果。

让请求表达我的真实意图

别让API去toggle,让它确保我的赞存在。第一个请求创建它,重试找到它并原样留着。这就是幂等:重复请求与发送一次具有相同的预期效果,响应可以不同,预期效果不变。

用PUT /posts/42/like确保赞存在,取消赞则用DELETE /posts/42/like确保赞不存在。处理器的数据库操作必须兑现这个承诺,只改HTTP动词,原来的toggle行为还在。

MongoDB可以用upsert创建缺失的赞,Neon上的PostgreSQL可以用INSERT … ON CONFLICT DO NOTHING。两者都需要针对用户与帖子组合的唯一规则,这样并发请求也只会产生一个赞。

实现示例

MongoDB:先建一次唯一索引,然后在请求处理器里用upsert,userId从已登录会话中取。

  • 一次性设置:对 { postId: 1, userId: 1 } 建唯一索引。
  • PUT处理器中:以 { postId, userId } 为条件,$setOnInsert 写入 likedAt,开启 upsert。
  • 已存在的赞保留原始时间戳。

Neon上的PostgreSQL:给likes表加上 (post_id, user_id) 主键,然后执行参数化查询 INSERT INTO likes (post_id, user_id) VALUES ($1, $2) ON CONFLICT (post_id, user_id) DO NOTHING。重试找到已存在的组合,原样不动。

如果另外维护了点赞计数器,只在新赞被插入时增加它,并且放在同一个事务里。

非要保留toggle端点怎么办

上面的PUT方案重复的是同一个期望状态。如果保留 POST /posts/42/toggle-like,就需要一种方式识别某个特定操作已经发生过。

给每次有意的点击一个幂等键。这个键在所有重试中标识同一个操作:客户端重试时复用它,新的有意点击则生成新键。在一个事务里,按用户和端点范围在唯一约束下认领这个键,执行toggle,保存结果。同一个键再次到来时,直接返回保存的结果。

然后测试那个确切的故障:让写入成功,丢掉它的HTTP回复,再重试。刷新之后,应该仍然只有一个赞。

无论赞住在MongoDB还是Neon,两次尝试现在都留下我们想要的结果。一次点击,一个赞。红心该保持红色。