上周线上项目遇到了一点问题,处理完成后,在这做个小结

相关功能

后台可通过配置发起拼团, 每个拼团可指定同一个用户的最大参与次数

遇到的问题

由于并发, 导致了代码在有前置判断用户拼团是否超限的前提下, 仍然可能出现拼团次数超限的情况

数据表结构

-- ----------------------------
-- Table structure for logs
-- ----------------------------
DROP TABLE IF EXISTS `logs` ;
CREATE TABLE `logs` (
`id` int(10) unsigned NOT NULL AUTO_INCREMENT COMMENT 'pk',
`act_id` int(6) unsigned NOT NULL DEFAULT '0' COMMENT '',
`user_id` int(10) unsigned NOT NULL DEFAULT '0' COMMENT '',
`pay_amount` int(6) unsigned NOT NULL DEFAULT '0' COMMENT '',
`cat` tinyint(1) unsigned NOT NULL DEFAULT '2' COMMENT '',
`created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '',
`form_id` varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '' COMMENT '',
utf8mb4_unicode_ci NOT NULL DEFAULT '' COMMENT '',
  PRIMARY KEY ( `id` ) USING BTREE,
  KEY `idx_userId` ( `user_id` ) USING BTREE
) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户拼团记录';

原有代码

// 开始处理请求
// 判断用户购买次数是否超限(通过 `act_id` && `user_id` )(当前N个并发请求同时通过验证,N>限制次数-用户已拼团次数)
// 开启事务
// 写入用户请求日志
// 提交事务(已写入N条数据,N>限制次数-用户已拼团次数)
// 请求处理完成

以上代码无法避免重复写入超过限制次数的用户拼团记录

目前的解决方案

  1. 数据表加unique index
    • 问题:无法兼容已存在的重复数据,如果修改线上数据,影响范围不易控制
  2. 通过在 insert 时使用 DUAL 表的方式配合 WHERE NOT EXISTS 的方式
    • 代码:INSERT INTO logs ( act_id, user_id, pay_amount, cat, form_id, created_at ) SELECT 1, 1, 1, 1, 'form_id', '2019-11-11 11:11:11' FROM DUAL WHERE NOT EXISTS ( SELECT COUNT(*) AS rows FROM logs WHERE user_id = 1 AND act_id = 1 GROUP BY user_id, act_id HAVING rows >= 拼团限制次数 );
    • 问题:在事务中执行会导致表锁
  3. 使用redis并发锁,以下内容主要是关于该方式的实现

方案3流程

// 开始处理请求
// 尝试获取锁
$lockSuccess = $this->_getRedisLock($actId, $userId);
if(false === $lockSuccess){
    // 返回系统繁忙一类的提示
}
// 判断用户购买次数是否超限(通过 `act_id` && `user_id` )(当前N个并发请求同时通过验证,N>限制次数-用户已拼团次数)
// 开启事务
// 写入用户请求日志
// 提交事务(已写入N条数据,N>限制次数-用户已拼团次数)
// 在后续每一个会结束当前function的位置释放锁(`throw`,`return`,etc.)
$this->_delLock($activity_id, $member_id);
// 请求处理完成

锁操作相关代码

    // 锁自动过期时间为60s
    const REDIS_GROUP_BUY_LOCK_EXPIRE = 60;

    /**
     * 获取用户拼团并发锁数据
     *
     * @param int $actId 活动ID
     * @param int $userId 用户ID
     *
     * @return bool
     */
    private function _getRedisLock($actId, $userId)
    {
        $redis = Redis::connection('demo');
        $key = 'logs_lock_' . $actId . '_' . $userId;

        try{
            // 初始化锁
            $res = $redis->set($key, 1, 'EX', self::REDIS_GROUP_BUY_LOCK_EXPIRE, 'NX');
            if(!$res){
                return false;
            }
            return true;
        }catch(\Exception $e){
            // 错误日志
            return false;
        }
    }

    /**
     * 用户拼团并发锁释放
     *
     * @param int $actId 活动ID
     * @param int $userId 用户ID
     */
    private function _delLock($actId, $userId)
    {
        $redis = Redis::connection('demo');
        $key = 'logs_lock_' . $actId . '_' . $userId;
        // 共尝试3次写入
        for($i=1;$i<=3;$i++){
            // 锁删除
            try{
                $redis->del([$key]);
                break;
            }catch(\Exception $e){
                // 错误日志
            }
        }
    }

影响范围

避免同一个用户对同一个拼团活动的多次请求导致的并发处理,相当于对并发处理的一个workaround

可能的异常

业务异常/redis连接异常导致没有成功释放锁,导致用户对同一个活动的下次请求的处理需要等到锁过期