本文将阐述竞争行为的分类、原理、风险特征、防御方案四个方面的内容。

分类

竞争行为可以分为两大类,条件竞争与时间竞争。

原理

用一句话来阐述条件竞争与时间竞争:顾名思义,多个进程或线程并发竞争同一个条件的行为称为条件竞争;竞争两个动作间的时间间隔的行为称为时间竞争。

条件竞争关键词:多进程/线程、并发、条件
时间竞争关键词:时间间隔

接下来对两类竞争行为分别进行举例,便于更好地理解原理、进行风险特征抽象。

条件竞争

商城推出了用积分兑换奖品的活动,业务规则如下:
5个积分可以兑换一件奖品,兑换完成以后用户积分总数减5;当用户积分总数小于5时无法进行兑换操作。

伪代码:

public void changeAwards(int uid) {

    // 步骤一:查询当前uid的积分
    // select point from xxx where uid=xxx
    int point = queryPointByUid(uid);

    // 判断:如果积分小于5,无法兑换奖品
    if (point > 5) {
    
        // 步骤二:积分兑换奖品逻辑,完成兑换后奖品数+1,积分数-5
        // update xxx set num=num+1,point=point-5 where uid=xxx
        usePointChangeAwards();
        
    }
 }

假设某个用户有20个积分,理论上他最多可以兑换4件奖品。

但如果该用户同时发起5个兑换奖品的请求,极端情况下这5个请求线程是完美并行的,也就是说这5个请求线程在步骤一中查到的积分都是20,均满足了积分大于5的条件,都会进入步骤二的逻辑。最终,5个update操作将用户的积分扣成了-5,但让用户兑换到了5件奖品。

回顾一下这个例子,5个线程并发竞争1个条件,业务预期只能成功4个,但最终5个都成功了。

时间竞争

为了防止黑客上传webshell,某程序员设计的图片上传功能的逻辑如下:

  1. 将用户上传的图片保存至某个路径(假设是web目录)
  2. 检测图片的后缀是否为.jpg或.png,如果不是,则删除该图片。

理论上攻击者上传的非图片后缀的文件最终都会被删除。

但图片保存至某个路径 -> 最终删除图片这个过程是存在时间间隔的,如果攻击者先上传一个木马文件,极端情况下可以在该木马文件被删除之前发起另外一个请求访问到并执行该木马。

风险特征

条件竞争风险多发于投票、兑换等业务,往往业务规则中会存在一个条件限制,并且没有做防并发的措施。
时间竞争风险多发于文件操作的安全校验逻辑,开发者使用了类似“先污染,后治理”的思路。

防御方案

以上的内容比较基础,因此描述得相对简单,接下来将会重点聊竞争风险的防御方案,也是本文的重点。大部分技术栈偏向于“攻”的安全同学可能会较少涉及防御的相关知识,可能也不感兴趣。但如果你想在甲方公司生存,攻击技术只是冰山一角。


时间竞争风险的防御方案相对容易,在代码逻辑层面做到“先治理,别污染”,不要给攻击者留下任何竞争的机会即可。

而条件竞争风险不仅仅是代码逻辑的问题了(代码逻辑往往没有问题)。

方案一 提高事务隔离级别

什么是事务?一般而言,用户的每次请求都对应一个业务逻辑方法,并且每个业务逻辑方法往往具有逻辑上的原子性。此外,一个业务逻辑方法往往包括一系列数据库原子访问操作,并且这些数据库原子访问操作应该绑定成一个整体,即要么全部执行,要么全部不执行,通过这种方式我们可以保证数据库的完整性。也就是说,事务是最小的逻辑执行单元,是数据库维护数据一致性的基本单位。
总的来说,事务是一个不可分割操作序列,也是数据库并发控制的基本单位,其执行的结果必须使数据库从一种一致性状态变到另一种一致性状态。事务具有四个重要特征,即原子性(Atomicity)、一致性(Consistency)、隔离性 (Isolation)和持久性 (Durability)。

如果继续拿上文商城的例子讲解,步骤二积分兑换奖品逻辑就是一个最简单的事务。为什么说是最简单呢?因为完成兑换后奖品数+1、积分数-5这两个操作是一个整体,逻辑写在了一条sql语句中,数据库底层的特性支持确保同一条sql语句一定是一个原子操作,要么全部执行,要么全部不执行。

通常情况下,一个事务往往包含多个子逻辑、多条sql语句。以下是一个典型的JDBC事务。

// JDBC事务
Connection conn = getConnection();
conn.setAutoCommit(false);
...
// 业务实现(包含多个子逻辑)
...
if 正常
    conn.commit();
if 失败
    conn.rollback();

因此使用积分兑换奖品这整个业务也可以被作为一个事务。

事务的隔离性是指并发执行的事务之间不能相互影响。也就是说,对于任意两个并发的事务 T1 和 T2,在事务 T1 看来,T2 要么在 T1 开始之前就已经结束,要么在 T1 结束之后才开始,这样每个事务都感觉不到有其他事务在并发地执行。

如果把积分兑换奖品整块业务作为一个事务,显然隔离性这个特征没有被实现。而条件竞争风险属于事务并发风险中“不可重复读”或者“幻读”类别的范畴。不可重复读、幻读是事务非独立执行时发生的一种现象,指的是在一个事务读的过程中,另外一个事务可能更新、插入了新数据记录,导致了业务非预期。
MySQL数据库为我们提供了四种隔离级别,分别为:
Serializable (串行化):最高级别,可避免脏读、不可重复读、幻读的发生;
Repeatable read (可重复读):可避免脏读、不可重复读的发生;
Read committed (读已提交):可避免脏读的发生;
Read uncommitted (读未提交):最低级别,任何情况都无法保证。

如果我们在建立JDBC连接时指定隔离级别为Repeatable read或者Serializable,可以避免条件竞争问题的发生。

方案二 使用编程语言内置锁

Java内置锁:synchronized

在多线程编程中,有可能会出现同时访问同一个共享、可变资源的情况,这种资源可以是:一个变量、一个对象、一个文件等。特别注意两点,共享:意味着该资源可以由多个线程同时访问;可变: 意味着该资源可以在其生命周期内被修改。所以,当多个线程同时访问这种资源的时候,就会存在一个问题:由于每个线程执行的过程是不可控的,所以需要采用同步机制来协同对对象可变状态的访问。  

在Java中使用synchronized内置锁将使得多线程同步访问对象、方法或者代码块,同样可以解决条件竞争问题。

伪代码:

public synchronized void changeAwards(int uid) {
    ...
}

方案三 使用数据库锁

乐观锁

乐观锁,虽然名字中带“锁”,但是乐观锁并不锁住任何东西,而是在提交事务时检查这条记录是否被其他事务进行了修改:如果没有,则提交;否则,进行回滚。乐观锁适用于读取频繁的场景。

我们可以修改一下步骤二的sql语句,增加一个where条件,判断point>5

update xxx set num=num+1,point=point-5 where uid=xxx and point>5

也可以采用在sql中增加数据版本判断的方法:
数据版本是为数据增加的一个版本标识。当读取数据时,将版本标识的值一同读出,数据每更新一次同时对版本标识进行更新。当我们提交更新的时候,判断数据库表对应记录的当前版本信息与第一次取出来的版本标识进行比对,如果数据库表当前版本号与第一次取出来的版本标识值相等,则予以更新,否则认为是过期数据。一般地,实现数据版本有两种方式,一种是使用版本号,另一种是使用时间戳。

//步骤一: 读取的时候将版本表示的值version读出,存到now_version变量
select point,version from xxx where uid=xxx

//步骤二: 更新时判断version是否等于now_version,如果等于则对version的值也进行更新;如果不等于意味着当前行已经被其它线程更新
update xxx set num=num+1,point=point-5,version=version+1 where uid=xxx and version=now_version

除了防并发外,sql中增加数据版本判断也是一种控幂等的办法。

悲观锁

悲观锁,正如其名,它指的是对数据被外界修改持保守(悲观)态度,因此,在整个数据处理过程中,将数据处于锁定状态。悲观锁的实现往往依靠数据库提供的锁机制,也只有数据库层提供的锁机制才能真正保证数据访问的排他性,否则即使在本系统中实现了加锁机制,也无法保证外部系统不会修改数据。悲观锁是真正的锁,在数据库中通常使用“select for update”的方式去实现。悲观锁适合写入频繁的场景。

begin;
select point from xxx where uid=xxx for update;
update xxx set num=num+1,point=point-5 where uid=xxx;
commit;

上文提到的Java synchronized关键字也是一种悲观锁。

最佳实践

对于方案一,数据库隔离级别越高,安全性越高,但性能也越差。MySQL数据库的默认隔离级别为Read committed,通常在实际业务中,我们也无法建议业务方使用更高的数据隔离级别,比如Serializable级别,如果开启它意味着当前业务将损失所有的并行性能,这是无法接受的。

方案二存在同样的性能问题,如果是在业务访问极其频繁、并发可能性并不太大的业务场景,也无法采纳。

方案三中,悲观锁主要用于数据争用激烈的环境或者发生并发冲突时使用锁保护数据的成本要低于回滚事务的成本的环境中;而乐观锁主要应用于并发可能性并不太大、数据竞争不激烈的环境中,这时乐观锁带来的性能消耗是非常小的。

结论,通常在存在条件竞争风险的业务场景中,正常业务操作产生并发的可能性并不太大,且数据竞争不激烈,使用乐观锁是解决条件竞争问题的最佳实践。


Reference:
https://blog.csdn.net/justloveyou_/article/details/70312810
https://blog.csdn.net/justloveyou_/article/details/54381099