Without a persistent object cache, a transient is two rows in wp_options — the value and a timeout — and expiry is checked nowhere except inside get_transient(), which deletes the pair when it finds them stale. A transient whose key was built from a search term or a product id is therefore never read again, and never deleted either.
SELECT COUNT(*), ROUND( SUM( LENGTH( option_value ) ) / 1024 / 1024, 1 ) AS mb
FROM wp_options
WHERE option_name LIKE '_transient_%';
DELETE v, t
FROM wp_options v
JOIN wp_options t
ON t.option_name = CONCAT( '_transient_timeout_', SUBSTRING( v.option_name, 12 ) )
WHERE v.option_name LIKE '_transient_%'
AND t.option_value < UNIX_TIMESTAMP();
Nothing in core sweeps these, so the options table on a busy site is routinely more dead transients than live options. The second row matters for a different reason: a transient set with an expiry is stored with autoload = no, while one set with an expiry of 0 is autoloaded and therefore read into memory on every single request, forever. That is the more expensive of the two mistakes. The DELETE above is maintenance, not a fix — the fix is a persistent object cache, which moves transients out of MySQL entirely and gives them eviction that actually happens. Where that is not available, keep the key space bounded: a transient per search term is unbounded by definition.