ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

支付系统开发避坑:幂等、索引、TCP、时区与缓存全复盘

支付系统开发避坑:幂等、索引、TCP、时区与缓存全复盘 凌晨一点我盯着支付回调的日志屏幕上两笔一模一样的订单流水几乎是挨着出现。笔数不多但每一笔都对应着真实的用户扣款。那一刻我意识到自己平时写业务代码时太依赖“直觉”把太多基础知识点当成了理所当然。这个项目本身不复杂——接入新支付渠道、把回调落到订单表、再跑对账——但正是因为不起眼才把许多我平时“以为自己会”的东西全部照了出来。我想把这些最近遗漏的知识点按真实场景记录下来。它们各有各的触发条件但共同点是都藏在文档里不踩坑时根本不会在意。这篇既是复盘也可以当一份排查清单来用。1. 幂等设计重复回调这件事我当时处理得太天真1.1 为什么支付渠道会重复通知接入支付回调时我的第一版逻辑很直白收到通知验签通过订单号查订单然后更新状态。对照渠道方的接口文档时一切看起来都顺理成章。直到联调环境里我手动点击“重发通知”线上服务同时收到两条内容一致的回调两个线程都经过“查订单”的步骤发现状态都是待支付于是走了两次相同的更新逻辑。虽然没有报错但支付流水记录被写入两次用户收到的扣款通知也变成两条。后来翻渠道方日志才发现回调并不是“成功就通知一次”它们的服务端会在一段时间窗口内按 1 分钟、5 分钟、15 分钟的间隔持续重试直到商户接口返回明确成功或者最终放弃。这背后的原因是任何网络传输都不能保证“恰好一次”TCP 只能保证不丢包不能保证业务系统处理成功。回调接口被设计出来的那一刻就已经默认了消费方必须做到幂等也就意味着我不能依赖“渠道不会重复通知”这个假设。1.2 从“先查再插”到唯一约束的调优过程错误的做法是“先查订单再决定是否更新”。这个做法在低并发下很好用但一旦两个请求同时到达它们都可能读到“待支付”的状态然后各自完成更新窗口期里没有原子性。要解决并发下的重复第一步是把业务更新语句改成带状态条件的UPDATE orders SET status 已支付, paid_at NOW() WHERE order_no 20231223001 AND status 待支付;受影响行数为 0说明这条通知之前已经被处理过了可以直接返回成功不需要再做业务变更。注意这里不能只依赖“先查后改”因为查询和更新之间总有间隔并发请求正是钻这个空子。状态条件更新把“判断和处理”合并成一条原子 SQL比任何锁都直接。但在复杂的支付场景里单靠订单状态还不够。渠道交易号本身也应该具备唯一约束。我当时补了一张通知流水表把每次回调原始报文都存下来再给渠道侧的交易号加唯一索引。处理流程变成先写入流水表如果插入重复直接忽略只有第一次插入成功才继续去更新订单状态。这样即使下游业务逻辑写得再粗心唯一的约束都会在数据库层拦住重复。1.3 通知流水表不只是防重更是排查工具这张流水表后来的价值远超我的预期。联调阶段渠道方偶尔反馈“某个通知你那边返回异常”我可以通过订单号反查出这个订单从早到晚被回调了几次、每次间隔多久、是验签失败还是写入失败。如果把所有回调都只当“防重复”处理而不落库排查时只能靠应用日志日志一旦被滚动冲掉就只能靠猜。更进阶的用法是拿它做对账。每天凌晨从流水表里拉取已经成功的通知记录和渠道后台的交易列表做一次对比看哪些交易渠道扣了款但我们没有处理记录哪些我们记录了但渠道列表里不存在。对账关注的是全量流水和终态结果的差集而不是“最后一次同步状态”。如果没有这张独立于业务表之外的流水表对账很可能要从业务单据里反推支付记录容易漏掉中间状态。2. 索引失效EXPLAIN 结果里的全表扫描让我沉默了两次2.1 隐式类型转换varchar 与 int 的意外陷阱幂等处理好之后压测才开始慢查询就陆续冒出来。我看了一眼其中一条 SQL发现订单号列明明是varchar(64)但查询条件写的是order_no 20231223001没有加引号。MySQL 在比较字符串列和数字常量时会默认把字符串转成数字再比较而不是把数字转成字符串。这一转换会让索引失去原本的字符串有序性优化器只能放弃索引走全表扫描。这种隐式转换并不限于单表查询。A 表的主键是bigintB 表关联字段是varchar两张表 JOIN 时同样会触发转换导致 B 表索引失效。我后来在项目里定了一条规则关联字段的类型定义必须完全一致包括长度、有无符号、字符集。因为字段定义一致才能从根本上让数据库的优化器老实走索引而不是靠每次写 SQL 都要记得“加引号”。另一个容易忽略的写法是查询YEAR(create_time)、DATE_FORMAT(create_time, %Y-%m)这类函数包裹列。索引的核心优势是 B 树内部按原值排序一旦列参与函数运算每个候选值都要先被算一遍才能比较无法利用“有序”这个前提索引自然失效。解决办法很简单把函数计算挪到常量那一边而不是套在列上。比如统计某天的订单我过去习惯写SELECT COUNT(*) FROM orders WHERE DATE_FORMAT(created_at, %Y-%m-%d) 2024-06-01;后来改成区间扫描SELECT COUNT(*) FROM orders WHERE created_at 2024-06-01 00:00:00 AND created_at 2024-06-02 00:00:00;左边保持列本身不参与任何计算优化器就能直接走索引区间。这个知识点我确实早知道但实战时还是习惯写前者因为读起来更接近自然语言。直到线上全表扫描的数据量打到几十万行压测响应时间一口气翻了好几倍我才真的长记性。2.2 复合索引的列顺序范围查询后还有没有机会复合索引的列顺序也踩过坑。当时索引建的是(merchant_id, create_time, status)查询条件依次是merchant_id ? AND create_time ? AND status ?。这里的问题在于create_time是范围条件范围条件一旦出现索引后面的status就没办法继续利用有序性进行精确定位了。优化器只能快速定位到商户和时间范围再在这个范围内逐行检查status。要优化这类查询需要重新思考列顺序。如果status的选择性更强把索引调整成(merchant_id, status, create_time)优化器就可以先用前两列等值定位再把create_time作为范围压入一个较紧的子区间。这个决策完全取决于实际数据分布并不存在一个万能顺序。我的经验是先看区分度再配合EXPLAIN的key_len判断优化器用了索引前缀到哪一列。2.3 上线前多看几眼 EXPLAIN 的输出其实上面两类问题只要在发版前跑一次EXPLAIN看一眼type是不是ALL、rows是不是整表行数基本都能提前发现。关键是要养成本能习惯而不是等项目跑挂了再回头检查。我现在每次写完涉及生产表的 SQL都会顺手在测试环境用EXPLAIN ANALYZE跑一遍把type和rows截图贴在 MR 描述里。哪怕接口只改一个过滤条件这个步骤也不省。压测里很多“突然变慢”的接口追根溯底都是新增条件时没重新确认索引利用率。3. TIME_WAIT 与端口耗尽压测报告里的连接错误3.1 TCP 主动关闭方为什么要等 2MSL第三件事发生得很有意思接口本身吞吐量没有变化但压测脚本突然开始大面积报Cannot assign requested address。服务端 CPU 和内存都很平稳数据库也没有问题问题完全出在客户端。我那时候才真正花时间去理解 TIME_WAIT而不只是背“主动关闭方会进入 TIME_WAIT”。TCP 连接断开后网络上还可能残留着旧连接的报文迟到、重传如果端口马上被新的连接复用旧报文可能被误认为是新连接的数据或确认号造成数据混乱。TIME_WAIT 持续时间为 2MSL就是让这一段网络上的潜在残留报文全部自然消失保证旧连接的“幽灵”不会污染新连接。2MSL 通常对应一到两分钟内核参数里的默认值不建议乱调这是 TCP 自己的保护机制不是可以随手优化的“垃圾回收”。3.2 客户端同一时刻到底能用多少个端口之所以报错是因为我的压测脚本里每次请求都用fetch新建连接请求结束就断开。客户端发起连接时需要从ip_local_port_range里挑一个可用端口与目标 IP 和端口组成四元组。默认范围一般是从 32768 到 60999也就是两万多个端口。每次短连接释放后客户端端口会进入 TIME_WAIT在这段时间里无法被再次使用。当压测持续一段时间后可用端口就被消耗干净新的连接自然建立不起来。压测脚本的并发量不算高但架不住每秒都在新建几分钟就吃满了端口。那个时刻我再回头看netstat -tan满屏都是 TIME_WAIT就一点也不意外了。要理解这里必须分清“连接”和“请求”的区别连接是四元组维度的短连接模式下每次请求都消耗一个新的四元组长连接模式下同一个四元组可以承载很多请求。3.3 正确解法连接复用而不是粗暴调内核最有效的解决方式是连接复用也就是连接池。HTTP/1.1 的 keep-alive 本身就是这个目的。我调整了压测客户端让所有请求复用同一个连接TIME_WAIT 的数量立刻降到几乎为零。生产环境里的服务如果偏向短连接重点也应该放在接入连接池上比如数据库连接池、Redis 连接池、HTTP 客户端连接池而不是一味地优化内核参数。如果确实存在大量无法复用的短连接Linux 里可以考虑打开net.ipv4.tcp_tw_reuse。这个参数允许客户端在发起新连接时安全重复使用处于 TIME_WAIT 的端口注意它只作用于发起连接的一方。至于tcp_tw_recycle这类需要谨慎尤其不要部署在 NAT 环境里因为它依赖时间戳判断同一 NAT 后面的多台机器会被误伤出现连接不可达的诡异现象。我不会在没搞清网络拓扑前开启它更多时候连接池已经足够解决问题。4. 时区与夏令时订单日期差了一天这种Bug最让人恼火4.1 存储一律用 UTC展示时才转本地第三个踩坑的地方是时间。订单表最初存的paid_at是timestamp类型数据库和服务器时区都配的北京时间项目初期完全没感觉有问题。后来业务覆盖到其他地区用户反馈“订单日期比实际少了一天”我才开始追时区链路。问题出在整套链路里许多环节各自有时区默认值数据库连接参数里有一个时区应用服务器的系统时区是一个日志打印时还会再转一次。用户提交的时间字符串本身带它自己的时区偏移到了后端按应用服务器时区解析。三者只要有一个不同最终展示就会出现小时级的偏移跨过某个临界点日期就变成“昨天”或者“明天”。后来我统一了规则数据库里存 UTC 或 Unix 时间戳应用层收发协议只认带时区的 ISO 8601 字符串解析后立刻转成 UTC 存起来展示时由前端或 API 层按用户的时区翻译。这样时区就只出现在边界业务代码永远在 UTC 语义下运算。PostgreSQL 里直接用timestamptz它在内部以 UTC 保存但在计算时又能正确理解带偏移的输入值比timestamp省掉很多隐式转换的麻烦。4.2 夏令时给周期计算带来的 23 小时和 25 小时时区的另一半坑是夏令时。某些地区春季会有一天只有 23 个小时因为凌晨时钟往前拨一小时秋季则有一天有 25 个小时。如果计算周期时写死“加上 24 小时”跨过切换点的那一排任务时间就会整体错位一个小时。这类问题典型出现在每日跑批、账单周期、订阅到期时间上。比如“订阅本月底到期”如果从月初加 30 个自然日与“下月 1 日零点”在夏令时切换时会差出整整一小时。处理原则是要算“日历上的下一天”就不要用now 24h而应该用日期时间库里的“加一天”语义。Java 里用ZonedDateTime.plusDays()Go 里用time.AddDate()前端用dayjs().add(1, day)这些方法会基于日历语义去处理小时数的浮动而不是简单做固定秒数运算。如果非得用 duration就必须显式固定一个 UTC 偏移避免走到“时钟往回拨一小时”的区间。4.3 导出文件名和定时任务全都没有幸免时区问题还藏在一个最容易忽视的地方文件名。我当时导出的对账单以日期命名布尔变量叫todayStr直接用服务器本地时间生成。结果某一天生产服务器的时区和业务预期不一致文件生成出来以后业务人员看到文件名里的日期和账务日期对不上排查了很久才发现是服务器时区问题。类似的还包括定时任务。很多调度框架在没有显式时区时默认使用运行进程的系统时区。两个容器如果分别部署在不同时区的主机同一个cron表达式就会在不同时刻触发。我的处理方式是把调度任务里的时区写死让任务表达式带上timezone字段比如0 0 2 * * * America/New_York。所有日志也统一输出带时区的 RFC3339 时间戳这样跨系统排查时只要时间格式标准就不至于因为日志格式不统一而互相扯皮。5. HTTP 缓存新旧文件混用和“304”背后的协商机制5.1 Cache-Control 和文件名指纹缺一个都不行最后一个知识点来自前端发布。重构上线后总有一部分用户打开页面时还在请求旧版本的脚本控制台里能看到 HTML 是新版本但 JS 和 CSS 还是旧缓存。问题出在缓存策略配得不够细。最稳妥的静态资源方案通常是这样构建工具给每个文件内容生成唯一的哈希文件名比如app.8f3d9c.js。这些带哈希的文件可以直接给很长的强制缓存时间例如Cache-Control: public, max-age31536000, immutable。因为文件名变了浏览器没有理由拿旧文件文件名没变说明文件内容确实没变本地缓存一年反而最优。而入口 HTML 文件不能这样做否则浏览器会一直拿旧入口永远不知道引用了新资源。HTML 应该用no-cache让浏览器每次回源验证配合协商缓存拿到最新入口信息。5.2 ETag 和 Last-Modified它们不是强制缓存很多人会把强制缓存和协商缓存搞混。max-age是强制缓存命中后浏览器不会再发请求直接用本地副本ETag 和 Last-Modified 是协商缓存命中后还是要发一次请求服务端返回304 Not Modified不发响应体从而省流量。两者解决的问题并不一样也不冲突。ETag 的常见生成方式是文件内容的哈希或者是 inode、文件大小、修改时间的组合。浏览器在后续请求时带If-None-Match服务端自动比较当前资源的 ETag相同则返回 304。我踩过的一个细节是某些网关会自动为响应生成 ETag它可能基于内容的压缩后表示也可能基于响应头的一部分排序结果。如果服务端调整过资源返回头的生成顺序原来相同的资源会被误判为变更304 命中率会断崖式下降。修改类似逻辑时要在灰度环境观察状态码分布再推到全量。5.3 代理缓存和 Vary 头这个坑很隐蔽如果资源经过 Nginx 或 CDN还需要关注Vary响应头。Vary的意思是同一个 URL 在不同请求头条件下可能返回不同的内容。典型例子是Accept-Encoding: gzip和br。如果源站本应根据请求头返回不同压缩格式但没有在响应里写Vary: Accept-Encoding代理层就可能把“gzip 压缩版”缓存下来之后发给不接受 gzip 的客户端导致乱码或者空白页。只要同一个 URL 会因为 Cookie、User-Agent、地区参数而变化就都应该通过Vary把差异表达清楚。代理层缓存 key 的生成默认只考虑 URL 和 Vary 指定头部如果后端忘了就会把不该公用的内容当成同一份缓存。出于稳妥拿不准的接口宁可设no-cache也不要让代理层缓存过头。5.4 一个可以直接复制的最小 Nginx 配置最后放一个最常用的静态资源配置生产环境按实际目录结构调整即可location /static/ { add_header Cache-Control public, max-age31536000, immutable; } location /index.html { add_header Cache-Control no-cache; etag on; }带哈希的静态文件用一年期强缓存HTML 入口走协商。这个配置不需要什么高级技巧但能把“用户更新后仍访问旧版本”的问题挡在门外。任何前端缓存策略都必须回答两个问题入口文件怎么保证最新构建产物怎么最大程度复用本地缓存。最后分享一个小习惯。这次项目结束之后我在每个模块的提交清单里都会多问三个问题这段逻辑会不会被重复调用且没有幂等保护这个时间在目标用户的时区里看起来是否正确这个响应被缓存后会不会让用户看到过时的内容这三个问题都来自这次踩坑经历看起来很简单但它们每一个都让我在深夜多花过几个小时。如果这些记录也能让读到的人少走一次弯路那今天这篇文章就没白写。
返回列表