MySQL InnoDB甚至在READ COMMITTED中也将主键锁定在删除状态


11

前言

我们的应用程序运行多个线程,这些线程DELETE并行执行查询。这些查询会影响隔离的数据,即,不可能存在并发DELETE发生在来自不同线程的同一行上。但是,对于每个文档,MySQL DELETE均对语句使用所谓的“下一个键”锁定,该锁定既锁定匹配键又锁定一些间隙。这会导致死锁,我们发现的唯一解决方案是使用READ COMMITTED隔离级别。

问题

执行DELETE具有JOINs个大表的复杂语句时会出现问题。在特定情况下,我们有一个带有警告的表,该表只有两行,但是查询需要从两个单独的INNER JOINed表中删除属于某些特定实体的所有警告。查询如下:

DELETE pw 
FROM proc_warnings pw 
INNER JOIN day_position dp 
   ON dp.transaction_id = pw.transaction_id 
INNER JOIN ivehicle_days vd 
   ON vd.id = dp.ivehicle_day_id 
WHERE vd.ivehicle_id=? AND dp.dirty_data=1

当day_position表足够大时(在我的测试案例中有1448行),那么即使使用READ COMMITTED隔离模式的任何事务也会阻塞整个 proc_warnings表。

这个问题始终重现这个样本数据- http://yadi.sk/d/QDuwBtpW1BxB9都在MySQL 5.1(在59年1月5日检查)和MySQL 5.5(在MySQL 5.5.24检查)。

编辑:链接的示例数据还包含查询表的架构和索引,为方便起见,在此处复制:

CREATE TABLE  `proc_warnings` (
    `id` int(11) NOT NULL AUTO_INCREMENT,
    `transaction_id` int(10) unsigned NOT NULL,
    `warning` varchar(2048) NOT NULL,
    PRIMARY KEY (`id`),
    KEY `proc_warnings__transaction` (`transaction_id`)
);

CREATE TABLE  `day_position` (
    `id` int(10) unsigned NOT NULL AUTO_INCREMENT,
    `transaction_id` int(10) unsigned DEFAULT NULL,
    `sort_index` int(11) DEFAULT NULL,
    `ivehicle_day_id` int(10) unsigned DEFAULT NULL,
    `dirty_data` tinyint(4) DEFAULT NULL,
    PRIMARY KEY (`id`),
    KEY `day_position__trans` (`transaction_id`),
    KEY `day_position__is` (`ivehicle_day_id`,`sort_index`),
    KEY `day_position__id` (`ivehicle_day_id`,`dirty_data`)
) ;

CREATE TABLE  `ivehicle_days` (
    `id` int(10) unsigned NOT NULL AUTO_INCREMENT,
    `d` date DEFAULT NULL,
    `sort_index` int(11) DEFAULT NULL,
    `ivehicle_id` int(10) unsigned DEFAULT NULL,
    PRIMARY KEY (`id`),
    KEY `ivehicle_days__is` (`ivehicle_id`,`sort_index`),
    KEY `ivehicle_days__d` (`d`)
);

每笔交易的查询如下:

  • 交易1

    set transaction isolation level read committed;
    set autocommit=0;
    begin;
    DELETE pw 
    FROM proc_warnings pw 
    INNER JOIN day_position dp 
        ON dp.transaction_id = pw.transaction_id 
    INNER JOIN ivehicle_days vd 
        ON vd.id = dp.ivehicle_day_id 
    WHERE vd.ivehicle_id=2 AND dp.dirty_data=1;
  • 交易2

    set transaction isolation level read committed;
    set autocommit=0;
    begin;
    DELETE pw 
    FROM proc_warnings pw 
    INNER JOIN day_position dp 
        ON dp.transaction_id = pw.transaction_id 
    INNER JOIN ivehicle_days vd 
        ON vd.id = dp.ivehicle_day_id 
    WHERE vd.ivehicle_id=13 AND dp.dirty_data=1;

其中之一总是失败,并显示“超过了锁定等待超时...”错误。将information_schema.innodb_trx包含以下行:

| trx_id     | trx_state   | trx_started           | trx_requested_lock_id  | trx_wait_started      | trx_wait | trx_mysql_thread_id | trx_query |
| '1A2973A4' | 'LOCK WAIT' | '2012-12-12 20:03:25' | '1A2973A4:0:3172298:2' | '2012-12-12 20:03:25' | '2'      | '3089'              | 'DELETE pw FROM proc_warnings pw INNER JOIN day_position dp ON dp.transaction_id = pw.transaction_id INNER JOIN ivehicle_days vd ON vd.id = dp.ivehicle_day_id WHERE vd.ivehicle_id=13 AND dp.dirty_data=1' |
| '1A296F67' | 'RUNNING'   | '2012-12-12 19:58:02' | NULL                   | NULL | '7' | '3087' | NULL |

information_schema.innodb_locks

| lock_id                | lock_trx_id | lock_mode | lock_type | lock_table | lock_index | lock_space | lock_page | lock_rec | lock_data |
| '1A2973A4:0:3172298:2' | '1A2973A4'  | 'X'       | 'RECORD'  | '`deadlock_test`.`proc_warnings`' | '`PRIMARY`' | '0' | '3172298' | '2' | '53' |
| '1A296F67:0:3172298:2' | '1A296F67'  | 'X'       | 'RECORD'  | '`deadlock_test`.`proc_warnings`' | '`PRIMARY`' | '0' | '3172298' | '2' | '53' |

如我所见,两个查询都希望X在主键= 53的行上具有排他锁。但是,它们都不必须从proc_warnings表中删除行。我只是不明白为什么索引被锁定了。此外,当proc_warnings表为空或day_position表包含较少的行数(即一百行)时,索引不会被锁定。

进一步的调查是要运行EXPLAIN类似的SELECT查询。它表明查询优化器不使用索引来查询proc_warnings表,这是我可以想象为什么它会阻塞整个主键索引的唯一原因。

简化的情况

当只有两个带有几个记录的表但子表在父表ref列上没有索引时,在更简单的情况下也可以重现问题。

建立parent表格

CREATE TABLE `parent` (
  `id` int(10) unsigned NOT NULL,
  PRIMARY KEY (`id`)
) ENGINE=InnoDB

建立child表格

CREATE TABLE `child` (
  `id` int(10) unsigned NOT NULL,
  `parent_id` int(10) unsigned DEFAULT NULL,
  PRIMARY KEY (`id`)
) ENGINE=InnoDB

填写表格

INSERT INTO `parent` (id) VALUES (1), (2);
INSERT INTO `child` (id, parent_id) VALUES (1, NULL), (2, NULL);

在两个并行事务中进行测试:

  • 交易1

    SET TRANSACTION ISOLATION LEVEL READ COMMITTED;
    SET AUTOCOMMIT=0;
    BEGIN;
    DELETE c FROM child c 
      INNER JOIN parent p ON p.id = c.parent_id 
    WHERE p.id = 1;
  • 交易2

    SET TRANSACTION ISOLATION LEVEL READ COMMITTED;
    SET AUTOCOMMIT=0;
    BEGIN;
    DELETE c FROM child c 
      INNER JOIN parent p ON p.id = c.parent_id 
    WHERE p.id = 2;

两种情况的共同点是MySQL不使用索引。我相信这就是锁定整个表的原因。

我们的解决方案

我们现在可以看到的唯一解决方案是将默认的锁定等待超时从50秒增加到500秒,以使线程完成清理工作。然后保持双手交叉。

任何帮助表示赞赏。


我有一个问题:您是否在任何事务中执行COMMIT?
RolandoMySQLDBA

当然。问题在于,所有其他事务都必须等待,直到其中一个提交更改为止。简单的测试用例不包含提交语句来显示如何重现该问题。如果您在非等待的事务中运行提交或回滚,它将同时释放锁,等待的事务将完成其工作。
vitalidze

当您说MySQL在两种情况下都不使用索引时,是因为实际情况中没有索引吗?如果有索引,您可以为其提供代码吗?是否可以尝试以下任何发布的索引建议?如果没有索引,并且无法尝试添加任何索引,则MySQL无法限制每个线程处理的数据集。如果是这种情况,那么N个线程将简单地将服务器工作负载乘以N倍,而仅让一个线程运行带有参数列表(例如{WHERE vd.ivehicle_id IN(2,13)AND dp.dirty_data = 1;}。
JM Hicks

好的,在链接的示例数据文件中找到了隐藏的索引。
JM Hicks

还有两个问题:1)day_position当表开始运行得太慢以至于您必须将超时限制提高到500秒时,该表通常包含多少行?2)仅拥有样本数据时,要花多长时间?
JM Hicks

Answers:


3

NEW ANSWER(MySQL风格的动态SQL):好的,这个解决了另一个发布者描述的问题的方式-颠倒了获取互不兼容的互斥锁的顺序,因此无论发生多少,它们仅在在事务执行结束时花费最少的时间。

这是通过将语句的读取部分分成自己的select语句并动态生成一个delete语句来实现的,该delete语句由于语句出现的顺序而被迫最后运行,并且仅影响proc_warnings表。

可以在sql小提琴上找到一个演示:

链接显示了带有示例数据的模式,以及一个简单查询,查询与匹配的行ivehicle_id=2。结果是2行,因为它们都没有被删除。

链接显示相同的架构,样本数据,但将值2传递给DeleteEntries存储的程序,告诉SP删除的proc_warnings条目ivehicle_id=2。对行的简单查询不会返回任何结果,因为它们都已成功删除。该演示链接仅表明该代码可以删除。具有适当测试环境的用户可以评论这是否可以解决线程阻塞的问题。

为方便起见,以下代码也是如此:

CREATE PROCEDURE DeleteEntries (input_vid INT)
BEGIN

    SELECT @idstring:= '';
    SELECT @idnum:= 0;
    SELECT @del_stmt:= '';

    SELECT @idnum:= @idnum+1 idnum_col, @idstring:= CONCAT(@idstring, CASE WHEN CHARACTER_LENGTH(@idstring) > 0 THEN ',' ELSE '' END, CAST(id AS CHAR(10))) idstring_col
    FROM proc_warnings
    WHERE EXISTS (
        SELECT 0
        FROM day_position
        WHERE day_position.transaction_id = proc_warnings.transaction_id
        AND day_position.dirty_data = 1
        AND EXISTS (
            SELECT 0
            FROM ivehicle_days
            WHERE ivehicle_days.id = day_position.ivehicle_day_id
            AND ivehicle_days.ivehicle_id = input_vid
        )
    )
    ORDER BY idnum_col DESC
    LIMIT 1;

    IF (@idnum > 0) THEN
        SELECT @del_stmt:= CONCAT('DELETE FROM proc_warnings WHERE id IN (', @idstring, ');');

        PREPARE del_stmt_hndl FROM @del_stmt;
        EXECUTE del_stmt_hndl;
        DEALLOCATE PREPARE del_stmt_hndl;
    END IF;
END;

这是从事务中调用程序的语法:

CALL DeleteEntries(2);

原始答案(仍然认为它不太破旧)看起来像2个问题:1)查询缓慢2)意外的锁定行为

关于问题#1,慢速查询通常通过相同的两种技术来解决,例如串联查询语句简化和索引的有用添加或修改。您自己已经建立了与索引的连接-没有索引,优化器将无法搜索要处理的有限行,并且每个表中的每一行乘以每一额外的行就会扫描必须完成的额外工作量。

在查看架构和索引后进行了修订:但是,我想通过确保您具有良好的索引配置,您可以从查询中获得最大的性能优势。为此,您可以选择更好的删除性能,甚至可能需要更好的删除性能,但要在增加索引结构的同一个表之间进行权衡,选择较大的索引,并可能明显降低插入性能。

更好的东西:

CREATE TABLE  `day_position` (
    ...,
    KEY `day_position__id_rvrsd` (`dirty_data`, `ivehicle_day_id`)

) ;


CREATE TABLE  `ivehicle_days` (
    ...,
    KEY `ivehicle_days__vid_no_sort_index` (`ivehicle_id`)
);

再次修订:由于需要花费很长时间才能运行,所以我将把dirty_data保留在索引中,并且我也确定将其按索引顺序放在ivehicle_day_id之后时也弄错了,它应该是第一个。

但是,如果到那时我已经准备好了,因为必须要有大量的数据才能花费那么长的时间,所以我会选择所有涵盖的索引,以确保获得最佳的索引。如果没有其他方法可以排除一部分问题,我的故障排除时间可以解决。

最佳/覆盖指数:

CREATE TABLE  `day_position` (
    ...,
    KEY `day_position__id_rvrsd_trnsid_cvrng` (`dirty_data`, `ivehicle_day_id`, `transaction_id`)
) ;

CREATE TABLE  `ivehicle_days` (
    ...,
    UNIQUE KEY `ivehicle_days__vid_id_cvrng` (ivehicle_id, id)
);

CREATE TABLE  `proc_warnings` (

    .., /*rename primary key*/
    CONSTRAINT pk_proc_warnings PRIMARY KEY (id),
    UNIQUE KEY `proc_warnings__transaction_id_id_cvrng` (`transaction_id`, `id`)
);

最后两个更改建议寻求两个性能优化目标:
1)如果连续访问的表的搜索键与为当前访问的表返回的聚簇键结果不同,我们将不需要做聚簇索引的第二组带扫描的索引搜索操作
2)如果不是这种情况,则至少存在优化器可以选择更有效的联接算法的可能性,因为索引将保持所需的联接键按排序顺序。

您的查询似乎已尽可能简化(如果在以后进行编辑,请在此处复制):

DELETE pw 
FROM proc_warnings pw 
INNER JOIN day_position dp 
    ON dp.transaction_id = pw.transaction_id 
INNER JOIN ivehicle_days vd 
    ON vd.id = dp.ivehicle_day_id 
WHERE vd.ivehicle_id=2 AND dp.dirty_data=1;

除非当然有关于书面连接顺序的问题,否则会影响查询优化器的处理方式,在这种情况下,您可以尝试其他人提供的一些重写建议,包括可能带有索引提示的该重写建议(可选):

DELETE FROM proc_warnings
FORCE INDEX (`proc_warnings__transaction_id_id_cvrng`, `pk_proc_warnings`)
WHERE EXISTS (
    SELECT 0
    FROM day_position
    FORCE INDEX (`day_position__id_rvrsd_trnsid_cvrng`)  
    WHERE day_position.transaction_id = proc_warnings.transaction_id
    AND day_position.dirty_data = 1
    AND EXISTS (
        SELECT 0
        FROM ivehicle_days
        FORCE INDEX (`ivehicle_days__vid_id_cvrng`)  
        WHERE ivehicle_days.id = day_position.ivehicle_day_id
        AND ivehicle_days.ivehicle_id = ?
    )
);

关于#2,意外的锁定行为。

如我所见,这两个查询都希望在主键= 53的行上具有排它X锁。但是,它们都不必须从proc_warnings表中删除行。我只是不明白为什么索引被锁定了。

我猜这将是锁定的索引,因为要锁定的数据行在聚簇索引中,即,单行数据本身位于索引中。

它将被锁定,因为:
1)根据http://dev.mysql.com/doc/refman/5.1/en/innodb-locks-set.html

... DELETE通常会在处理SQL语句时扫描的每个索引记录上设置记录锁定。语句中是否存在排除行的条件并不重要。InnoDB不记得确切的WHERE条件,而只知道扫描了哪个索引范围。

您还在上面提到:

...对我而言,READ COMMITTED的主要功能是如何处理锁。它应该释放不匹配的行的索引锁,但是不会。

并为此提供了以下参考:http :
//dev.mysql.com/doc/refman/5.1/en/set-transaction.html#isolevel_read-committed

该状态与您相同,不同之处在于,根据该相同的引用,有一个条件可以解除锁定:

另外,在MySQL评估WHERE条件之后,将释放不匹配行的记录锁。

在本手册页http://dev.mysql.com/doc/refman/5.1/en/innodb-record-level-locks.html中也重申了这一点

使用READ COMMITTED隔离级别或启用innodb_locks_unsafe_for_binlog还有其他效果:MySQL评估WHERE条件后,将释放不匹配行的记录锁。

因此,我们被告知在释放锁之前必须先评估WHERE条件。不幸的是,我们没有被告知何时评估WHERE条件,它可能会从一个计划变为由优化程序创建的另一个计划。但是它确实告诉我们锁定释放在某种程度上取决于查询执行的性能,正如我们上面所讨论的那样,其优化取决于仔细地编写语句以及明智地使用索引。也可以通过更好的表格设计来改进它,但这可能最好留给一个单独的问题。

此外,当proc_warnings表为空时,索引也不会被锁定

如果没有记录,数据库将无法锁定索引中的记录。

此外,当... day_position表包含较少的行数(即一百行)时,索引未锁定。

这可能意味着许多事情,例如但可能不限于:由于统计信息的变化,执行计划不同;由于数据集//加入操作。


WHERE查询完成时评估条件。是不是 我认为在执行一些并发查询后立即释放锁定。那是自然的行为。但是,这不会发生。此线程中的建议查询都不能帮助避免聚集索引锁定在proc_warnings表中。我想我会向MySQL提交错误。谢谢你的帮助。
vitalidze 2012年

我也不希望他们也避免锁定行为。我希望它会锁定,因为我认为文档说明了这是所期望的,无论这是否是我们希望它处理查询的方式。我只希望摆脱性能问题将使并发查询在如此明显的时间(超过500秒的超时)内保持阻塞。
JM Hicks

尽管您的{WHERE}似乎可以在联接处理期间用来限制联接计算中包括哪些行,但直到整个联接集被确定后,我才能看到如何对每个锁定行求值{WHERE}子句计算也是如此。就是说,对于我们的分析,我怀疑您是对的,我们应该怀疑“查询完成时将评估WHERE条件”。但这使我得出相同的总体结论,即需要解决性能问题,然后表观并发度将成比例地增加。
JM Hicks

请记住,适当的索引可能会消除proc_warnings表上发生的任何全表扫描。为了做到这一点,我们需要查询优化器对我们很好地工作,并且我们需要索引,查询和数据与之很好地协作。参数值必须最后评估到目标表中在两个查询之间不重叠的行。索引需要为查询优化器提供一种有效搜索这些行的方法。我们需要优化器来实现潜在的搜索效率并选择这样的计划。
JM Hicks

如果在参数值,索引,proc_warnings表中的非重叠结果以及优化器计划选择之间一切正常,即使在为每个线程执行查询所需的时间内可能生成了锁,这些锁(如果不是)重叠,不会与其他线程的锁定请求冲突。
JM Hicks

3

我可以看到READ_COMMITTED如何导致这种情况。

READ_COMMITTED允许三件事:

  • 使用READ_COMMITTED隔离级别的其他事务提交的更改的可见性。
  • 不可重复读取:事务执行相同的检索,每次都有可能得到不同的结果。
  • 幻影:事务可能有行出现在事先看不到的地方。

这为事务本身创建了内部范例,因为事务必须与以下对象保持联系:

  • InnoDB缓冲池(尽管提交仍未取消)
  • 表的主键
  • 可能
    • 双写缓冲区
    • 撤消表空间
  • 绘画作品

如果两个不同的READ_COMMITTED事务正在访问以相同方式更新的相同表/行,请准备好不要在gen_clust_index(又称为集群索引)中使用表锁,而希望使用排他锁。考虑到您的简化案例的查询:

  • 交易1

    SET TRANSACTION ISOLATION LEVEL READ COMMITTED;
    SET AUTOCOMMIT=0;
    BEGIN;
    DELETE c FROM child c 
      INNER JOIN parent p ON p.id = c.parent_id 
    WHERE p.id = 1;
  • 交易2

    SET TRANSACTION ISOLATION LEVEL READ COMMITTED;
    SET AUTOCOMMIT=0;
    BEGIN;
    DELETE c FROM child c 
      INNER JOIN parent p ON p.id = c.parent_id 
    WHERE p.id = 2;

您正在gen_clust_index中锁定相同的位置。可能有人会说:“但是每笔交易都有不同的主键。” 不幸的是,在InnoDB眼中并非如此。碰巧id 1和id 2驻留在同一页面上。

回头看看information_schema.innodb_locks您在问题中提供的内容

| lock_id                | lock_trx_id | lock_mode | lock_type | lock_table | lock_index | lock_space | lock_page | lock_rec | lock_data |
| '1A2973A4:0:3172298:2' | '1A2973A4'  | 'X'       | 'RECORD'  | '`deadlock_test`.`proc_warnings`' | '`PRIMARY`' | '0' | '3172298' | '2' | '53' |
| '1A296F67:0:3172298:2' | '1A296F67'  | 'X'       | 'RECORD'  | '`deadlock_test`.`proc_warnings`' | '`PRIMARY`' | '0' | '3172298' | '2' | '53' |

lock_id,之外lock_trx_id,其余锁描述相同。由于事务处于同一级别的竞争环境(相同的事务隔离),因此确实应该发生这种情况

相信我,我以前已经解决过这种情况。这是我过去关于此的帖子:


我已经阅读了您在MySQL文档中描述的内容。但是对我而言,READ COMMITTED的主要功能是如何处理锁。它应该释放不匹配的行的索引锁,但是不会。
vitalidze 2012年

如果由于错误而仅回滚单个SQL语句,则可以保留该语句设置的某些锁。这是因为在一个格式的InnoDB存储行锁定,使其无法事后知道哪个锁由该语句设置:dev.mysql.com/doc/refman/5.5/en/innodb-deadlock-detection.html
RolandoMySQLDBA

请注意,我提到了同一页面中可能存在两行锁定的可能性(请参阅Look back at information_schema.innodb_locks you supplied in the Question
RolandoMySQLDBA 2012年

关于回滚单个语句-我理解这就像单个语句在单个事务中失败一样,它仍然可以持有锁。没关系。我的大问题是,为什么在成功处理DELETE语句后它不会释放不匹配的行锁。
vitalidze 2012年

有两个完整的锁,其中一个必须回滚。锁可能会持续存在。工作原理:回滚的事务可能会重试,并且可能会遇到之前持有该事务的旧锁。
RolandoMySQLDBA 2012年

2

我看着查询和解释。我不确定,但是有一种直觉,问题出在下面。让我们看一下查询:

DELETE pw 
FROM proc_warnings pw 
INNER JOIN day_position dp 
   ON dp.transaction_id = pw.transaction_id 
INNER JOIN ivehicle_days vd 
   ON vd.id = dp.ivehicle_day_id 
WHERE vd.ivehicle_id=? AND dp.dirty_data=1;

等效的SELECT是:

SELECT pw.id
FROM proc_warnings pw
INNER JOIN day_position dp
   ON dp.transaction_id = pw.transaction_id
INNER JOIN ivehicle_days vd
   ON vd.id = dp.ivehicle_day_id
WHERE vd.ivehicle_id=16 AND dp.dirty_data=1;

如果看一下它的解释,您会看到执行计划从proc_warnings表开始。这意味着MySQL扫描表中的主键,并针对每一行检查条件是否为true,如果为true,则删除该行。那就是MySQL必须锁定整个主键。

您需要的是反转JOIN顺序,即查找所有事务ID vd.ivehicle_id=16 AND dp.dirty_data=1并将它们加入proc_warnings表中。

那就是您将需要修补索引之一:

ALTER TABLE `day_position`
 DROP INDEX `day_position__id`,
 ADD INDEX `day_position__id`
   USING BTREE (`ivehicle_day_id`, `dirty_data`, `transaction_id`);

并重写删除查询:

DELETE pw
FROM (
  SELECT DISTINCT dp.transaction_id
  FROM ivehicle_days vd
  JOIN day_position dp ON dp.ivehicle_day_id = vd.id
  WHERE vd.ivehicle_id=? AND dp.dirty_data=1
) as tr_id
JOIN proc_warnings pw ON pw.transaction_id = tr_id.transaction_id;

不幸的是,这无济于事,即行中的行proc_warnings仍然被锁定。不管怎么说,还是要谢谢你。
vitalidze 2012年

2

如果您不按照自己的方式设置事务级别,则会将“已读提交”仅应用于下一个事务,从而(设置自动提交)。这意味着在autocommit = 0之后,您将不再处于Read Committed中。我会这样写:

SET TRANSACTION ISOLATION LEVEL READ COMMITTED;
START TRANSACTION;
DELETE c FROM child c
INNER JOIN parent p ON
    p.id = c.parent_id
WHERE p.id = 1;

您可以通过查询来检查您所处的隔离级别

SELECT @@tx_isolation;

这不是真的。为什么SET AUTOCOMMIT=0要为下一个事务重置隔离级别?我相信如果之前没有启动任何事务(我就是这种情况),它将启动新事务。因此,更确切地说,下一个START TRANSACTIONBEGIN语句不是必需的。我禁用自动提交的目的是在DELETE执行语句后使事务保持打开状态。
vitalidze 2012年

1
@SqlKiwi,这是编辑此帖子的方法,这是评论;-)的方法
jcolebrand
By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.