Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

High Database Load for Sales Rule Validation #19117

Closed
rauberdaniel opened this issue Nov 8, 2018 · 18 comments
Closed

High Database Load for Sales Rule Validation #19117

rauberdaniel opened this issue Nov 8, 2018 · 18 comments
Labels
Component: SalesRule Fixed in 2.2.x The issue has been fixed in 2.2 release line Fixed in 2.3.x The issue has been fixed in 2.3 release line help wanted Issue: Clear Description Gate 2 Passed. Manual verification of the issue description passed Issue: Confirmed Gate 3 Passed. Manual verification of the issue completed. Issue is confirmed Issue: Format is valid Gate 1 Passed. Automatic verification of issue format passed Issue: Ready for Work Gate 4. Acknowledged. Issue is added to backlog and ready for development Reproduced on 2.2.x The issue has been reproduced on latest 2.2 release Reproduced on 2.3.x The issue has been reproduced on latest 2.3 release

Comments

@rauberdaniel
Copy link

rauberdaniel commented Nov 8, 2018

Preconditions (*)

  1. Magento 2.2.5
  2. PHP 7.1 / MySQL 5.6.41

Steps to reproduce (*)

  1. Create multiple Sales Rules with multiple coupons each
  2. Get a lot of customers to redeem codes

Expected result (*)

  1. High Performance, as finding sales rules is fast using indexes on Database

Actual result (*)

  1. Incredibly Low Performance, as SQL query is badly constructed so it cannot use indexes

Description

We’re currently having about 400 sales rules (most of them are inactive or expired) as well as about 600.000 coupons. The query, that is currently being used by the sales-rule module is extremely inefficient on the database and therefore results in massive database CPU consumption when having several orders with coupons per minute. However, this query can greatly be optimized by splitting it into two queries.

The query that is generated by the setValidationFilter method in magento/vendor/magento/module-sales-rule/Model/ResourceModel/Rule/Collection.php currently looks like this:

SELECT `main_table`.*, `rule_coupons`.`code`
FROM `salesrule` AS `main_table`
INNER JOIN `salesrule_website` AS `website`
ON website.website_id IN ('1') AND main_table.rule_id = website.rule_id
INNER JOIN `salesrule_customer_group` AS `customer_group_ids`
ON main_table.rule_id = customer_group_ids.rule_id AND customer_group_ids.customer_group_id = 0
LEFT JOIN `salesrule_coupon` AS `rule_coupons` ON main_table.rule_id = rule_coupons.rule_id AND main_table.coupon_type != 1

WHERE (from_date is null or from_date <= '2018-11-01')
AND (to_date is null or to_date >= '2018-11-01')
AND (`is_active` = '1')
AND
(
    main_table.coupon_type = 1
    OR
    (
        (
            (main_table.coupon_type = 3 AND rule_coupons.type = 1)
            OR
            (main_table.coupon_type = 2 AND main_table.use_auto_generation = 1 AND rule_coupons.type = 1)
            OR
            (main_table.coupon_type = 2 AND main_table.use_auto_generation = 0 AND rule_coupons.type = 0)
        )
        AND
        rule_coupons.code = 'COUPONCODE'
        AND
        (rule_coupons.expiration_date IS NULL OR rule_coupons.expiration_date >= '2018-11-01')
    )
)
AND (`is_active` = '1') ORDER BY sort_order ASC;

The problematic part in this query is line 14 main_table.coupon_type = 1 OR. This part forces the database to filter the joined tables based on coupon_type. By simply removing that line, the rule_coupons.code can be used as an index to find exactly the one matching sales rule without having to join and filter a massive amount of rows. The line 14 is a completely separate case than the rest in that scope (line 13 to 29) and therefore should be treated separately e.g. by a second query in a UNION operation.

Some details using the MySQL EXPLAIN function:

Original Query:
image

Query without line 14/15 (not added in a second query in this example, therefore ignoring sales rules without coupons):
image

This is a major performance improvement especially for larger amounts of sales rules and coupons and absolutely critical.

@magento-engcom-team
Copy link
Contributor

Hi @rauberdaniel. Thank you for your report.
To help us process this issue please make sure that you provided the following information:

  • Summary of the issue
  • Information on your environment
  • Steps to reproduce
  • Expected and actual results

Please make sure that the issue is reproducible on the vanilla Magento instance following Steps to reproduce. To deploy vanilla Magento instance on our environment, please, add a comment to the issue:

@magento-engcom-team give me $VERSION instance

where $VERSION is version tags (starting from 2.2.0+) or develop branches (for example: 2.3-develop).
For more details, please, review the Magento Contributor Assistant documentation.

@rauberdaniel do you confirm that you was able to reproduce the issue on vanilla Magento instance following steps to reproduce?

  • yes
  • no

@magento-engcom-team magento-engcom-team added the Issue: Format is valid Gate 1 Passed. Automatic verification of issue format passed label Nov 8, 2018
@rauberdaniel
Copy link
Author

rauberdaniel commented Nov 8, 2018

To be a bit more precise, the alternative recommended query that has the equal result but split into two queries that are very fast because of index usage is:

SELECT `main_table`.*, `rule_coupons`.`code`
FROM `salesrule` AS `main_table`
INNER JOIN `salesrule_website` AS `website`
ON website.website_id IN ('1') AND main_table.rule_id = website.rule_id
INNER JOIN `salesrule_customer_group` AS `customer_group_ids`
ON main_table.rule_id = customer_group_ids.rule_id AND customer_group_ids.customer_group_id = 0
LEFT JOIN `salesrule_coupon` AS `rule_coupons` ON main_table.rule_id = rule_coupons.rule_id AND main_table.coupon_type != 1

WHERE (from_date is null or from_date <= '2018-11-08')
AND (to_date is null or to_date >= '2018-11-08')
AND (`is_active` = '1')
AND
   (
        (
            (main_table.coupon_type = 3 AND rule_coupons.type = 1)
            OR
            (main_table.coupon_type = 2 AND main_table.use_auto_generation = 1 AND rule_coupons.type = 1)
            OR
            (main_table.coupon_type = 2 AND main_table.use_auto_generation = 0 AND rule_coupons.type = 0)
        )
        AND
        rule_coupons.code = 'COUPONCODE'
        AND
        (rule_coupons.expiration_date IS NULL OR rule_coupons.expiration_date >= '2018-11-08')
    )
AND (`is_active` = '1')

UNION

SELECT `main_table`.*, NULL as code
FROM `salesrule` AS `main_table`
INNER JOIN `salesrule_website` AS `website`
ON website.website_id IN ('1') AND main_table.rule_id = website.rule_id
INNER JOIN `salesrule_customer_group` AS `customer_group_ids`
ON main_table.rule_id = customer_group_ids.rule_id AND customer_group_ids.customer_group_id = 0
WHERE (from_date is null or from_date <= '2018-11-08')
AND (to_date is null or to_date >= '2018-11-08')
AND (`is_active` = '1')
AND (main_table.coupon_type = 1)

ORDER BY sort_order ASC;

The first query will then use rule_coupons.code as the index to retrieve that one coupon as well as the respective sales rule, the second query has to filter the sales rules table which however is smaller than the coupon table (on any joined combination). In our case by a very big factor as one sales rule can have multiple thousands of individual coupons.

@orlangur
Copy link
Contributor

orlangur commented Nov 8, 2018

@rauberdaniel great finding and analysis! Are you interested in preparing a pull request maybe?

@orlangur orlangur added the Issue: Clear Description Gate 2 Passed. Manual verification of the issue description passed label Nov 8, 2018
@rauberdaniel
Copy link
Author

@orlangur I absolutely would, however, I’m not very into Magento code (or PHP at all) and the class that defines this query as well as the abstract are pretty confusing to me.

@orlangur
Copy link
Contributor

orlangur commented Nov 8, 2018

@rauberdaniel got it, no problem. Let's wait for a volunteer then.

@orlangur orlangur added help wanted Issue: Ready for Work Gate 4. Acknowledged. Issue is added to backlog and ready for development and removed Issue: Ready for Work Gate 4. Acknowledged. Issue is added to backlog and ready for development labels Nov 8, 2018
@ghost ghost self-assigned this Nov 9, 2018
@magento-engcom-team
Copy link
Contributor

magento-engcom-team commented Nov 9, 2018

Hi @engcom-backlog-nazar. Thank you for working on this issue.
In order to make sure that issue has enough information and ready for development, please read and check the following instruction: 👇

  • 1. Verify that issue has all the required information. (Preconditions, Steps to reproduce, Expected result, Actual result).

    DetailsIf the issue has a valid description, the label Issue: Format is valid will be added to the issue automatically. Please, edit issue description if needed, until label Issue: Format is valid appears.

  • 2. Verify that issue has a meaningful description and provides enough information to reproduce the issue. If the report is valid, add Issue: Clear Description label to the issue by yourself.

  • 3. Add Component: XXXXX label(s) to the ticket, indicating the components it may be related to.

  • 4. Verify that the issue is reproducible on 2.3-develop branch

    Details- Add the comment @magento-engcom-team give me 2.3-develop instance to deploy test instance on Magento infrastructure.
    - If the issue is reproducible on 2.3-develop branch, please, add the label Reproduced on 2.3.x.
    - If the issue is not reproducible, add your comment that issue is not reproducible and close the issue and stop verification process here!

  • 5. Verify that the issue is reproducible on 2.2-develop branch.

    Details- Add the comment @magento-engcom-team give me 2.2-develop instance to deploy test instance on Magento infrastructure.
    - If the issue is reproducible on 2.2-develop branch, please add the label Reproduced on 2.2.x

  • 6. Add label Issue: Confirmed once verification is complete.

  • 7. Make sure that automatic system confirms that report has been added to the backlog.

@ghost ghost added Reproduced on 2.2.x The issue has been reproduced on latest 2.2 release Reproduced on 2.3.x The issue has been reproduced on latest 2.3 release Component: SalesRule Issue: Confirmed Gate 3 Passed. Manual verification of the issue completed. Issue is confirmed labels Nov 9, 2018
@magento-engcom-team
Copy link
Contributor

@engcom-backlog-nazar Thank you for verifying the issue. Based on the provided information internal tickets MAGETWO-96241, MAGETWO-96242 were created

@magento-engcom-team magento-engcom-team added the Issue: Ready for Work Gate 4. Acknowledged. Issue is added to backlog and ready for development label Nov 9, 2018
@ghost ghost removed their assignment Nov 9, 2018
@progreg progreg self-assigned this Nov 16, 2018
@magento-engcom-team
Copy link
Contributor

magento-engcom-team commented Nov 16, 2018

Hi @progreg. Thank you for working on this issue.
Looks like this issue is already verified and confirmed. But if your want to validate it one more time, please, go though the following instruction:

  • 1. Add/Edit Component: XXXXX label(s) to the ticket, indicating the components it may be related to.

  • 2. Verify that the issue is reproducible on 2.3-develop branch

    Details- Add the comment @magento-engcom-team give me 2.3-develop instance to deploy test instance on Magento infrastructure.
    - If the issue is reproducible on 2.3-develop branch, please, add the label Reproduced on 2.3.x.
    - If the issue is not reproducible, add your comment that issue is not reproducible and close the issue and stop verification process here!

  • 3. Verify that the issue is reproducible on 2.2-develop branch.

    Details- Add the comment @magento-engcom-team give me 2.2-develop instance to deploy test instance on Magento infrastructure.
    - If the issue is reproducible on 2.2-develop branch, please add the label Reproduced on 2.2.x

  • 4. If the issue is not relevant or is not reproducible any more, feel free to close it.

@progreg
Copy link
Contributor

progreg commented Nov 20, 2018

Hey @orlangur,

I already have some working solution, but I'm not sure about an extension point that I've used.
My solution is to clone select in beforeLoad method and filter all no needed conditions to prepare select for the union. The reason why I choose such a strategy is not crash custom conditions that can be added manually to origin select.

All conditions that should be deleted in cloned selectwould be marked by /*@OPTIONAL*/ comment, for example:

$select->where(
    '/*@OPTIONAL*/ (' . $orWhereCondition . ') AND ' . $andWhereCondition,
    null,
    Select::TYPE_CONDITION
);

So in result i have such beforeLoad method in Magento\SalesRule\Model\ResourceModel\Rule\Collection.

protected function _beforeLoad()
{
    if ($this->getFlag('validation_filter')) {
        $connection = $this->getConnection();
        $noCouponWhereCondition = $connection->quoteInto(
            'main_table.coupon_type = ? ',
            \Magento\SalesRule\Model\Rule::COUPON_TYPE_NO_COUPON
        );

        $origin = $this->_select;
        $clone = clone $this->_select;
        $clone->where($noCouponWhereCondition);

        // Remove coupon join
        $fromPart = array_filter(
            $clone->getPart(Select::FROM),
            function ($item) {
                if ($item['joinType'] == 'left join' && $item['tableName'] == 'salesrule_coupon') {
                    return false;
                }
                return $item;
            }
        );
        $clone->setPart(Select::FROM, $fromPart);

        // Replace coupon code column with NULL value
        $columnsPart = array_map(
            function ($item) use ($connection) {
                if ($item[0] == 'rule_coupons' && $item[1] == 'code') {
                    $item[1] = new Zend_Db_Expr('NULL as ' . $connection->quote('code'));
                }
                return $item;
            },
            $clone->getPart(Select::COLUMNS)
        );
        $clone->setPart(Select::COLUMNS, $columnsPart);

        // Remove where conditions related to coupon_type
        $wherePart = array_filter(
            $clone->getPart(Select::WHERE),
            function ($item) {
                if (preg_match('/\*@OPTIONAL\*/', $item)) {
                    return false;
                }
                return $item;
            }
        );
        $clone->setPart(Select::WHERE, $wherePart);

        // Apply UNION
        $this->_select = $this->getConnection()->select();
        $this->_select->union([$origin, $clone]);
    }
    return parent::_beforeLoad();
}

This is just an idea of concrete realization, that's why I first wanna ask you what is the best solution for this issue in your opinion. Also please check maybe I miss something.

Query Before:

SELECT `main_table`.*, `rule_coupons`.`code`
FROM `salesrule` AS `main_table`
       INNER JOIN `salesrule_website` AS `website` ON website.website_id IN (1) AND main_table.rule_id = website.rule_id
       INNER JOIN `salesrule_customer_group` AS `customer_group_ids`
         ON main_table.rule_id = customer_group_ids.rule_id AND customer_group_ids.customer_group_id = 1
       LEFT JOIN `salesrule_coupon` AS `rule_coupons`
         ON main_table.rule_id = rule_coupons.rule_id AND main_table.coupon_type != 1
WHERE (from_date is null or from_date <= '2018-11-20')
  AND (to_date is null or to_date >= '2018-11-20')
  AND (`is_active` = '1')
  AND (/*@OPTIONAL*/
          (
              (main_table.coupon_type = 3 AND rule_coupons.type = 1)
                OR
              (main_table.coupon_type = 2 AND main_table.use_auto_generation = 1 AND rule_coupons.type = 1)
                OR
              (main_table.coupon_type = 2 AND main_table.use_auto_generation = 0 AND rule_coupons.type = 0)
              )
            AND
          rule_coupons.code = 'asdasdasdasd'
            AND
          (rule_coupons.expiration_date IS NULL OR rule_coupons.expiration_date >= '2018-11-20')
          )

Query After:

SELECT `main_table`.*, `rule_coupons`.`code`
FROM `salesrule` AS `main_table`
       INNER JOIN `salesrule_website` AS `website` ON website.website_id IN (1) AND main_table.rule_id = website.rule_id
       INNER JOIN `salesrule_customer_group` AS `customer_group_ids`
         ON main_table.rule_id = customer_group_ids.rule_id AND customer_group_ids.customer_group_id = 1
       LEFT JOIN `salesrule_coupon` AS `rule_coupons`
         ON main_table.rule_id = rule_coupons.rule_id AND main_table.coupon_type != 1
WHERE (from_date is null or from_date <= '2018-11-20')
  AND (to_date is null or to_date >= '2018-11-20')
  AND (`is_active` = '1')
  AND (/*@OPTIONAL*/
          (
              (main_table.coupon_type = 3 AND rule_coupons.type = 1) 
                 OR
              (main_table.coupon_type = 2 AND main_table.use_auto_generation = 1 AND rule_coupons.type = 1) 
                 OR
              (main_table.coupon_type = 2 AND main_table.use_auto_generation = 0 AND rule_coupons.type = 0)
          ) 
          AND
          rule_coupons.code = 'asdasdasdasd' 
          AND
         (rule_coupons.expiration_date IS NULL OR rule_coupons.expiration_date >= '2018-11-20')
   )
UNION
SELECT `main_table`.*, NULL as 'code'
FROM `salesrule` AS `main_table`
       INNER JOIN `salesrule_website` AS `website` ON website.website_id IN (1) AND main_table.rule_id = website.rule_id
       INNER JOIN `salesrule_customer_group` AS `customer_group_ids`
         ON main_table.rule_id = customer_group_ids.rule_id AND customer_group_ids.customer_group_id = 1
WHERE (from_date is null or from_date <= '2018-11-20')
  AND (to_date is null or to_date >= '2018-11-20')
  AND (`is_active` = '1')
  AND (main_table.coupon_type = 1)

@progreg
Copy link
Contributor

progreg commented Dec 18, 2018

Hey @orlangur. Can you give me any feedback, is my solution may be good? Or I should add PR first?

@progreg progreg pinned this issue Jan 21, 2019
@ajay2jcommerce ajay2jcommerce unpinned this issue Jan 21, 2019
david-fuehr added a commit to david-fuehr/magento2 that referenced this issue Jan 22, 2019
Github Issue: magento#19117

Refactored sql query that created a huge temporary table for each request, when a greater amount of salesrules
and coupon codes exists in database. The sorting of this table took a lot of cpu time.
The statement now consists of two subselects that drill down the remaining lines as far as possible, so that
the remaining temporary table is minimal and easily sorted.

example:
for 2,000 salesrules and 3,000,000 coupon codes the original query took about 2.4 seconds (mbp, server, aws).
the optimized query takes about 5ms (about 100ms on aws).
@rauberdaniel
Copy link
Author

@david-fuehr Thank you very much for your work on this issue!

@rauberdaniel
Copy link
Author

@progreg Thank you for your work as well! Sorry to see you didn’t get any feedback on your work (which I’m unable to give due to little knowledge over Magento).

@progreg
Copy link
Contributor

progreg commented Jan 23, 2019

@rauberdaniel, At least I've tried) Anyway, if a solution of @david-fuehr will be better then all is good.

@david-fuehr
Copy link
Contributor

@rauberdaniel @progreg Thanks for the report and your thoughts on this issue. With that, we were able to quickly solve that issue as we encountered it.
And as we have a working solution, I found a contribution appropriate ;)

@progreg progreg removed their assignment Jan 23, 2019
@magento-engcom-team magento-engcom-team added the Fixed in 2.3.x The issue has been fixed in 2.3 release line label Mar 8, 2019
david-fuehr added a commit to david-fuehr/magento2 that referenced this issue Mar 11, 2019
Github Issue: magento#19117

Refactored sql query that created a huge temporary table for each request, when a greater amount of salesrules
and coupon codes exists in database. The sorting of this table took a lot of cpu time.
The statement now consists of two subselects that drill down the remaining lines as far as possible, so that
the remaining temporary table is minimal and easily sorted.

example:
for 2,000 salesrules and 3,000,000 coupon codes the original query took about 2.4 seconds (mbp, server, aws).
the optimized query takes about 5ms (about 100ms on aws).
@magento-engcom-team
Copy link
Contributor

@magento-engcom-team magento-engcom-team added the Fixed in 2.2.x The issue has been fixed in 2.2 release line label Mar 14, 2019
@cesmendez
Copy link

Hello, I realize that this post is now closed. But do you all know if there is a similar solution for M1 EE?

@david-fuehr
Copy link
Contributor

Hello @cesmendez,

I don't believe that someone implemented this (or a similar) fix for M1. But as the code hardly changed, you could try to apply these changes: https://github.com/magento/magento2/pull/20484/files to magento/app/code/core/Mage/SalesRule/Model/Resource/Rule/Collection.php

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Component: SalesRule Fixed in 2.2.x The issue has been fixed in 2.2 release line Fixed in 2.3.x The issue has been fixed in 2.3 release line help wanted Issue: Clear Description Gate 2 Passed. Manual verification of the issue description passed Issue: Confirmed Gate 3 Passed. Manual verification of the issue completed. Issue is confirmed Issue: Format is valid Gate 1 Passed. Automatic verification of issue format passed Issue: Ready for Work Gate 4. Acknowledged. Issue is added to backlog and ready for development Reproduced on 2.2.x The issue has been reproduced on latest 2.2 release Reproduced on 2.3.x The issue has been reproduced on latest 2.3 release
Projects
None yet
Development

No branches or pull requests

6 participants