一、引言
不管是在电商系统、还是物业系统中都会存在一个账单的东西,账单号是每一个账单的标识,这个标识是唯一的、且不可重复的。
在电商系统中,账单号就等同于订单号;在物业系统中,账单号就是每一次向业主催缴费的账单。
这个账单号一旦重复结果可以说是灾难级的,在物业系统中,收费的时候,明明不是这个业主的钱,怎么多算了;而另一个业主的钱甚至少算,或者该缴的费用被其他业主缴了等等,涉及到钱的问题那就是大问题了。
这篇文章,我们就来具体分析下这个问题,并给出解决方案。
二、发现问题:从用户反馈到技术排查
2.1 来自用户的反馈
在某个阳光明媚的上午,我正在工位前快马加鞭的完成今天的工作任务,突然一个身影“不合事宜”的出现在了我的旁边,我转头一看原来是客成(客户成功团队:负责项目咨询、问题反馈,相当于售后了)同事
:“咋了?”
:“客户那边反馈有个问题,你帮忙看一下呗”
:“啥问题”
:“客户说自己的业主有笔账单,本来一个月的物业费应该是321块,结果翻了双倍,变成了600多”
我眉头紧锁,心里想:这啥情况啊,钱还会算错?!
一看是钱的问题,我就在心里默默地把优先级排到了最高!(今天的工作事项?你可去一边儿等会儿吧)
我们负责的物业系统是多租户模式的,如果有一个租户出现了问题,那么其他租户也可能有相同的问题发生
:“好,让客户先提个工单过来,描述下具体问题,我再看一下”
一句话把同事先“打发”走,没过一会儿,企微上消息甩了过来,是刚刚聊的问题已经建立了工单,我立刻流转了状态,进入到问题排查中...
2.2 问题定位
排查问题三步走
一看业务代码:如果只看业务代码就能看到问题所在,那说明这个问题很容易就能解决
二看数据:通过查看业务代码,把账单费用计算的逻辑知晓,然后自己再计算一遍最后的金额。我自己算了一遍之后发现金额确实不对,把相关的SQL整理出来,甩到数据库客户端工具中(我最常用的还是Navicat Premium了)
不查不知道,一查吓一跳,一个账单号在账单主表中重复出现了两次?而且业主id竟然不是同一个人(啥情况?我的大脑差点宕机... )再看账单号字段,竟然没配置唯一索引?
三问老大:“*哥,这个账单表中的账单号字段为啥没加唯一索引啊?!”
:“嗯...”
经过漫长的嗯之后:“可能是历史遗留问题了,你自己看下解决一下啦”
又是历史遗留问题,这个项目的历史问题可太多了
回到自己的工位上,把生成账单的代码又仔仔细细看了一遍,感觉有点猫腻啊!伪代码如下:
// 获取code
public function getCode()
{
$redis = new Redis();
$billCode = '';
try {
if (empty($redis->get('bill_code'))) {
$redis->set('bill_code', 1);
$lastBillCode = $this->getLastBillCode();
$redis->clear('bill_code');
return $lastBillCode++;
}
throw new Exception('生成code失败!');
} catch (\Exception $e) {
throw new Exception('生成code失败!');
}
}
// 获取账单表中最后一条记录的code
public function getLastBillCode()
{
return $PDO->select("select code from bill order by datetime DESC limit 1");
}
上面的代码不仔细看还真看不出什么问题,但是好在我多年的经验练就了慧眼如炬,问题基本上清晰了。这段代码要实现逻辑其实很清晰
通过redis获取指定key
如果指定key不存在,设置指定key为1
获取最后一笔账单的code,自增+1返回
如果指定key已被设置,直接返回失败
这段代码不难理解为什么要这么写,应该是当时的开发者想要为当前生成账单code的行为设置一个锁,直到code生成成功,再把这个锁释放掉。这样如果有其他进程也想生成code,那再进来的时候先判断指定key是否被设置,如果已被设置,则返回失败。
这样的操作在大部分情况几乎没啥问题,但是在高并发的情况下,很容易就产生重复的code,因为查询和设置的动作不是原子性的;
既然问题已经确定了,那解决起来就很容易了。
以这个场景为例,其实我们需要的就是一个分布式锁了。
三、分布式锁
什么是分布式锁?
分布式锁是一种在分布式系统中(多节点、多进程、多服务)中协调共享资源的访问机制,确保在任一时刻只有一个客户端(节点)能访问资源(如数据库、文件、服务等),从而避免数据竞争和不一致问题。
使用场景
防止重复操作,例如:用户提交订单,确保订单只处理一次
库存扣减,避免超卖:多个服务同时扣减库存,保证原子性
定时任务调度,在分布式系统中,确保集群中只有一个服务能执行定时任务(如每天的数据统计、库存刷新等)
分布式缓存更新,多个节点同时更新缓存,避免脏数据
特性
互斥性
同一时间,只有一个客户端可以持有锁
防死锁
即使客户端崩溃,到了指定时间也能释放(通过超时机制)
高可用
锁服务需具备容错能力(如redis集群)
四、解决问题
那了解了什么是分布式锁,那基于我们这个场景,其实就很适用了。生成账单code,同一时间只能有一个生成账单的服务运行。那redis中是如何实现呢?
首先先了解redis的两个命令
redis两个命令
NX
NX全称Not Exists,表示只有键不存在时,才执行操作,比如设置值。
用法:实现分布式锁,或避免覆盖已有数据。
举例:
SET key "value" NX
如果key不存在,则设置key值为value,返回OK
如果key存在,则操作失败,返回nil
PX
PX
即毫秒级过期时间,作用是为键设置毫秒级的过期时间(TTL
)
redis设置过期时间命令的不只有PX,还有EX
,EX
设置秒级过期时间,PX
设置毫秒级过期时间(10000毫秒为10秒)
举例:
SET key "value" PX 10000 # 10秒后自动删除
结合来用
NX
+ PX
的组合常用于带超时的分布式锁
SET lock:resource "value" NX PX 10000
以上代码表示如果锁lock:resource
不存在,则设置值,并10秒后释放。如果锁已存在,则获取锁失败。
那在php中如何使用呢,参考以下代码
$redis = new Redis();
$redis->connect('127.0.0.1', 6379); // 连接 Redis
$script = 'return redis.call("SET", KEYS[1], ARGV[1], "NX", "PX", ARGV[2])';
$result = $redis->eval($script, ['lock_create_code', 1, 5000], 1); // 最后一个参数是 KEYS 的数量
if ($result === false) {
throw Exception "获取锁失败!~";
}
// 执行业务逻辑
echo "生成订单code中...\n";
sleep(2);
// 释放锁(需校验value)
$unlockScript = '
if redis.call("GET", KEYS[1]) == ARGV[1] then
return redis.call("DEL", KEYS[1])
else
return 0
end
';
$redis->eval($unlockScript, ['lock:create_code', 1], 1);
redis会单线程执行整个脚本,所以不用担心并发问题。
五、总结
好啦,以上就是遇到生成订单号重复的解决方案,可能也有更优的解决方案,但是目前这个方案个人感觉是最适合这个业务场景的。